chrome-devtools-frontend
Version:
Chrome DevTools UI
553 lines (490 loc) • 20.1 kB
text/typescript
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable rulesdir/no-imperative-dom-api */
import * as Common from '../../../../core/common/common.js';
import * as Platform from '../../../../core/platform/platform.js';
import * as RenderCoordinator from '../../../components/render_coordinator/render_coordinator.js';
import * as UI from '../../legacy.js';
import chartViewPortStyles from './chartViewport.css.js';
import {MinimalTimeWindowMs} from './FlameChart.js';
export interface ChartViewportDelegate {
windowChanged(startTime: number, endTime: number, animate: boolean): void;
updateRangeSelection(startTime: number, endTime: number): void;
setSize(width: number, height: number): void;
update(): void;
}
export interface Config {
/**
* Configures if the Chart should show a vertical line at the position of the
* mouse cursor when the user holds the `Shift` key.
* The reason this is configurable is because within the Performance Panel
* we use our own overlays system for UI like this, so we do not need the
* ChartViewport to manage it.
*/
enableCursorElement: boolean;
}
export class ChartViewport extends UI.Widget.VBox {
private readonly delegate: ChartViewportDelegate;
viewportElement: HTMLElement;
private alwaysShowVerticalScrollInternal: boolean;
private rangeSelectionEnabled: boolean;
private vScrollElement: HTMLElement;
private vScrollContent: HTMLElement;
private readonly selectionOverlay: HTMLElement;
private cursorElement: HTMLElement;
private isDraggingInternal!: boolean;
private totalHeight!: number;
private offsetHeight!: number;
private scrollTop!: number;
private rangeSelectionStart: number|null;
private rangeSelectionEnd: number|null;
private dragStartPointX!: number;
private dragStartPointY!: number;
private dragStartScrollTop!: number;
private visibleLeftTime!: number;
private visibleRightTime!: number;
private offsetWidth!: number;
private targetLeftTime!: number;
private targetRightTime!: number;
private selectionOffsetShiftX!: number;
private selectionStartX!: number|null;
private lastMouseOffsetX?: number;
private minimumBoundary!: number;
private totalTime!: number;
private isUpdateScheduled?: boolean;
private cancelWindowTimesAnimation?: (() => void)|null;
#config: Config;
constructor(delegate: ChartViewportDelegate, config: Config) {
super();
this.#config = config;
this.registerRequiredCSS(chartViewPortStyles);
this.delegate = delegate;
this.viewportElement = this.contentElement.createChild('div', 'fill');
this.viewportElement.addEventListener('mousemove', this.updateCursorPosition.bind(this), false);
this.viewportElement.addEventListener('mouseout', this.onMouseOut.bind(this), false);
this.viewportElement.addEventListener('wheel', this.onMouseWheel.bind(this), false);
this.viewportElement.addEventListener('keydown', this.onChartKeyDown.bind(this), false);
this.viewportElement.addEventListener('keyup', this.onChartKeyUp.bind(this), false);
UI.UIUtils.installDragHandle(
this.viewportElement, this.startDragging.bind(this), this.dragging.bind(this), this.endDragging.bind(this),
'-webkit-grabbing', null);
UI.UIUtils.installDragHandle(
this.viewportElement, this.startRangeSelection.bind(this), this.rangeSelectionDragging.bind(this),
this.endRangeSelection.bind(this), 'text', null);
this.alwaysShowVerticalScrollInternal = false;
this.rangeSelectionEnabled = true;
this.vScrollElement = this.contentElement.createChild('div', 'chart-viewport-v-scroll');
this.vScrollContent = this.vScrollElement.createChild('div');
this.vScrollElement.addEventListener('scroll', this.onScroll.bind(this), false);
this.selectionOverlay = this.contentElement.createChild('div', 'chart-viewport-selection-overlay hidden');
this.cursorElement = this.contentElement.createChild('div', 'chart-cursor-element hidden');
this.reset();
this.rangeSelectionStart = null;
this.rangeSelectionEnd = null;
}
alwaysShowVerticalScroll(): void {
this.alwaysShowVerticalScrollInternal = true;
this.vScrollElement.classList.add('always-show-scrollbar');
}
disableRangeSelection(): void {
this.rangeSelectionEnabled = false;
this.rangeSelectionStart = null;
this.rangeSelectionEnd = null;
}
isDragging(): boolean {
return this.isDraggingInternal;
}
override elementsToRestoreScrollPositionsFor(): Element[] {
return [this.vScrollElement];
}
private updateScrollBar(): void {
const showScroll = this.alwaysShowVerticalScrollInternal || this.totalHeight > this.offsetHeight;
if (this.vScrollElement.classList.contains('hidden') !== showScroll) {
return;
}
this.vScrollElement.classList.toggle('hidden', !showScroll);
this.updateContentElementSize();
}
override onResize(): void {
this.updateScrollBar();
this.updateContentElementSize();
this.scheduleUpdate();
}
reset(): void {
this.vScrollElement.scrollTop = 0;
this.scrollTop = 0;
this.rangeSelectionStart = null;
this.rangeSelectionEnd = null;
this.isDraggingInternal = false;
this.dragStartPointX = 0;
this.dragStartPointY = 0;
this.dragStartScrollTop = 0;
this.visibleLeftTime = 0;
this.visibleRightTime = 0;
this.offsetWidth = 0;
this.offsetHeight = 0;
this.totalHeight = 0;
this.targetLeftTime = 0;
this.targetRightTime = 0;
this.isUpdateScheduled = false;
this.updateContentElementSize();
}
private updateContentElementSize(): void {
let offsetWidth: number = this.vScrollElement.offsetLeft;
if (!offsetWidth) {
offsetWidth = this.contentElement.offsetWidth;
}
this.offsetWidth = offsetWidth;
this.offsetHeight = this.contentElement.offsetHeight;
this.delegate.setSize(this.offsetWidth, this.offsetHeight);
}
setContentHeight(totalHeight: number): void {
this.totalHeight = totalHeight;
this.vScrollContent.style.height = totalHeight + 'px';
this.updateScrollBar();
this.updateContentElementSize();
if (this.scrollTop + this.offsetHeight <= totalHeight) {
return;
}
this.scrollTop = Math.max(0, totalHeight - this.offsetHeight);
this.vScrollElement.scrollTop = this.scrollTop;
}
/**
* @param centered - If true, scrolls offset to where it is centered on the chart,
* based on current the this.offsetHeight value.
*/
setScrollOffset(offset: number, height?: number, centered?: boolean): void {
height = height || 0;
if (centered) {
// Half of the height for padding.
const halfPadding = Math.floor(this.offsetHeight / 2);
if (this.vScrollElement.scrollTop > offset) {
// Need to scroll up, include height.
this.vScrollElement.scrollTop = offset - (height + halfPadding);
}
} else if (this.vScrollElement.scrollTop > offset) {
this.vScrollElement.scrollTop = offset;
}
if (this.vScrollElement.scrollTop < offset - this.offsetHeight + height) {
this.vScrollElement.scrollTop = offset - this.offsetHeight + height;
}
}
scrollOffset(): number {
// Return the cached value, rather than the live value (which typically incurs a forced reflow)
// In practice, this is true whenever scrollOffset() is called: `this.scrollTop === this.vScrollElement.scrollTop`
return this.scrollTop;
}
chartHeight(): number {
return this.offsetHeight;
}
setBoundaries(zeroTime: number, totalTime: number): void {
this.minimumBoundary = zeroTime;
this.totalTime = totalTime;
}
/**
* The mouse wheel can results in flamechart zoom, scroll and pan actions, depending on the scroll deltas and the selected navigation:
*
* Classic navigation:
* 1. Mouse Wheel --> Zoom
* 2. Mouse Wheel + Shift --> Scroll
* 3. Trackpad: Mouse Wheel AND horizontal scroll (deltaX > deltaY): --> Pan left/right
*
* Modern navigation:
* 1. Mouse Wheel -> Scroll
* 2. Mouse Wheel + Shift -> Pan left/right
* 3. Mouse Wheel + Ctrl/Cmd -> Zoom
* 4. Trackpad: Mouse Wheel AND horizontal scroll (deltaX > deltaY): --> Zoom
*/
private onMouseWheel(wheelEvent: WheelEvent): void {
const navigation = Common.Settings.Settings.instance().moduleSetting('flamechart-selected-navigation').get();
// Delta for navigation left, right, up and down.
// Calculated from horizontal or vertical scroll delta, depending on which one exists.
const panDelta = (wheelEvent.deltaY || wheelEvent.deltaX) / 53 * this.offsetHeight / 8;
const zoomDelta = Math.pow(1.2, (wheelEvent.deltaY || wheelEvent.deltaX) * 1 / 53) - 1;
if (navigation === 'classic') {
if (wheelEvent.shiftKey) { // Scroll
this.vScrollElement.scrollTop += panDelta;
} else if (
Math.abs(wheelEvent.deltaX) > Math.abs(wheelEvent.deltaY)) { // Pan left/right on trackpad horizontal scroll
// Horizontal scroll on the trackpad feels smoother when only deltaX is taken into account
this.handleHorizontalPanGesture(wheelEvent.deltaX, /* animate */ true);
} else { // Zoom
this.handleZoomGesture(zoomDelta);
}
} else if (navigation === 'modern') {
const isCtrlOrCmd = UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(wheelEvent);
if (wheelEvent.shiftKey) { // Pan left/right
this.handleHorizontalPanGesture(panDelta, /* animate */ true);
} else if (
Math.abs(wheelEvent.deltaX) > Math.abs(wheelEvent.deltaY)) { // Pan left/right on trackpad horizontal scroll
// Horizontal scroll on the trackpad feels smoother when only deltaX is taken into account
this.handleHorizontalPanGesture(wheelEvent.deltaX, /* animate */ true);
} else if (isCtrlOrCmd) { // Zoom
this.handleZoomGesture(zoomDelta);
} else { // Scroll
this.vScrollElement.scrollTop += panDelta;
}
}
// Block swipe gesture.
wheelEvent.consume(true);
}
private startDragging(event: MouseEvent): boolean {
if (event.shiftKey) {
return false;
}
this.isDraggingInternal = true;
this.dragStartPointX = event.pageX;
this.dragStartPointY = event.pageY;
this.dragStartScrollTop = this.vScrollElement.scrollTop;
this.viewportElement.style.cursor = '';
return true;
}
private dragging(event: MouseEvent): void {
const pixelShift = this.dragStartPointX - event.pageX;
this.dragStartPointX = event.pageX;
this.handleHorizontalPanGesture(pixelShift);
const pixelScroll = this.dragStartPointY - event.pageY;
this.vScrollElement.scrollTop = this.dragStartScrollTop + pixelScroll;
}
private endDragging(): void {
this.isDraggingInternal = false;
}
private startRangeSelection(event: MouseEvent): boolean {
if (!event.shiftKey || !this.rangeSelectionEnabled) {
return false;
}
this.isDraggingInternal = true;
this.selectionOffsetShiftX = event.offsetX - event.pageX;
this.selectionStartX = event.offsetX;
return true;
}
private endRangeSelection(): void {
this.isDraggingInternal = false;
this.selectionStartX = null;
}
hideRangeSelection(): void {
this.selectionOverlay.classList.add('hidden');
this.rangeSelectionStart = null;
this.rangeSelectionEnd = null;
}
/**
* @param startTime - the start time of the selection in MilliSeconds
* @param endTime - the end time of the selection in MilliSeconds
* TODO(crbug.com/346312365): update the type definitions in ChartViewport.ts
*/
setRangeSelection(startTime: number, endTime: number): void {
if (!this.rangeSelectionEnabled) {
return;
}
this.rangeSelectionStart = Math.min(startTime, endTime);
this.rangeSelectionEnd = Math.max(startTime, endTime);
this.delegate.updateRangeSelection(this.rangeSelectionStart, this.rangeSelectionEnd);
}
onClick(event: Event): void {
const mouseEvent = (event as MouseEvent);
const time = this.pixelToTime(mouseEvent.offsetX);
if (this.rangeSelectionStart !== null && this.rangeSelectionEnd !== null && time >= this.rangeSelectionStart &&
time <= this.rangeSelectionEnd) {
return;
}
this.hideRangeSelection();
}
private rangeSelectionDragging(event: MouseEvent): void {
const x = Platform.NumberUtilities.clamp(event.pageX + this.selectionOffsetShiftX, 0, this.offsetWidth);
const start = this.pixelToTime(this.selectionStartX || 0);
const end = this.pixelToTime(x);
this.setRangeSelection(start, end);
}
private onScroll(): void {
this.scrollTop = this.vScrollElement.scrollTop;
this.scheduleUpdate();
}
private onMouseOut(): void {
this.lastMouseOffsetX = -1;
this.showCursor(false);
}
private updateCursorPosition(e: Event): void {
const mouseEvent = (e as MouseEvent);
this.lastMouseOffsetX = mouseEvent.offsetX;
const shouldShowCursor = this.#config.enableCursorElement && mouseEvent.shiftKey && !mouseEvent.metaKey;
this.showCursor(shouldShowCursor);
if (shouldShowCursor) {
this.cursorElement.style.left = mouseEvent.offsetX + 'px';
}
}
pixelToTime(x: number): number {
return this.pixelToTimeOffset(x) + this.visibleLeftTime;
}
pixelToTimeOffset(x: number): number {
return x * (this.visibleRightTime - this.visibleLeftTime) / this.offsetWidth;
}
timeToPosition(time: number): number {
return Math.floor(
(time - this.visibleLeftTime) / (this.visibleRightTime - this.visibleLeftTime) * this.offsetWidth);
}
timeToPixel(): number {
return this.offsetWidth / (this.visibleRightTime - this.visibleLeftTime);
}
private showCursor(visible: boolean): void {
this.cursorElement.classList.toggle('hidden', !visible || this.isDraggingInternal);
}
private onChartKeyDown(keyboardEvent: KeyboardEvent): void {
this.showCursor(keyboardEvent.shiftKey);
this.handleZoomPanScrollKeys(keyboardEvent);
}
private onChartKeyUp(keyboardEvent: KeyboardEvent): void {
this.showCursor(keyboardEvent.shiftKey);
}
private handleZoomPanScrollKeys(keyboardEvent: KeyboardEvent): void {
// Do not zoom, pan or scroll if the key combination has any modifiers other than shift key
if (UI.KeyboardShortcut.KeyboardShortcut.hasAtLeastOneModifier(keyboardEvent) && !keyboardEvent.shiftKey) {
return;
}
const zoomFactor = keyboardEvent.shiftKey ? 0.8 : 0.3;
const panOffset = 160;
const scrollOffset = 50;
switch (keyboardEvent.code) {
case 'KeyA':
this.handleHorizontalPanGesture(-panOffset, /* animate */ true);
break;
case 'KeyD':
this.handleHorizontalPanGesture(panOffset, /* animate */ true);
break;
case 'Equal': // '+' key for zoom in
case 'KeyW':
this.handleZoomGesture(-zoomFactor);
break;
case 'Minus': // '-' key for zoom out
case 'KeyS':
this.handleZoomGesture(zoomFactor);
break;
case 'ArrowUp':
if (keyboardEvent.shiftKey) {
this.vScrollElement.scrollTop -= scrollOffset;
}
break;
case 'ArrowDown':
if (keyboardEvent.shiftKey) {
this.vScrollElement.scrollTop += scrollOffset;
}
break;
case 'ArrowLeft':
if (keyboardEvent.shiftKey) {
this.handleHorizontalPanGesture(-panOffset, /* animate */ true);
}
break;
case 'ArrowRight':
if (keyboardEvent.shiftKey) {
this.handleHorizontalPanGesture(panOffset, /* animate */ true);
}
break;
default:
return;
}
keyboardEvent.consume(true);
}
private handleZoomGesture(zoom: number): void {
const bounds = {left: this.targetLeftTime, right: this.targetRightTime};
// If the user has not moved their mouse over the panel (unlikely but
// possible!), the offsetX will be undefined. In that case, let's just use
// the minimum time / pixel 0 as their mouse point.
const cursorTime = this.pixelToTime(this.lastMouseOffsetX || 0);
bounds.left += (bounds.left - cursorTime) * zoom;
bounds.right += (bounds.right - cursorTime) * zoom;
this.requestWindowTimes(bounds, /* animate */ true);
}
private handleHorizontalPanGesture(offset: number, animate?: boolean): void {
const bounds = {left: this.targetLeftTime, right: this.targetRightTime};
const timeOffset = Platform.NumberUtilities.clamp(
this.pixelToTimeOffset(offset), this.minimumBoundary - bounds.left,
this.totalTime + this.minimumBoundary - bounds.right);
bounds.left += timeOffset;
bounds.right += timeOffset;
this.requestWindowTimes(bounds, Boolean(animate));
}
private requestWindowTimes(
bounds: {
left: number,
right: number,
},
animate: boolean): void {
const maxBound = this.minimumBoundary + this.totalTime;
if (bounds.left < this.minimumBoundary) {
bounds.right = Math.min(bounds.right + this.minimumBoundary - bounds.left, maxBound);
bounds.left = this.minimumBoundary;
} else if (bounds.right > maxBound) {
bounds.left = Math.max(bounds.left - bounds.right + maxBound, this.minimumBoundary);
bounds.right = maxBound;
}
if (bounds.right - bounds.left < MinimalTimeWindowMs) {
return;
}
this.delegate.windowChanged(bounds.left, bounds.right, animate);
}
scheduleUpdate(): void {
if (this.cancelWindowTimesAnimation || this.isUpdateScheduled) {
return;
}
this.isUpdateScheduled = true;
void RenderCoordinator.write(() => {
this.isUpdateScheduled = false;
this.update();
});
}
update(): void {
this.delegate.update();
}
override willHide(): void {
// Stop animations when the view is hidden (or destroyed).
// In this case, we also jump the time immediately to the target time, so
// that if the view is restored, the time shown is correct.
if (this.cancelWindowTimesAnimation) {
this.cancelWindowTimesAnimation();
this.setWindowTimes(this.targetLeftTime, this.targetRightTime, false);
}
}
setWindowTimes(startTime: number, endTime: number, animate?: boolean): void {
if (startTime === this.targetLeftTime && endTime === this.targetRightTime) {
return;
}
if (!animate || this.visibleLeftTime === 0 || this.visibleRightTime === Infinity ||
(startTime === 0 && endTime === Infinity) || (startTime === Infinity && endTime === Infinity)) {
// Skip animation, move instantly.
this.targetLeftTime = startTime;
this.targetRightTime = endTime;
this.visibleLeftTime = startTime;
this.visibleRightTime = endTime;
this.scheduleUpdate();
return;
}
if (this.cancelWindowTimesAnimation) {
this.cancelWindowTimesAnimation();
this.visibleLeftTime = this.targetLeftTime;
this.visibleRightTime = this.targetRightTime;
}
this.targetLeftTime = startTime;
this.targetRightTime = endTime;
this.cancelWindowTimesAnimation = UI.UIUtils.animateFunction(
this.element.window(), animateWindowTimes.bind(this),
[{from: this.visibleLeftTime, to: startTime}, {from: this.visibleRightTime, to: endTime}], 100, () => {
this.cancelWindowTimesAnimation = null;
});
function animateWindowTimes(this: ChartViewport, startTime: number, endTime: number): void {
// We cancel the animation in the willHide method, but as an extra check
// bail here if we are hidden rather than queue an update.
if (!this.isShowing()) {
return;
}
this.visibleLeftTime = startTime;
this.visibleRightTime = endTime;
this.update();
}
}
windowLeftTime(): number {
return this.visibleLeftTime;
}
windowRightTime(): number {
return this.visibleRightTime;
}
}