• 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 Timer = NodeJS.Timer;
17import {DragGestureHandler} from './drag_gesture_handler';
18import {globals} from './globals';
19import {handleKey} from './keyboard_event_handler';
20import {TRACK_SHELL_WIDTH} from './track_constants';
21
22const ZOOM_RATIO_PER_FRAME = 0.008;
23const KEYBOARD_PAN_PX_PER_FRAME = 8;
24const HORIZONTAL_WHEEL_PAN_SPEED = 1;
25const WHEEL_ZOOM_SPEED = -0.02;
26
27// Usually, animations are cancelled on keyup. However, in case the keyup
28// event is not captured by the document, e.g. if it loses focus first, then
29// we want to stop the animation as soon as possible.
30const ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS = 700;
31// This value must be larger than the maximum delta between keydown repeat
32// events. Largest observed value so far: 86ms.
33const ANIMATION_AUTO_END_AFTER_KEYPRESS_MS = 100;
34
35// This defines the step size for an individual pan or zoom keyboard tap.
36const TAP_ANIMATION_TIME = 200;
37
38enum Pan {
39  None = 0,
40  Left = -1,
41  Right = 1
42}
43function keyToPan(e: KeyboardEvent): Pan {
44  if (['a'].includes(e.key)) return Pan.Left;
45  if (['d'].includes(e.key)) return Pan.Right;
46  return Pan.None;
47}
48
49enum Zoom {
50  None = 0,
51  In = 1,
52  Out = -1
53}
54function keyToZoom(e: KeyboardEvent): Zoom {
55  if (['w'].includes(e.key)) return Zoom.In;
56  if (['s'].includes(e.key)) return Zoom.Out;
57  return Zoom.None;
58}
59
60/**
61 * Enables horizontal pan and zoom with mouse-based drag and WASD navigation.
62 */
63export class PanAndZoomHandler {
64  private mousePositionX: number|null = null;
65  private boundOnMouseMove = this.onMouseMove.bind(this);
66  private boundOnWheel = this.onWheel.bind(this);
67  private boundOnKeyDown = this.onKeyDown.bind(this);
68  private boundOnKeyUp = this.onKeyUp.bind(this);
69  private shiftDown = false;
70  private dragStartPx = -1;
71  private panning: Pan = Pan.None;
72  private zooming: Zoom = Zoom.None;
73  private cancelPanTimeout?: Timer;
74  private cancelZoomTimeout?: Timer;
75  private panAnimation = new Animation(this.onPanAnimationStep.bind(this));
76  private zoomAnimation = new Animation(this.onZoomAnimationStep.bind(this));
77
78  private element: HTMLElement;
79  private contentOffsetX: number;
80  private onPanned: (movedPx: number) => void;
81  private onZoomed: (zoomPositionPx: number, zoomRatio: number) => void;
82  private onDragSelect: (selectStartPx: number, selectEndPx: number) => void;
83
84  constructor({element, contentOffsetX, onPanned, onZoomed, onDragSelect}: {
85    element: HTMLElement,
86    contentOffsetX: number,
87    onPanned: (movedPx: number) => void,
88    onZoomed: (zoomPositionPx: number, zoomRatio: number) => void,
89    onDragSelect: (selectStartPx: number, selectEndPx: number) => void
90  }) {
91    this.element = element;
92    this.contentOffsetX = contentOffsetX;
93    this.onPanned = onPanned;
94    this.onZoomed = onZoomed;
95    this.onDragSelect = onDragSelect;
96
97    document.body.addEventListener('keydown', this.boundOnKeyDown);
98    document.body.addEventListener('keyup', this.boundOnKeyUp);
99    this.element.addEventListener('mousemove', this.boundOnMouseMove);
100    this.element.addEventListener('wheel', this.boundOnWheel, {passive: true});
101
102    let lastX = -1;
103    new DragGestureHandler(this.element, x => {
104      if (this.shiftDown && this.dragStartPx !== -1) {
105        this.onDragSelect(this.dragStartPx, x);
106      } else {
107        this.onPanned(lastX - x);
108      }
109      lastX = x;
110    }, x => {
111      lastX = x;
112      if (this.shiftDown) {
113        this.dragStartPx = x;
114      }
115    }, () => {
116      this.dragStartPx = -1;
117    });
118  }
119
120  shutdown() {
121    document.body.removeEventListener('keydown', this.boundOnKeyDown);
122    document.body.removeEventListener('keyup', this.boundOnKeyUp);
123    this.element.removeEventListener('mousemove', this.boundOnMouseMove);
124    this.element.removeEventListener('wheel', this.boundOnWheel);
125  }
126
127  private onPanAnimationStep(msSinceStartOfAnimation: number) {
128    if (this.panning === Pan.None) return;
129    let offset = this.panning * KEYBOARD_PAN_PX_PER_FRAME;
130    offset *= Math.max(msSinceStartOfAnimation / 40, 1);
131    this.onPanned(offset);
132  }
133
134  private onZoomAnimationStep(msSinceStartOfAnimation: number) {
135    if (this.zooming === Zoom.None || this.mousePositionX === null) return;
136    let zoomRatio = this.zooming * ZOOM_RATIO_PER_FRAME;
137    zoomRatio *= Math.max(msSinceStartOfAnimation / 40, 1);
138    this.onZoomed(this.mousePositionX, zoomRatio);
139  }
140
141  private onMouseMove(e: MouseEvent) {
142    // TODO(taylori): Content offset is 6px off, why?
143    this.mousePositionX = e.clientX - this.contentOffsetX - 6;
144    if (this.shiftDown) {
145      const pos = this.mousePositionX - TRACK_SHELL_WIDTH;
146      const ts =
147        globals.frontendLocalState.timeScale.pxToTime(pos);
148      globals.frontendLocalState.setHoveredTimestamp(ts);
149    }
150  }
151
152  private onWheel(e: WheelEvent) {
153    if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
154      this.onPanned(e.deltaX * HORIZONTAL_WHEEL_PAN_SPEED);
155      globals.rafScheduler.scheduleRedraw();
156    } else if (e.ctrlKey && this.mousePositionX) {
157      const sign = e.deltaY < 0 ? -1 : 1;
158      const deltaY = sign * Math.log2(1 + Math.abs(e.deltaY));
159      this.onZoomed(this.mousePositionX, deltaY * WHEEL_ZOOM_SPEED);
160      globals.rafScheduler.scheduleRedraw();
161    }
162  }
163
164  private onKeyDown(e: KeyboardEvent) {
165    this.updateShift(e.shiftKey);
166    if (keyToPan(e) !== Pan.None) {
167      this.panning = keyToPan(e);
168      const animationTime = e.repeat ?
169          ANIMATION_AUTO_END_AFTER_KEYPRESS_MS :
170          ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS;
171      this.panAnimation.start(animationTime);
172      clearTimeout(this.cancelPanTimeout!);
173    }
174
175    if (keyToZoom(e) !== Zoom.None) {
176      this.zooming = keyToZoom(e);
177      const animationTime = e.repeat ?
178          ANIMATION_AUTO_END_AFTER_KEYPRESS_MS :
179          ANIMATION_AUTO_END_AFTER_INITIAL_KEYPRESS_MS;
180      this.zoomAnimation.start(animationTime);
181      clearTimeout(this.cancelZoomTimeout!);
182    }
183
184    // Handle key events that are not pan or zoom.
185    handleKey(e.key, true);
186  }
187
188  private onKeyUp(e: KeyboardEvent) {
189    this.updateShift(e.shiftKey);
190    if (keyToPan(e) === this.panning) {
191      const minEndTime = this.panAnimation.startTimeMs + TAP_ANIMATION_TIME;
192      const t = minEndTime - performance.now();
193      this.cancelPanTimeout = setTimeout(() => this.panAnimation.stop(), t);
194    }
195    if (keyToZoom(e) === this.zooming) {
196      const minEndTime = this.zoomAnimation.startTimeMs + TAP_ANIMATION_TIME;
197      const t = minEndTime - performance.now();
198      this.cancelZoomTimeout = setTimeout(() => this.zoomAnimation.stop(), t);
199    }
200
201    // Handle key events that are not pan or zoom.
202    handleKey(e.key, false);
203  }
204
205  private updateShift(down: boolean) {
206    if (down === this.shiftDown) return;
207    this.shiftDown = down;
208    if (this.shiftDown) {
209      if (this.mousePositionX) {
210        this.element.style.cursor = 'text';
211        const pos = this.mousePositionX - TRACK_SHELL_WIDTH;
212        const ts = globals.frontendLocalState.timeScale.pxToTime(pos);
213        globals.frontendLocalState.setHoveredTimestamp(ts);
214      }
215    } else {
216      globals.frontendLocalState.setHoveredTimestamp(-1);
217      this.element.style.cursor = 'default';
218    }
219
220    globals.frontendLocalState.setShowTimeSelectPreview(this.shiftDown);
221  }
222}
223