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