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