|  | // 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 {DragGestureHandler} from './drag_gesture_handler'; | 
|  | import {globals} from './globals'; | 
|  | import {handleKey} from './keyboard_event_handler'; | 
|  |  | 
|  | // When first starting to pan or zoom, move at least this many units. | 
|  | const INITIAL_PAN_STEP_PX = 50; | 
|  | const INITIAL_ZOOM_STEP = 0.1; | 
|  |  | 
|  | // The snappiness (spring constant) of pan and zoom animations [0..1]. | 
|  | const SNAP_FACTOR = 0.4; | 
|  |  | 
|  | // How much the velocity of a pan or zoom animation increases per millisecond. | 
|  | const ACCELERATION_PER_MS = 1 / 50; | 
|  |  | 
|  | // The default duration of a pan or zoom animation. The animation may run longer | 
|  | // if the user keeps holding the respective button down or shorter if the button | 
|  | // is released. This value so chosen so that it is longer than the typical key | 
|  | // repeat timeout to avoid breaks in the animation. | 
|  | const DEFAULT_ANIMATION_DURATION = 700; | 
|  |  | 
|  | // The minimum number of units to pan or zoom per frame (before the | 
|  | // ACCELERATION_PER_MS multiplier is applied). | 
|  | const ZOOM_RATIO_PER_FRAME = 0.008; | 
|  | const KEYBOARD_PAN_PX_PER_FRAME = 8; | 
|  |  | 
|  | // Scroll wheel animation steps. | 
|  | const HORIZONTAL_WHEEL_PAN_SPEED = 1; | 
|  | const WHEEL_ZOOM_SPEED = -0.02; | 
|  |  | 
|  | const EDITING_RANGE_CURSOR = 'ew-resize'; | 
|  | const DRAG_CURSOR = 'default'; | 
|  | const PAN_CURSOR = 'move'; | 
|  |  | 
|  | // Use key mapping based on the 'KeyboardEvent.code' property vs the | 
|  | // 'KeyboardEvent.key', because the the former corresponds to the physical key | 
|  | // position rather than the glyph printed on top of it, and is unaffected by | 
|  | // the user's keyboard layout. | 
|  | // For example, 'KeyW' always corresponds to the key at the physical location of | 
|  | // the 'w' key on an English QWERTY keyboard, regardless of the user's keyboard | 
|  | // layout, or at least the layout they have configured in their OS. | 
|  | // Seeing as most users use the keys in the English QWERTY "WASD" position for | 
|  | // controlling kb+mouse applications like games, it's a good bet that these are | 
|  | // the keys most poeple are going to find natural for navigating the UI. | 
|  | // See https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system | 
|  | export enum KeyMapping { | 
|  | KEY_PAN_LEFT = 'KeyA', | 
|  | KEY_PAN_RIGHT = 'KeyD', | 
|  | KEY_ZOOM_IN = 'KeyW', | 
|  | KEY_ZOOM_OUT = 'KeyS', | 
|  | } | 
|  |  | 
|  | enum Pan { | 
|  | None = 0, | 
|  | Left = -1, | 
|  | Right = 1 | 
|  | } | 
|  | function keyToPan(e: KeyboardEvent): Pan { | 
|  | if (e.code === KeyMapping.KEY_PAN_LEFT) return Pan.Left; | 
|  | if (e.code === KeyMapping.KEY_PAN_RIGHT) return Pan.Right; | 
|  | return Pan.None; | 
|  | } | 
|  |  | 
|  | enum Zoom { | 
|  | None = 0, | 
|  | In = 1, | 
|  | Out = -1 | 
|  | } | 
|  | function keyToZoom(e: KeyboardEvent): Zoom { | 
|  | if (e.code === KeyMapping.KEY_ZOOM_IN) return Zoom.In; | 
|  | if (e.code === KeyMapping.KEY_ZOOM_OUT) 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 panning: Pan = Pan.None; | 
|  | private panOffsetPx = 0; | 
|  | private targetPanOffsetPx = 0; | 
|  | private zooming: Zoom = Zoom.None; | 
|  | private zoomRatio = 0; | 
|  | private targetZoomRatio = 0; | 
|  | 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 editSelection: (currentPx: number) => boolean; | 
|  | private onSelection: | 
|  | (dragStartX: number, dragStartY: number, prevX: number, currentX: number, | 
|  | currentY: number, editing: boolean) => void; | 
|  | private endSelection: (edit: boolean) => void; | 
|  |  | 
|  | constructor({ | 
|  | element, | 
|  | contentOffsetX, | 
|  | onPanned, | 
|  | onZoomed, | 
|  | editSelection, | 
|  | onSelection, | 
|  | endSelection, | 
|  | }: { | 
|  | element: HTMLElement, | 
|  | contentOffsetX: number, | 
|  | onPanned: (movedPx: number) => void, | 
|  | onZoomed: (zoomPositionPx: number, zoomRatio: number) => void, | 
|  | editSelection: (currentPx: number) => boolean, | 
|  | onSelection: | 
|  | (dragStartX: number, dragStartY: number, prevX: number, | 
|  | currentX: number, currentY: number, editing: boolean) => void, | 
|  | endSelection: (edit: boolean) => void, | 
|  | }) { | 
|  | this.element = element; | 
|  | this.contentOffsetX = contentOffsetX; | 
|  | this.onPanned = onPanned; | 
|  | this.onZoomed = onZoomed; | 
|  | this.editSelection = editSelection; | 
|  | this.onSelection = onSelection; | 
|  | this.endSelection = endSelection; | 
|  |  | 
|  | 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 prevX = -1; | 
|  | let dragStartX = -1; | 
|  | let dragStartY = -1; | 
|  | let edit = false; | 
|  | new DragGestureHandler( | 
|  | this.element, | 
|  | (x, y) => { | 
|  | if (this.shiftDown) { | 
|  | this.onPanned(prevX - x); | 
|  | } else { | 
|  | this.onSelection(dragStartX, dragStartY, prevX, x, y, edit); | 
|  | } | 
|  | prevX = x; | 
|  | }, | 
|  | (x, y) => { | 
|  | prevX = x; | 
|  | dragStartX = x; | 
|  | dragStartY = y; | 
|  | edit = this.editSelection(x); | 
|  | // Set the cursor style based on where the cursor is when the drag | 
|  | // starts. | 
|  | if (edit) { | 
|  | this.element.style.cursor = EDITING_RANGE_CURSOR; | 
|  | } else if (!this.shiftDown) { | 
|  | this.element.style.cursor = DRAG_CURSOR; | 
|  | } | 
|  | }, | 
|  | () => { | 
|  | // Reset the cursor now the drag has ended. | 
|  | this.element.style.cursor = this.shiftDown ? PAN_CURSOR : DRAG_CURSOR; | 
|  | dragStartX = -1; | 
|  | dragStartY = -1; | 
|  | this.endSelection(edit); | 
|  | }); | 
|  | } | 
|  |  | 
|  |  | 
|  | 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) { | 
|  | const step = (this.targetPanOffsetPx - this.panOffsetPx) * SNAP_FACTOR; | 
|  | if (this.panning !== Pan.None) { | 
|  | const velocity = 1 + msSinceStartOfAnimation * ACCELERATION_PER_MS; | 
|  | // Pan at least as fast as the snapping animation to avoid a | 
|  | // discontinuity. | 
|  | const targetStep = Math.max(KEYBOARD_PAN_PX_PER_FRAME * velocity, step); | 
|  | this.targetPanOffsetPx += this.panning * targetStep; | 
|  | } | 
|  | this.panOffsetPx += step; | 
|  | if (Math.abs(step) > 1e-1) { | 
|  | this.onPanned(step); | 
|  | } else { | 
|  | this.panAnimation.stop(); | 
|  | } | 
|  | } | 
|  |  | 
|  | private onZoomAnimationStep(msSinceStartOfAnimation: number) { | 
|  | if (this.mousePositionX === null) return; | 
|  | const step = (this.targetZoomRatio - this.zoomRatio) * SNAP_FACTOR; | 
|  | if (this.zooming !== Zoom.None) { | 
|  | const velocity = 1 + msSinceStartOfAnimation * ACCELERATION_PER_MS; | 
|  | // Zoom at least as fast as the snapping animation to avoid a | 
|  | // discontinuity. | 
|  | const targetStep = Math.max(ZOOM_RATIO_PER_FRAME * velocity, step); | 
|  | this.targetZoomRatio += this.zooming * targetStep; | 
|  | } | 
|  | this.zoomRatio += step; | 
|  | if (Math.abs(step) > 1e-6) { | 
|  | this.onZoomed(this.mousePositionX, step); | 
|  | } else { | 
|  | this.zoomAnimation.stop(); | 
|  | } | 
|  | } | 
|  |  | 
|  | private onMouseMove(e: MouseEvent) { | 
|  | const pageOffset = globals.state.sidebarVisible && !globals.hideSidebar ? | 
|  | this.contentOffsetX : | 
|  | 0; | 
|  | // We can't use layerX here because there are many layers in this element. | 
|  | this.mousePositionX = e.clientX - pageOffset; | 
|  | // Only change the cursor when hovering, the DragGestureHandler handles | 
|  | // changing the cursor during drag events. This avoids the problem of | 
|  | // the cursor flickering between styles if you drag fast and get too | 
|  | // far from the current time range. | 
|  | if (e.buttons === 0) { | 
|  | if (this.editSelection(this.mousePositionX)) { | 
|  | this.element.style.cursor = EDITING_RANGE_CURSOR; | 
|  | } else { | 
|  | this.element.style.cursor = this.shiftDown ? PAN_CURSOR : DRAG_CURSOR; | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | 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); | 
|  |  | 
|  | // Handle key events that are not pan or zoom. | 
|  | if (handleKey(e, true)) return; | 
|  |  | 
|  | if (keyToPan(e) !== Pan.None) { | 
|  | if (this.panning !== keyToPan(e)) { | 
|  | this.panAnimation.stop(); | 
|  | this.panOffsetPx = 0; | 
|  | this.targetPanOffsetPx = keyToPan(e) * INITIAL_PAN_STEP_PX; | 
|  | } | 
|  | this.panning = keyToPan(e); | 
|  | this.panAnimation.start(DEFAULT_ANIMATION_DURATION); | 
|  | } | 
|  |  | 
|  | if (keyToZoom(e) !== Zoom.None) { | 
|  | if (this.zooming !== keyToZoom(e)) { | 
|  | this.zoomAnimation.stop(); | 
|  | this.zoomRatio = 0; | 
|  | this.targetZoomRatio = keyToZoom(e) * INITIAL_ZOOM_STEP; | 
|  | } | 
|  | this.zooming = keyToZoom(e); | 
|  | this.zoomAnimation.start(DEFAULT_ANIMATION_DURATION); | 
|  | } | 
|  | } | 
|  |  | 
|  | private onKeyUp(e: KeyboardEvent) { | 
|  | this.updateShift(e.shiftKey); | 
|  |  | 
|  | // Handle key events that are not pan or zoom. | 
|  | if (handleKey(e, false)) return; | 
|  |  | 
|  | if (keyToPan(e) === this.panning) { | 
|  | this.panning = Pan.None; | 
|  | } | 
|  | if (keyToZoom(e) === this.zooming) { | 
|  | this.zooming = Zoom.None; | 
|  | } | 
|  | } | 
|  |  | 
|  | // TODO(hjd): Move this shift handling into the viewer page. | 
|  | private updateShift(down: boolean) { | 
|  | if (down === this.shiftDown) return; | 
|  | this.shiftDown = down; | 
|  | if (this.shiftDown) { | 
|  | this.element.style.cursor = PAN_CURSOR; | 
|  | } else if (this.mousePositionX) { | 
|  | this.element.style.cursor = DRAG_CURSOR; | 
|  | } | 
|  | } | 
|  | } |