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