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 {Disposable, DisposableStack} from '../base/disposable'; 16import {currentTargetOffset, elementIsEditable} from '../base/dom_utils'; 17import {raf} from '../core/raf_scheduler'; 18 19import {Animation} from './animation'; 20import {DragGestureHandler} from './drag_gesture_handler'; 21 22// When first starting to pan or zoom, move at least this many units. 23const INITIAL_PAN_STEP_PX = 50; 24const INITIAL_ZOOM_STEP = 0.1; 25 26// The snappiness (spring constant) of pan and zoom animations [0..1]. 27const SNAP_FACTOR = 0.4; 28 29// How much the velocity of a pan or zoom animation increases per millisecond. 30const ACCELERATION_PER_MS = 1 / 50; 31 32// The default duration of a pan or zoom animation. The animation may run longer 33// if the user keeps holding the respective button down or shorter if the button 34// is released. This value so chosen so that it is longer than the typical key 35// repeat timeout to avoid breaks in the animation. 36const DEFAULT_ANIMATION_DURATION = 700; 37 38// The minimum number of units to pan or zoom per frame (before the 39// ACCELERATION_PER_MS multiplier is applied). 40const ZOOM_RATIO_PER_FRAME = 0.008; 41const KEYBOARD_PAN_PX_PER_FRAME = 8; 42 43// Scroll wheel animation steps. 44const HORIZONTAL_WHEEL_PAN_SPEED = 1; 45const WHEEL_ZOOM_SPEED = -0.02; 46 47const EDITING_RANGE_CURSOR = 'ew-resize'; 48const DRAG_CURSOR = 'default'; 49const PAN_CURSOR = 'move'; 50 51// Use key mapping based on the 'KeyboardEvent.code' property vs the 52// 'KeyboardEvent.key', because the former corresponds to the physical key 53// position rather than the glyph printed on top of it, and is unaffected by 54// the user's keyboard layout. 55// For example, 'KeyW' always corresponds to the key at the physical location of 56// the 'w' key on an English QWERTY keyboard, regardless of the user's keyboard 57// layout, or at least the layout they have configured in their OS. 58// Seeing as most users use the keys in the English QWERTY "WASD" position for 59// controlling kb+mouse applications like games, it's a good bet that these are 60// the keys most poeple are going to find natural for navigating the UI. 61// See https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system 62export enum KeyMapping { 63 KEY_PAN_LEFT = 'KeyA', 64 KEY_PAN_RIGHT = 'KeyD', 65 KEY_ZOOM_IN = 'KeyW', 66 KEY_ZOOM_OUT = 'KeyS', 67} 68 69enum Pan { 70 None = 0, 71 Left = -1, 72 Right = 1, 73} 74function keyToPan(e: KeyboardEvent): Pan { 75 if (e.code === KeyMapping.KEY_PAN_LEFT) return Pan.Left; 76 if (e.code === KeyMapping.KEY_PAN_RIGHT) return Pan.Right; 77 return Pan.None; 78} 79 80enum Zoom { 81 None = 0, 82 In = 1, 83 Out = -1, 84} 85function keyToZoom(e: KeyboardEvent): Zoom { 86 if (e.code === KeyMapping.KEY_ZOOM_IN) return Zoom.In; 87 if (e.code === KeyMapping.KEY_ZOOM_OUT) return Zoom.Out; 88 return Zoom.None; 89} 90 91/** 92 * Enables horizontal pan and zoom with mouse-based drag and WASD navigation. 93 */ 94export class PanAndZoomHandler implements Disposable { 95 private mousePositionX: number | null = null; 96 private boundOnMouseMove = this.onMouseMove.bind(this); 97 private boundOnWheel = this.onWheel.bind(this); 98 private boundOnKeyDown = this.onKeyDown.bind(this); 99 private boundOnKeyUp = this.onKeyUp.bind(this); 100 private shiftDown = false; 101 private panning: Pan = Pan.None; 102 private panOffsetPx = 0; 103 private targetPanOffsetPx = 0; 104 private zooming: Zoom = Zoom.None; 105 private zoomRatio = 0; 106 private targetZoomRatio = 0; 107 private panAnimation = new Animation(this.onPanAnimationStep.bind(this)); 108 private zoomAnimation = new Animation(this.onZoomAnimationStep.bind(this)); 109 110 private element: HTMLElement; 111 private onPanned: (movedPx: number) => void; 112 private onZoomed: (zoomPositionPx: number, zoomRatio: number) => void; 113 private editSelection: (currentPx: number) => boolean; 114 private onSelection: ( 115 dragStartX: number, 116 dragStartY: number, 117 prevX: number, 118 currentX: number, 119 currentY: number, 120 editing: boolean, 121 ) => void; 122 private endSelection: (edit: boolean) => void; 123 private trash: DisposableStack; 124 125 constructor({ 126 element, 127 onPanned, 128 onZoomed, 129 editSelection, 130 onSelection, 131 endSelection, 132 }: { 133 element: HTMLElement; 134 onPanned: (movedPx: number) => void; 135 onZoomed: (zoomPositionPx: number, zoomRatio: number) => void; 136 editSelection: (currentPx: number) => boolean; 137 onSelection: ( 138 dragStartX: number, 139 dragStartY: number, 140 prevX: number, 141 currentX: number, 142 currentY: number, 143 editing: boolean, 144 ) => void; 145 endSelection: (edit: boolean) => void; 146 }) { 147 this.element = element; 148 this.onPanned = onPanned; 149 this.onZoomed = onZoomed; 150 this.editSelection = editSelection; 151 this.onSelection = onSelection; 152 this.endSelection = endSelection; 153 this.trash = new DisposableStack(); 154 155 document.body.addEventListener('keydown', this.boundOnKeyDown); 156 document.body.addEventListener('keyup', this.boundOnKeyUp); 157 this.element.addEventListener('mousemove', this.boundOnMouseMove); 158 this.element.addEventListener('wheel', this.boundOnWheel, {passive: true}); 159 this.trash.defer(() => { 160 this.element.removeEventListener('wheel', this.boundOnWheel); 161 this.element.removeEventListener('mousemove', this.boundOnMouseMove); 162 document.body.removeEventListener('keyup', this.boundOnKeyUp); 163 document.body.removeEventListener('keydown', this.boundOnKeyDown); 164 }); 165 166 let prevX = -1; 167 let dragStartX = -1; 168 let dragStartY = -1; 169 let edit = false; 170 this.trash.use( 171 new DragGestureHandler( 172 this.element, 173 (x, y) => { 174 if (this.shiftDown) { 175 this.onPanned(prevX - x); 176 } else { 177 this.onSelection(dragStartX, dragStartY, prevX, x, y, edit); 178 } 179 prevX = x; 180 }, 181 (x, y) => { 182 prevX = x; 183 dragStartX = x; 184 dragStartY = y; 185 edit = this.editSelection(x); 186 // Set the cursor style based on where the cursor is when the drag 187 // starts. 188 if (edit) { 189 this.element.style.cursor = EDITING_RANGE_CURSOR; 190 } else if (!this.shiftDown) { 191 this.element.style.cursor = DRAG_CURSOR; 192 } 193 }, 194 () => { 195 // Reset the cursor now the drag has ended. 196 this.element.style.cursor = this.shiftDown ? PAN_CURSOR : DRAG_CURSOR; 197 dragStartX = -1; 198 dragStartY = -1; 199 this.endSelection(edit); 200 }, 201 ), 202 ); 203 } 204 205 dispose() { 206 this.trash.dispose(); 207 } 208 209 private onPanAnimationStep(msSinceStartOfAnimation: number) { 210 const step = (this.targetPanOffsetPx - this.panOffsetPx) * SNAP_FACTOR; 211 if (this.panning !== Pan.None) { 212 const velocity = 1 + msSinceStartOfAnimation * ACCELERATION_PER_MS; 213 // Pan at least as fast as the snapping animation to avoid a 214 // discontinuity. 215 const targetStep = Math.max(KEYBOARD_PAN_PX_PER_FRAME * velocity, step); 216 this.targetPanOffsetPx += this.panning * targetStep; 217 } 218 this.panOffsetPx += step; 219 if (Math.abs(step) > 1e-1) { 220 this.onPanned(step); 221 } else { 222 this.panAnimation.stop(); 223 } 224 } 225 226 private onZoomAnimationStep(msSinceStartOfAnimation: number) { 227 if (this.mousePositionX === null) return; 228 const step = (this.targetZoomRatio - this.zoomRatio) * SNAP_FACTOR; 229 if (this.zooming !== Zoom.None) { 230 const velocity = 1 + msSinceStartOfAnimation * ACCELERATION_PER_MS; 231 // Zoom at least as fast as the snapping animation to avoid a 232 // discontinuity. 233 const targetStep = Math.max(ZOOM_RATIO_PER_FRAME * velocity, step); 234 this.targetZoomRatio += this.zooming * targetStep; 235 } 236 this.zoomRatio += step; 237 if (Math.abs(step) > 1e-6) { 238 this.onZoomed(this.mousePositionX, step); 239 } else { 240 this.zoomAnimation.stop(); 241 } 242 } 243 244 private onMouseMove(e: MouseEvent) { 245 this.mousePositionX = currentTargetOffset(e).x; 246 247 // Only change the cursor when hovering, the DragGestureHandler handles 248 // changing the cursor during drag events. This avoids the problem of 249 // the cursor flickering between styles if you drag fast and get too 250 // far from the current time range. 251 if (e.buttons === 0) { 252 if (this.editSelection(this.mousePositionX)) { 253 this.element.style.cursor = EDITING_RANGE_CURSOR; 254 } else { 255 this.element.style.cursor = this.shiftDown ? PAN_CURSOR : DRAG_CURSOR; 256 } 257 } 258 } 259 260 private onWheel(e: WheelEvent) { 261 if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { 262 this.onPanned(e.deltaX * HORIZONTAL_WHEEL_PAN_SPEED); 263 raf.scheduleRedraw(); 264 } else if (e.ctrlKey && this.mousePositionX !== null) { 265 const sign = e.deltaY < 0 ? -1 : 1; 266 const deltaY = sign * Math.log2(1 + Math.abs(e.deltaY)); 267 this.onZoomed(this.mousePositionX, deltaY * WHEEL_ZOOM_SPEED); 268 raf.scheduleRedraw(); 269 } 270 } 271 272 // Due to a bug in chrome, we get onKeyDown events fired where the payload is 273 // not a KeyboardEvent when selecting an item from an autocomplete suggestion. 274 // See https://issues.chromium.org/issues/41425904 275 // Thus, we can't assume we get an KeyboardEvent and must check manually. 276 private onKeyDown(e: Event) { 277 if (e instanceof KeyboardEvent) { 278 if (elementIsEditable(e.target)) return; 279 280 this.updateShift(e.shiftKey); 281 282 if (e.ctrlKey || e.metaKey) return; 283 284 if (keyToPan(e) !== Pan.None) { 285 if (this.panning !== keyToPan(e)) { 286 this.panAnimation.stop(); 287 this.panOffsetPx = 0; 288 this.targetPanOffsetPx = keyToPan(e) * INITIAL_PAN_STEP_PX; 289 } 290 this.panning = keyToPan(e); 291 this.panAnimation.start(DEFAULT_ANIMATION_DURATION); 292 } 293 294 if (keyToZoom(e) !== Zoom.None) { 295 if (this.zooming !== keyToZoom(e)) { 296 this.zoomAnimation.stop(); 297 this.zoomRatio = 0; 298 this.targetZoomRatio = keyToZoom(e) * INITIAL_ZOOM_STEP; 299 } 300 this.zooming = keyToZoom(e); 301 this.zoomAnimation.start(DEFAULT_ANIMATION_DURATION); 302 } 303 } 304 } 305 306 private onKeyUp(e: Event) { 307 if (e instanceof KeyboardEvent) { 308 this.updateShift(e.shiftKey); 309 310 if (e.ctrlKey || e.metaKey) return; 311 312 if (keyToPan(e) === this.panning) { 313 this.panning = Pan.None; 314 } 315 if (keyToZoom(e) === this.zooming) { 316 this.zooming = Zoom.None; 317 } 318 } 319 } 320 321 // TODO(hjd): Move this shift handling into the viewer page. 322 private updateShift(down: boolean) { 323 if (down === this.shiftDown) return; 324 this.shiftDown = down; 325 if (this.shiftDown) { 326 this.element.style.cursor = PAN_CURSOR; 327 } else if (this.mousePositionX !== null) { 328 this.element.style.cursor = DRAG_CURSOR; 329 } 330 } 331} 332