1// Copyright (C) 2018 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import {Animation} from './animation'; 16import Timer = NodeJS.Timer; 17import {DragGestureHandler} from './drag_gesture_handler'; 18import {globals} from './globals'; 19import {handleKey} from './keyboard_event_handler'; 20import {TRACK_SHELL_WIDTH} from './track_constants'; 21 22const ZOOM_RATIO_PER_FRAME = 0.008; 23const KEYBOARD_PAN_PX_PER_FRAME = 8; 24const HORIZONTAL_WHEEL_PAN_SPEED = 1; 25const WHEEL_ZOOM_SPEED = -0.02; 26 27// Usually, animations are cancelled on keyup. However, in case the keyup 28// event is not captured by the document, e.g. if it loses focus first, then 29// we want to stop the animation as soon as possible. 30const ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS = 700; 31// This value must be larger than the maximum delta between keydown repeat 32// events. Largest observed value so far: 86ms. 33const ANIMATION_AUTO_END_AFTER_KEYPRESS_MS = 100; 34 35// This defines the step size for an individual pan or zoom keyboard tap. 36const TAP_ANIMATION_TIME = 200; 37 38enum Pan { 39 None = 0, 40 Left = -1, 41 Right = 1 42} 43function keyToPan(e: KeyboardEvent): Pan { 44 if (['a'].includes(e.key)) return Pan.Left; 45 if (['d'].includes(e.key)) return Pan.Right; 46 return Pan.None; 47} 48 49enum Zoom { 50 None = 0, 51 In = 1, 52 Out = -1 53} 54function keyToZoom(e: KeyboardEvent): Zoom { 55 if (['w'].includes(e.key)) return Zoom.In; 56 if (['s'].includes(e.key)) return Zoom.Out; 57 return Zoom.None; 58} 59 60/** 61 * Enables horizontal pan and zoom with mouse-based drag and WASD navigation. 62 */ 63export class PanAndZoomHandler { 64 private mousePositionX: number|null = null; 65 private boundOnMouseMove = this.onMouseMove.bind(this); 66 private boundOnWheel = this.onWheel.bind(this); 67 private boundOnKeyDown = this.onKeyDown.bind(this); 68 private boundOnKeyUp = this.onKeyUp.bind(this); 69 private shiftDown = false; 70 private dragStartPx = -1; 71 private panning: Pan = Pan.None; 72 private zooming: Zoom = Zoom.None; 73 private cancelPanTimeout?: Timer; 74 private cancelZoomTimeout?: Timer; 75 private panAnimation = new Animation(this.onPanAnimationStep.bind(this)); 76 private zoomAnimation = new Animation(this.onZoomAnimationStep.bind(this)); 77 78 private element: HTMLElement; 79 private contentOffsetX: number; 80 private onPanned: (movedPx: number) => void; 81 private onZoomed: (zoomPositionPx: number, zoomRatio: number) => void; 82 private onDragSelect: (selectStartPx: number, selectEndPx: number) => void; 83 84 constructor({element, contentOffsetX, onPanned, onZoomed, onDragSelect}: { 85 element: HTMLElement, 86 contentOffsetX: number, 87 onPanned: (movedPx: number) => void, 88 onZoomed: (zoomPositionPx: number, zoomRatio: number) => void, 89 onDragSelect: (selectStartPx: number, selectEndPx: number) => void 90 }) { 91 this.element = element; 92 this.contentOffsetX = contentOffsetX; 93 this.onPanned = onPanned; 94 this.onZoomed = onZoomed; 95 this.onDragSelect = onDragSelect; 96 97 document.body.addEventListener('keydown', this.boundOnKeyDown); 98 document.body.addEventListener('keyup', this.boundOnKeyUp); 99 this.element.addEventListener('mousemove', this.boundOnMouseMove); 100 this.element.addEventListener('wheel', this.boundOnWheel, {passive: true}); 101 102 let lastX = -1; 103 new DragGestureHandler(this.element, x => { 104 if (this.shiftDown && this.dragStartPx !== -1) { 105 this.onDragSelect(this.dragStartPx, x); 106 } else { 107 this.onPanned(lastX - x); 108 } 109 lastX = x; 110 }, x => { 111 lastX = x; 112 if (this.shiftDown) { 113 this.dragStartPx = x; 114 } 115 }, () => { 116 this.dragStartPx = -1; 117 }); 118 } 119 120 shutdown() { 121 document.body.removeEventListener('keydown', this.boundOnKeyDown); 122 document.body.removeEventListener('keyup', this.boundOnKeyUp); 123 this.element.removeEventListener('mousemove', this.boundOnMouseMove); 124 this.element.removeEventListener('wheel', this.boundOnWheel); 125 } 126 127 private onPanAnimationStep(msSinceStartOfAnimation: number) { 128 if (this.panning === Pan.None) return; 129 let offset = this.panning * KEYBOARD_PAN_PX_PER_FRAME; 130 offset *= Math.max(msSinceStartOfAnimation / 40, 1); 131 this.onPanned(offset); 132 } 133 134 private onZoomAnimationStep(msSinceStartOfAnimation: number) { 135 if (this.zooming === Zoom.None || this.mousePositionX === null) return; 136 let zoomRatio = this.zooming * ZOOM_RATIO_PER_FRAME; 137 zoomRatio *= Math.max(msSinceStartOfAnimation / 40, 1); 138 this.onZoomed(this.mousePositionX, zoomRatio); 139 } 140 141 private onMouseMove(e: MouseEvent) { 142 // TODO(taylori): Content offset is 6px off, why? 143 this.mousePositionX = e.clientX - this.contentOffsetX - 6; 144 if (this.shiftDown) { 145 const pos = this.mousePositionX - TRACK_SHELL_WIDTH; 146 const ts = 147 globals.frontendLocalState.timeScale.pxToTime(pos); 148 globals.frontendLocalState.setHoveredTimestamp(ts); 149 } 150 } 151 152 private onWheel(e: WheelEvent) { 153 if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { 154 this.onPanned(e.deltaX * HORIZONTAL_WHEEL_PAN_SPEED); 155 globals.rafScheduler.scheduleRedraw(); 156 } else if (e.ctrlKey && this.mousePositionX) { 157 const sign = e.deltaY < 0 ? -1 : 1; 158 const deltaY = sign * Math.log2(1 + Math.abs(e.deltaY)); 159 this.onZoomed(this.mousePositionX, deltaY * WHEEL_ZOOM_SPEED); 160 globals.rafScheduler.scheduleRedraw(); 161 } 162 } 163 164 private onKeyDown(e: KeyboardEvent) { 165 this.updateShift(e.shiftKey); 166 if (keyToPan(e) !== Pan.None) { 167 this.panning = keyToPan(e); 168 const animationTime = e.repeat ? 169 ANIMATION_AUTO_END_AFTER_KEYPRESS_MS : 170 ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS; 171 this.panAnimation.start(animationTime); 172 clearTimeout(this.cancelPanTimeout!); 173 } 174 175 if (keyToZoom(e) !== Zoom.None) { 176 this.zooming = keyToZoom(e); 177 const animationTime = e.repeat ? 178 ANIMATION_AUTO_END_AFTER_KEYPRESS_MS : 179 ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS; 180 this.zoomAnimation.start(animationTime); 181 clearTimeout(this.cancelZoomTimeout!); 182 } 183 184 // Handle key events that are not pan or zoom. 185 handleKey(e.key, true); 186 } 187 188 private onKeyUp(e: KeyboardEvent) { 189 this.updateShift(e.shiftKey); 190 if (keyToPan(e) === this.panning) { 191 const minEndTime = this.panAnimation.startTimeMs + TAP_ANIMATION_TIME; 192 const t = minEndTime - performance.now(); 193 this.cancelPanTimeout = setTimeout(() => this.panAnimation.stop(), t); 194 } 195 if (keyToZoom(e) === this.zooming) { 196 const minEndTime = this.zoomAnimation.startTimeMs + TAP_ANIMATION_TIME; 197 const t = minEndTime - performance.now(); 198 this.cancelZoomTimeout = setTimeout(() => this.zoomAnimation.stop(), t); 199 } 200 201 // Handle key events that are not pan or zoom. 202 handleKey(e.key, false); 203 } 204 205 private updateShift(down: boolean) { 206 if (down === this.shiftDown) return; 207 this.shiftDown = down; 208 if (this.shiftDown) { 209 if (this.mousePositionX) { 210 this.element.style.cursor = 'text'; 211 const pos = this.mousePositionX - TRACK_SHELL_WIDTH; 212 const ts = globals.frontendLocalState.timeScale.pxToTime(pos); 213 globals.frontendLocalState.setHoveredTimestamp(ts); 214 } 215 } else { 216 globals.frontendLocalState.setHoveredTimestamp(-1); 217 this.element.style.cursor = 'default'; 218 } 219 220 globals.frontendLocalState.setShowTimeSelectPreview(this.shiftDown); 221 } 222} 223