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 49enum Pan { 50 None = 0, 51 Left = -1, 52 Right = 1 53} 54function keyToPan(e: KeyboardEvent): Pan { 55 const key = e.key.toLowerCase(); 56 if (['a'].includes(key)) return Pan.Left; 57 if (['d', 'e'].includes(key)) return Pan.Right; 58 return Pan.None; 59} 60 61enum Zoom { 62 None = 0, 63 In = 1, 64 Out = -1 65} 66function keyToZoom(e: KeyboardEvent): Zoom { 67 const key = e.key.toLowerCase(); 68 if (['w', ','].includes(key)) return Zoom.In; 69 if (['s', 'o'].includes(key)) return Zoom.Out; 70 return Zoom.None; 71} 72 73/** 74 * Enables horizontal pan and zoom with mouse-based drag and WASD navigation. 75 */ 76export class PanAndZoomHandler { 77 private mousePositionX: number|null = null; 78 private boundOnMouseMove = this.onMouseMove.bind(this); 79 private boundOnWheel = this.onWheel.bind(this); 80 private boundOnKeyDown = this.onKeyDown.bind(this); 81 private boundOnKeyUp = this.onKeyUp.bind(this); 82 private shiftDown = false; 83 private panning: Pan = Pan.None; 84 private panOffsetPx = 0; 85 private targetPanOffsetPx = 0; 86 private zooming: Zoom = Zoom.None; 87 private zoomRatio = 0; 88 private targetZoomRatio = 0; 89 private panAnimation = new Animation(this.onPanAnimationStep.bind(this)); 90 private zoomAnimation = new Animation(this.onZoomAnimationStep.bind(this)); 91 92 private element: HTMLElement; 93 private contentOffsetX: number; 94 private onPanned: (movedPx: number) => void; 95 private onZoomed: (zoomPositionPx: number, zoomRatio: number) => void; 96 private editSelection: (currentPx: number) => boolean; 97 private onSelection: 98 (dragStartX: number, dragStartY: number, prevX: number, currentX: number, 99 currentY: number, editing: boolean) => void; 100 private endSelection: (edit: boolean) => void; 101 102 constructor({ 103 element, 104 contentOffsetX, 105 onPanned, 106 onZoomed, 107 editSelection, 108 onSelection, 109 endSelection 110 }: { 111 element: HTMLElement, 112 contentOffsetX: number, 113 onPanned: (movedPx: number) => void, 114 onZoomed: (zoomPositionPx: number, zoomRatio: number) => void, 115 editSelection: (currentPx: number) => boolean, 116 onSelection: 117 (dragStartX: number, dragStartY: number, prevX: number, 118 currentX: number, currentY: number, editing: boolean) => void, 119 endSelection: (edit: boolean) => void, 120 }) { 121 this.element = element; 122 this.contentOffsetX = contentOffsetX; 123 this.onPanned = onPanned; 124 this.onZoomed = onZoomed; 125 this.editSelection = editSelection; 126 this.onSelection = onSelection; 127 this.endSelection = endSelection; 128 129 document.body.addEventListener('keydown', this.boundOnKeyDown); 130 document.body.addEventListener('keyup', this.boundOnKeyUp); 131 this.element.addEventListener('mousemove', this.boundOnMouseMove); 132 this.element.addEventListener('wheel', this.boundOnWheel, {passive: true}); 133 134 let prevX = -1; 135 let dragStartX = -1; 136 let dragStartY = -1; 137 let edit = false; 138 new DragGestureHandler( 139 this.element, 140 (x, y) => { 141 if (this.shiftDown) { 142 this.onPanned(prevX - x); 143 } else { 144 this.onSelection(dragStartX, dragStartY, prevX, x, y, edit); 145 } 146 prevX = x; 147 }, 148 (x, y) => { 149 prevX = x; 150 dragStartX = x; 151 dragStartY = y; 152 edit = this.editSelection(x); 153 // Set the cursor style based on where the cursor is when the drag 154 // starts. 155 if (edit) { 156 this.element.style.cursor = EDITING_RANGE_CURSOR; 157 } else if (!this.shiftDown) { 158 this.element.style.cursor = DRAG_CURSOR; 159 } 160 }, 161 () => { 162 // Reset the cursor now the drag has ended. 163 this.element.style.cursor = this.shiftDown ? PAN_CURSOR : DRAG_CURSOR; 164 dragStartX = -1; 165 dragStartY = -1; 166 this.endSelection(edit); 167 }); 168 } 169 170 171 shutdown() { 172 document.body.removeEventListener('keydown', this.boundOnKeyDown); 173 document.body.removeEventListener('keyup', this.boundOnKeyUp); 174 this.element.removeEventListener('mousemove', this.boundOnMouseMove); 175 this.element.removeEventListener('wheel', this.boundOnWheel); 176 } 177 178 private onPanAnimationStep(msSinceStartOfAnimation: number) { 179 const step = (this.targetPanOffsetPx - this.panOffsetPx) * SNAP_FACTOR; 180 if (this.panning !== Pan.None) { 181 const velocity = 1 + msSinceStartOfAnimation * ACCELERATION_PER_MS; 182 // Pan at least as fast as the snapping animation to avoid a 183 // discontinuity. 184 const targetStep = Math.max(KEYBOARD_PAN_PX_PER_FRAME * velocity, step); 185 this.targetPanOffsetPx += this.panning * targetStep; 186 } 187 this.panOffsetPx += step; 188 if (Math.abs(step) > 1e-1) { 189 this.onPanned(step); 190 } else { 191 this.panAnimation.stop(); 192 } 193 } 194 195 private onZoomAnimationStep(msSinceStartOfAnimation: number) { 196 if (this.mousePositionX === null) return; 197 const step = (this.targetZoomRatio - this.zoomRatio) * SNAP_FACTOR; 198 if (this.zooming !== Zoom.None) { 199 const velocity = 1 + msSinceStartOfAnimation * ACCELERATION_PER_MS; 200 // Zoom at least as fast as the snapping animation to avoid a 201 // discontinuity. 202 const targetStep = Math.max(ZOOM_RATIO_PER_FRAME * velocity, step); 203 this.targetZoomRatio += this.zooming * targetStep; 204 } 205 this.zoomRatio += step; 206 if (Math.abs(step) > 1e-6) { 207 this.onZoomed(this.mousePositionX, step); 208 } else { 209 this.zoomAnimation.stop(); 210 } 211 } 212 213 private onMouseMove(e: MouseEvent) { 214 const pageOffset = 215 globals.frontendLocalState.sidebarVisible ? this.contentOffsetX : 0; 216 // We can't use layerX here because there are many layers in this element. 217 this.mousePositionX = e.clientX - pageOffset; 218 // Only change the cursor when hovering, the DragGestureHandler handles 219 // changing the cursor during drag events. This avoids the problem of 220 // the cursor flickering between styles if you drag fast and get too 221 // far from the current time range. 222 if (e.buttons === 0) { 223 if (this.editSelection(this.mousePositionX)) { 224 this.element.style.cursor = EDITING_RANGE_CURSOR; 225 } else { 226 this.element.style.cursor = this.shiftDown ? PAN_CURSOR : DRAG_CURSOR; 227 } 228 } 229 } 230 231 private onWheel(e: WheelEvent) { 232 if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { 233 this.onPanned(e.deltaX * HORIZONTAL_WHEEL_PAN_SPEED); 234 globals.rafScheduler.scheduleRedraw(); 235 } else if (e.ctrlKey && this.mousePositionX) { 236 const sign = e.deltaY < 0 ? -1 : 1; 237 const deltaY = sign * Math.log2(1 + Math.abs(e.deltaY)); 238 this.onZoomed(this.mousePositionX, deltaY * WHEEL_ZOOM_SPEED); 239 globals.rafScheduler.scheduleRedraw(); 240 } 241 } 242 243 private onKeyDown(e: KeyboardEvent) { 244 this.updateShift(e.shiftKey); 245 if (keyToPan(e) !== Pan.None) { 246 if (this.panning !== keyToPan(e)) { 247 this.panAnimation.stop(); 248 this.panOffsetPx = 0; 249 this.targetPanOffsetPx = keyToPan(e) * INITIAL_PAN_STEP_PX; 250 } 251 this.panning = keyToPan(e); 252 this.panAnimation.start(DEFAULT_ANIMATION_DURATION); 253 } 254 255 if (keyToZoom(e) !== Zoom.None) { 256 if (this.zooming !== keyToZoom(e)) { 257 this.zoomAnimation.stop(); 258 this.zoomRatio = 0; 259 this.targetZoomRatio = keyToZoom(e) * INITIAL_ZOOM_STEP; 260 } 261 this.zooming = keyToZoom(e); 262 this.zoomAnimation.start(DEFAULT_ANIMATION_DURATION); 263 } 264 265 // Handle key events that are not pan or zoom. 266 handleKey(e, true); 267 } 268 269 private onKeyUp(e: KeyboardEvent) { 270 this.updateShift(e.shiftKey); 271 if (keyToPan(e) === this.panning) { 272 this.panning = Pan.None; 273 } 274 if (keyToZoom(e) === this.zooming) { 275 this.zooming = Zoom.None; 276 } 277 278 // Handle key events that are not pan or zoom. 279 handleKey(e, false); 280 } 281 282 // TODO(hjd): Move this shift handling into the viewer page. 283 private updateShift(down: boolean) { 284 if (down === this.shiftDown) return; 285 this.shiftDown = down; 286 if (this.shiftDown) { 287 this.element.style.cursor = PAN_CURSOR; 288 } else if (this.mousePositionX) { 289 this.element.style.cursor = DRAG_CURSOR; 290 } 291 } 292} 293