blob: e485d63f3aca990f22a7ecd22c1aa0bf58a6c8b3 [file] [log] [blame]
Michail Schwab405002c2018-07-26 13:19:10 -04001// 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 Timer = NodeJS.Timer;
Michail Schwab14302052018-08-03 17:23:35 -040017import {DragGestureHandler} from './drag_gesture_handler';
Primiano Tuccif30cd9c2018-08-13 01:53:26 +020018import {globals} from './globals';
Michail Schwab405002c2018-07-26 13:19:10 -040019
Primiano Tuccif30cd9c2018-08-13 01:53:26 +020020const ZOOM_RATIO_PER_FRAME = 0.008;
21const KEYBOARD_PAN_PX_PER_FRAME = 8;
Michail Schwab405002c2018-07-26 13:19:10 -040022const HORIZONTAL_WHEEL_PAN_SPEED = 1;
Primiano Tuccif30cd9c2018-08-13 01:53:26 +020023const WHEEL_ZOOM_SPEED = -0.02;
Michail Schwab405002c2018-07-26 13:19:10 -040024
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.
28const ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS = 700;
29const ANIMATION_AUTO_END_AFTER_KEYPRESS_MS = 80;
30
31// This defines the step size for an individual pan or zoom keyboard tap.
32const TAP_ANIMATION_TIME = 200;
33
Primiano Tuccif30cd9c2018-08-13 01:53:26 +020034enum Pan {
35 None = 0,
36 Left = -1,
37 Right = 1
38}
39function 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
45enum Zoom {
46 None = 0,
47 In = 1,
48 Out = -1
49}
50function 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 Schwab405002c2018-07-26 13:19:10 -040055
56/**
57 * Enables horizontal pan and zoom with mouse-based drag and WASD navigation.
58 */
59export class PanAndZoomHandler {
Michail Schwab405002c2018-07-26 13:19:10 -040060 private mousePositionX: number|null = null;
Michail Schwab405002c2018-07-26 13:19:10 -040061 private boundOnMouseMove = this.onMouseMove.bind(this);
Michail Schwab405002c2018-07-26 13:19:10 -040062 private boundOnWheel = this.onWheel.bind(this);
Primiano Tuccif30cd9c2018-08-13 01:53:26 +020063 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 Schwab405002c2018-07-26 13:19:10 -040071
72 private element: HTMLElement;
73 private contentOffsetX: number;
74 private onPanned: (movedPx: number) => void;
Primiano Tuccif30cd9c2018-08-13 01:53:26 +020075 private onZoomed: (zoomPositionPx: number, zoomRatio: number) => void;
Michail Schwab405002c2018-07-26 13:19:10 -040076
77 constructor({element, contentOffsetX, onPanned, onZoomed}: {
78 element: HTMLElement,
79 contentOffsetX: number,
80 onPanned: (movedPx: number) => void,
Primiano Tuccif30cd9c2018-08-13 01:53:26 +020081 onZoomed: (zoomPositionPx: number, zoomRatio: number) => void,
Michail Schwab405002c2018-07-26 13:19:10 -040082 }) {
83 this.element = element;
84 this.contentOffsetX = contentOffsetX;
85 this.onPanned = onPanned;
86 this.onZoomed = onZoomed;
87
Primiano Tuccif30cd9c2018-08-13 01:53:26 +020088 document.body.addEventListener('keydown', this.boundOnKeyDown);
89 document.body.addEventListener('keyup', this.boundOnKeyUp);
Michail Schwab405002c2018-07-26 13:19:10 -040090 this.element.addEventListener('mousemove', this.boundOnMouseMove);
Michail Schwab405002c2018-07-26 13:19:10 -040091 this.element.addEventListener('wheel', this.boundOnWheel, {passive: true});
92
Michail Schwab14302052018-08-03 17:23:35 -040093 let lastX = -1;
94 new DragGestureHandler(this.element, x => {
95 this.onPanned(lastX - x);
96 lastX = x;
97 }, x => lastX = x);
Michail Schwab405002c2018-07-26 13:19:10 -040098 }
99
100 shutdown() {
Primiano Tuccif30cd9c2018-08-13 01:53:26 +0200101 document.body.removeEventListener('keydown', this.boundOnKeyDown);
102 document.body.removeEventListener('keyup', this.boundOnKeyUp);
Michail Schwab405002c2018-07-26 13:19:10 -0400103 this.element.removeEventListener('mousemove', this.boundOnMouseMove);
Michail Schwab405002c2018-07-26 13:19:10 -0400104 this.element.removeEventListener('wheel', this.boundOnWheel);
Michail Schwab405002c2018-07-26 13:19:10 -0400105 }
106
Primiano Tuccif30cd9c2018-08-13 01:53:26 +0200107 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 Schwab405002c2018-07-26 13:19:10 -0400112 }
113
Primiano Tuccif30cd9c2018-08-13 01:53:26 +0200114 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 Schwab405002c2018-07-26 13:19:10 -0400119 }
120
Michail Schwab405002c2018-07-26 13:19:10 -0400121 private onMouseMove(e: MouseEvent) {
Michail Schwab14302052018-08-03 17:23:35 -0400122 this.mousePositionX = e.clientX - this.contentOffsetX;
Michail Schwab405002c2018-07-26 13:19:10 -0400123 }
124
125 private onWheel(e: WheelEvent) {
Primiano Tuccif30cd9c2018-08-13 01:53:26 +0200126 if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
Michail Schwab405002c2018-07-26 13:19:10 -0400127 this.onPanned(e.deltaX * HORIZONTAL_WHEEL_PAN_SPEED);
Deepanjan Royf190cb22018-08-28 10:43:07 -0400128 globals.rafScheduler.scheduleRedraw();
Primiano Tuccif30cd9c2018-08-13 01:53:26 +0200129 } 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 Royf190cb22018-08-28 10:43:07 -0400133 globals.rafScheduler.scheduleRedraw();
Primiano Tuccif30cd9c2018-08-13 01:53:26 +0200134 }
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 Schwab405002c2018-07-26 13:19:10 -0400167 }
168 }
Deepanjan Royf190cb22018-08-28 10:43:07 -0400169}