Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 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 | |
| 15 | import {Animation} from './animation'; |
| 16 | import Timer = NodeJS.Timer; |
Michail Schwab | 1430205 | 2018-08-03 17:23:35 -0400 | [diff] [blame] | 17 | import {DragGestureHandler} from './drag_gesture_handler'; |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 18 | import {globals} from './globals'; |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 19 | |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 20 | const ZOOM_RATIO_PER_FRAME = 0.008; |
| 21 | const KEYBOARD_PAN_PX_PER_FRAME = 8; |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 22 | const HORIZONTAL_WHEEL_PAN_SPEED = 1; |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 23 | const WHEEL_ZOOM_SPEED = -0.02; |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 24 | |
| 25 | // Usually, animations are cancelled on keyup. However, in case the keyup |
| 26 | // event is not captured by the document, e.g. if it loses focus first, then |
| 27 | // we want to stop the animation as soon as possible. |
| 28 | const ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS = 700; |
| 29 | const ANIMATION_AUTO_END_AFTER_KEYPRESS_MS = 80; |
| 30 | |
| 31 | // This defines the step size for an individual pan or zoom keyboard tap. |
| 32 | const TAP_ANIMATION_TIME = 200; |
| 33 | |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 34 | enum Pan { |
| 35 | None = 0, |
| 36 | Left = -1, |
| 37 | Right = 1 |
| 38 | } |
| 39 | function keyToPan(e: KeyboardEvent): Pan { |
| 40 | if (['a'].includes(e.key)) return Pan.Left; |
| 41 | if (['d'].includes(e.key)) return Pan.Right; |
| 42 | return Pan.None; |
| 43 | } |
| 44 | |
| 45 | enum Zoom { |
| 46 | None = 0, |
| 47 | In = 1, |
| 48 | Out = -1 |
| 49 | } |
| 50 | function keyToZoom(e: KeyboardEvent): Zoom { |
| 51 | if (['w'].includes(e.key)) return Zoom.In; |
| 52 | if (['s'].includes(e.key)) return Zoom.Out; |
| 53 | return Zoom.None; |
| 54 | } |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 55 | |
| 56 | /** |
| 57 | * Enables horizontal pan and zoom with mouse-based drag and WASD navigation. |
| 58 | */ |
| 59 | export class PanAndZoomHandler { |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 60 | private mousePositionX: number|null = null; |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 61 | private boundOnMouseMove = this.onMouseMove.bind(this); |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 62 | private boundOnWheel = this.onWheel.bind(this); |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 63 | private boundOnKeyDown = this.onKeyDown.bind(this); |
| 64 | private boundOnKeyUp = this.onKeyUp.bind(this); |
| 65 | private panning: Pan = Pan.None; |
| 66 | private zooming: Zoom = Zoom.None; |
| 67 | private cancelPanTimeout?: Timer; |
| 68 | private cancelZoomTimeout?: Timer; |
| 69 | private panAnimation = new Animation(this.onPanAnimationStep.bind(this)); |
| 70 | private zoomAnimation = new Animation(this.onZoomAnimationStep.bind(this)); |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 71 | |
| 72 | private element: HTMLElement; |
| 73 | private contentOffsetX: number; |
| 74 | private onPanned: (movedPx: number) => void; |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 75 | private onZoomed: (zoomPositionPx: number, zoomRatio: number) => void; |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 76 | |
| 77 | constructor({element, contentOffsetX, onPanned, onZoomed}: { |
| 78 | element: HTMLElement, |
| 79 | contentOffsetX: number, |
| 80 | onPanned: (movedPx: number) => void, |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 81 | onZoomed: (zoomPositionPx: number, zoomRatio: number) => void, |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 82 | }) { |
| 83 | this.element = element; |
| 84 | this.contentOffsetX = contentOffsetX; |
| 85 | this.onPanned = onPanned; |
| 86 | this.onZoomed = onZoomed; |
| 87 | |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 88 | document.body.addEventListener('keydown', this.boundOnKeyDown); |
| 89 | document.body.addEventListener('keyup', this.boundOnKeyUp); |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 90 | this.element.addEventListener('mousemove', this.boundOnMouseMove); |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 91 | this.element.addEventListener('wheel', this.boundOnWheel, {passive: true}); |
| 92 | |
Michail Schwab | 1430205 | 2018-08-03 17:23:35 -0400 | [diff] [blame] | 93 | let lastX = -1; |
| 94 | new DragGestureHandler(this.element, x => { |
| 95 | this.onPanned(lastX - x); |
| 96 | lastX = x; |
| 97 | }, x => lastX = x); |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 98 | } |
| 99 | |
| 100 | shutdown() { |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 101 | document.body.removeEventListener('keydown', this.boundOnKeyDown); |
| 102 | document.body.removeEventListener('keyup', this.boundOnKeyUp); |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 103 | this.element.removeEventListener('mousemove', this.boundOnMouseMove); |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 104 | this.element.removeEventListener('wheel', this.boundOnWheel); |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 105 | } |
| 106 | |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 107 | private onPanAnimationStep(msSinceStartOfAnimation: number) { |
| 108 | if (this.panning === Pan.None) return; |
| 109 | let offset = this.panning * KEYBOARD_PAN_PX_PER_FRAME; |
| 110 | offset *= Math.max(msSinceStartOfAnimation / 40, 1); |
| 111 | this.onPanned(offset); |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 112 | } |
| 113 | |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 114 | private onZoomAnimationStep(msSinceStartOfAnimation: number) { |
| 115 | if (this.zooming === Zoom.None || this.mousePositionX === null) return; |
| 116 | let zoomRatio = this.zooming * ZOOM_RATIO_PER_FRAME; |
| 117 | zoomRatio *= Math.max(msSinceStartOfAnimation / 40, 1); |
| 118 | this.onZoomed(this.mousePositionX, zoomRatio); |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 119 | } |
| 120 | |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 121 | private onMouseMove(e: MouseEvent) { |
Michail Schwab | 1430205 | 2018-08-03 17:23:35 -0400 | [diff] [blame] | 122 | this.mousePositionX = e.clientX - this.contentOffsetX; |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 123 | } |
| 124 | |
| 125 | private onWheel(e: WheelEvent) { |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 126 | if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 127 | this.onPanned(e.deltaX * HORIZONTAL_WHEEL_PAN_SPEED); |
Deepanjan Roy | f190cb2 | 2018-08-28 10:43:07 -0400 | [diff] [blame] | 128 | globals.rafScheduler.scheduleRedraw(); |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 129 | } else if (e.ctrlKey && this.mousePositionX) { |
| 130 | const sign = e.deltaY < 0 ? -1 : 1; |
| 131 | const deltaY = sign * Math.log2(1 + Math.abs(e.deltaY)); |
| 132 | this.onZoomed(this.mousePositionX, deltaY * WHEEL_ZOOM_SPEED); |
Deepanjan Roy | f190cb2 | 2018-08-28 10:43:07 -0400 | [diff] [blame] | 133 | globals.rafScheduler.scheduleRedraw(); |
Primiano Tucci | f30cd9c | 2018-08-13 01:53:26 +0200 | [diff] [blame] | 134 | } |
| 135 | } |
| 136 | |
| 137 | private onKeyDown(e: KeyboardEvent) { |
| 138 | if (keyToPan(e) !== Pan.None) { |
| 139 | this.panning = keyToPan(e); |
| 140 | const animationTime = e.repeat ? |
| 141 | ANIMATION_AUTO_END_AFTER_KEYPRESS_MS : |
| 142 | ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS; |
| 143 | this.panAnimation.start(animationTime); |
| 144 | clearTimeout(this.cancelPanTimeout!); |
| 145 | } |
| 146 | |
| 147 | if (keyToZoom(e) !== Zoom.None) { |
| 148 | this.zooming = keyToZoom(e); |
| 149 | const animationTime = e.repeat ? |
| 150 | ANIMATION_AUTO_END_AFTER_KEYPRESS_MS : |
| 151 | ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS; |
| 152 | this.zoomAnimation.start(animationTime); |
| 153 | clearTimeout(this.cancelZoomTimeout!); |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | private onKeyUp(e: KeyboardEvent) { |
| 158 | if (keyToPan(e) === this.panning) { |
| 159 | const minEndTime = this.panAnimation.startTimeMs + TAP_ANIMATION_TIME; |
| 160 | const t = minEndTime - performance.now(); |
| 161 | this.cancelPanTimeout = setTimeout(() => this.panAnimation.stop(), t); |
| 162 | } |
| 163 | if (keyToZoom(e) === this.zooming) { |
| 164 | const minEndTime = this.zoomAnimation.startTimeMs + TAP_ANIMATION_TIME; |
| 165 | const t = minEndTime - performance.now(); |
| 166 | this.cancelZoomTimeout = setTimeout(() => this.zoomAnimation.stop(), t); |
Michail Schwab | 405002c | 2018-07-26 13:19:10 -0400 | [diff] [blame] | 167 | } |
| 168 | } |
Deepanjan Roy | f190cb2 | 2018-08-28 10:43:07 -0400 | [diff] [blame] | 169 | } |