// Copyright (C) 2018 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import {Animation} from './animation'; import Timer = NodeJS.Timer; import {DragGestureHandler} from './drag_gesture_handler'; import {globals} from './globals'; import {handleKey} from './keyboard_event_handler'; import {TRACK_SHELL_WIDTH} from './track_constants'; const ZOOM_RATIO_PER_FRAME = 0.008; const KEYBOARD_PAN_PX_PER_FRAME = 8; const HORIZONTAL_WHEEL_PAN_SPEED = 1; const WHEEL_ZOOM_SPEED = -0.02; // Usually, animations are cancelled on keyup. However, in case the keyup // event is not captured by the document, e.g. if it loses focus first, then // we want to stop the animation as soon as possible. const ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS = 700; // This value must be larger than the maximum delta between keydown repeat // events. Largest observed value so far: 86ms. const ANIMATION_AUTO_END_AFTER_KEYPRESS_MS = 100; // This defines the step size for an individual pan or zoom keyboard tap. const TAP_ANIMATION_TIME = 200; enum Pan { None = 0, Left = -1, Right = 1 } function keyToPan(e: KeyboardEvent): Pan { if (['a'].includes(e.key)) return Pan.Left; if (['d'].includes(e.key)) return Pan.Right; return Pan.None; } enum Zoom { None = 0, In = 1, Out = -1 } function keyToZoom(e: KeyboardEvent): Zoom { if (['w'].includes(e.key)) return Zoom.In; if (['s'].includes(e.key)) return Zoom.Out; return Zoom.None; } /** * Enables horizontal pan and zoom with mouse-based drag and WASD navigation. */ export class PanAndZoomHandler { private mousePositionX: number|null = null; private boundOnMouseMove = this.onMouseMove.bind(this); private boundOnWheel = this.onWheel.bind(this); private boundOnKeyDown = this.onKeyDown.bind(this); private boundOnKeyUp = this.onKeyUp.bind(this); private shiftDown = false; private dragStartPx = -1; private panning: Pan = Pan.None; private zooming: Zoom = Zoom.None; private cancelPanTimeout?: Timer; private cancelZoomTimeout?: Timer; private panAnimation = new Animation(this.onPanAnimationStep.bind(this)); private zoomAnimation = new Animation(this.onZoomAnimationStep.bind(this)); private element: HTMLElement; private contentOffsetX: number; private onPanned: (movedPx: number) => void; private onZoomed: (zoomPositionPx: number, zoomRatio: number) => void; private onDragSelect: (selectStartPx: number, selectEndPx: number) => void; constructor({element, contentOffsetX, onPanned, onZoomed, onDragSelect}: { element: HTMLElement, contentOffsetX: number, onPanned: (movedPx: number) => void, onZoomed: (zoomPositionPx: number, zoomRatio: number) => void, onDragSelect: (selectStartPx: number, selectEndPx: number) => void }) { this.element = element; this.contentOffsetX = contentOffsetX; this.onPanned = onPanned; this.onZoomed = onZoomed; this.onDragSelect = onDragSelect; document.body.addEventListener('keydown', this.boundOnKeyDown); document.body.addEventListener('keyup', this.boundOnKeyUp); this.element.addEventListener('mousemove', this.boundOnMouseMove); this.element.addEventListener('wheel', this.boundOnWheel, {passive: true}); let lastX = -1; new DragGestureHandler(this.element, x => { if (this.shiftDown && this.dragStartPx !== -1) { this.onDragSelect(this.dragStartPx, x); } else { this.onPanned(lastX - x); } lastX = x; }, x => { lastX = x; if (this.shiftDown) { this.dragStartPx = x; } }, () => { this.dragStartPx = -1; }); } shutdown() { document.body.removeEventListener('keydown', this.boundOnKeyDown); document.body.removeEventListener('keyup', this.boundOnKeyUp); this.element.removeEventListener('mousemove', this.boundOnMouseMove); this.element.removeEventListener('wheel', this.boundOnWheel); } private onPanAnimationStep(msSinceStartOfAnimation: number) { if (this.panning === Pan.None) return; let offset = this.panning * KEYBOARD_PAN_PX_PER_FRAME; offset *= Math.max(msSinceStartOfAnimation / 40, 1); this.onPanned(offset); } private onZoomAnimationStep(msSinceStartOfAnimation: number) { if (this.zooming === Zoom.None || this.mousePositionX === null) return; let zoomRatio = this.zooming * ZOOM_RATIO_PER_FRAME; zoomRatio *= Math.max(msSinceStartOfAnimation / 40, 1); this.onZoomed(this.mousePositionX, zoomRatio); } private onMouseMove(e: MouseEvent) { // TODO(taylori): Content offset is 6px off, why? this.mousePositionX = e.clientX - this.contentOffsetX - 6; if (this.shiftDown) { const pos = this.mousePositionX - TRACK_SHELL_WIDTH; const ts = globals.frontendLocalState.timeScale.pxToTime(pos); globals.frontendLocalState.setHoveredTimestamp(ts); } } private onWheel(e: WheelEvent) { if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { this.onPanned(e.deltaX * HORIZONTAL_WHEEL_PAN_SPEED); globals.rafScheduler.scheduleRedraw(); } else if (e.ctrlKey && this.mousePositionX) { const sign = e.deltaY < 0 ? -1 : 1; const deltaY = sign * Math.log2(1 + Math.abs(e.deltaY)); this.onZoomed(this.mousePositionX, deltaY * WHEEL_ZOOM_SPEED); globals.rafScheduler.scheduleRedraw(); } } private onKeyDown(e: KeyboardEvent) { this.updateShift(e.shiftKey); if (keyToPan(e) !== Pan.None) { this.panning = keyToPan(e); const animationTime = e.repeat ? ANIMATION_AUTO_END_AFTER_KEYPRESS_MS : ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS; this.panAnimation.start(animationTime); clearTimeout(this.cancelPanTimeout!); } if (keyToZoom(e) !== Zoom.None) { this.zooming = keyToZoom(e); const animationTime = e.repeat ? ANIMATION_AUTO_END_AFTER_KEYPRESS_MS : ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS; this.zoomAnimation.start(animationTime); clearTimeout(this.cancelZoomTimeout!); } // Handle key events that are not pan or zoom. handleKey(e.key, true); } private onKeyUp(e: KeyboardEvent) { this.updateShift(e.shiftKey); if (keyToPan(e) === this.panning) { const minEndTime = this.panAnimation.startTimeMs + TAP_ANIMATION_TIME; const t = minEndTime - performance.now(); this.cancelPanTimeout = setTimeout(() => this.panAnimation.stop(), t); } if (keyToZoom(e) === this.zooming) { const minEndTime = this.zoomAnimation.startTimeMs + TAP_ANIMATION_TIME; const t = minEndTime - performance.now(); this.cancelZoomTimeout = setTimeout(() => this.zoomAnimation.stop(), t); } // Handle key events that are not pan or zoom. handleKey(e.key, false); } private updateShift(down: boolean) { if (down === this.shiftDown) return; this.shiftDown = down; if (this.shiftDown) { if (this.mousePositionX) { this.element.style.cursor = 'text'; const pos = this.mousePositionX - TRACK_SHELL_WIDTH; const ts = globals.frontendLocalState.timeScale.pxToTime(pos); globals.frontendLocalState.setHoveredTimestamp(ts); } } else { globals.frontendLocalState.setHoveredTimestamp(-1); this.element.style.cursor = 'default'; } globals.frontendLocalState.setShowTimeSelectPreview(this.shiftDown); } }