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