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