• 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 {DisposableStack} from '../../base/disposable_stack';
16import {currentTargetOffset, elementIsEditable} from '../../base/dom_utils';
17import {Animation} from '../animation';
18
19// When first starting to pan or zoom, move at least this many units.
20const INITIAL_PAN_STEP_PX = 50;
21const INITIAL_ZOOM_STEP = 0.1;
22
23// The snappiness (spring constant) of pan and zoom animations [0..1].
24const SNAP_FACTOR = 0.4;
25
26// How much the velocity of a pan or zoom animation increases per millisecond.
27const ACCELERATION_PER_MS = 1 / 50;
28
29// The default duration of a pan or zoom animation. The animation may run longer
30// if the user keeps holding the respective button down or shorter if the button
31// is released. This value so chosen so that it is longer than the typical key
32// repeat timeout to avoid breaks in the animation.
33const DEFAULT_ANIMATION_DURATION = 700;
34
35// The minimum number of units to pan or zoom per frame (before the
36// ACCELERATION_PER_MS multiplier is applied).
37const ZOOM_RATIO_PER_FRAME = 0.008;
38const KEYBOARD_PAN_PX_PER_FRAME = 8;
39
40// Use key mapping based on the 'KeyboardEvent.code' property vs the
41// 'KeyboardEvent.key', because the former corresponds to the physical key
42// position rather than the glyph printed on top of it, and is unaffected by
43// the user's keyboard layout.
44// For example, 'KeyW' always corresponds to the key at the physical location of
45// the 'w' key on an English QWERTY keyboard, regardless of the user's keyboard
46// layout, or at least the layout they have configured in their OS.
47// Seeing as most users use the keys in the English QWERTY "WASD" position for
48// controlling kb+mouse applications like games, it's a good bet that these are
49// the keys most poeple are going to find natural for navigating the UI.
50// See https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system
51export enum KeyMapping {
52  KEY_PAN_LEFT = 'KeyA',
53  KEY_PAN_RIGHT = 'KeyD',
54  KEY_ZOOM_IN = 'KeyW',
55  KEY_ZOOM_OUT = 'KeyS',
56}
57
58enum Pan {
59  None = 0,
60  Left = -1,
61  Right = 1,
62}
63function keyToPan(e: KeyboardEvent): Pan {
64  if (e.code === KeyMapping.KEY_PAN_LEFT) return Pan.Left;
65  if (e.code === KeyMapping.KEY_PAN_RIGHT) return Pan.Right;
66  return Pan.None;
67}
68
69enum Zoom {
70  None = 0,
71  In = 1,
72  Out = -1,
73}
74function keyToZoom(e: KeyboardEvent): Zoom {
75  if (e.code === KeyMapping.KEY_ZOOM_IN) return Zoom.In;
76  if (e.code === KeyMapping.KEY_ZOOM_OUT) return Zoom.Out;
77  return Zoom.None;
78}
79
80/**
81 * Enables horizontal pan and zoom with WASD navigation.
82 */
83export class KeyboardNavigationHandler implements Disposable {
84  private mousePositionX: number | null = null;
85  private boundOnMouseMove = this.onMouseMove.bind(this);
86  private boundOnKeyDown = this.onKeyDown.bind(this);
87  private boundOnKeyUp = this.onKeyUp.bind(this);
88  private panning: Pan = Pan.None;
89  private panOffsetPx = 0;
90  private targetPanOffsetPx = 0;
91  private zooming: Zoom = Zoom.None;
92  private zoomRatio = 0;
93  private targetZoomRatio = 0;
94  private panAnimation = new Animation(this.onPanAnimationStep.bind(this));
95  private zoomAnimation = new Animation(this.onZoomAnimationStep.bind(this));
96
97  private element: HTMLElement;
98  private onPanned: (movedPx: number) => void;
99  private onZoomed: (zoomPositionPx: number, zoomRatio: number) => void;
100  private trash: DisposableStack;
101
102  constructor({
103    element,
104    onPanned,
105    onZoomed,
106  }: {
107    element: HTMLElement;
108    onPanned: (movedPx: number) => void;
109    onZoomed: (zoomPositionPx: number, zoomRatio: number) => void;
110  }) {
111    this.element = element;
112    this.onPanned = onPanned;
113    this.onZoomed = onZoomed;
114    this.trash = new DisposableStack();
115
116    document.body.addEventListener('keydown', this.boundOnKeyDown);
117    document.body.addEventListener('keyup', this.boundOnKeyUp);
118    this.element.addEventListener('mousemove', this.boundOnMouseMove);
119    this.trash.defer(() => {
120      this.element.removeEventListener('mousemove', this.boundOnMouseMove);
121      document.body.removeEventListener('keyup', this.boundOnKeyUp);
122      document.body.removeEventListener('keydown', this.boundOnKeyDown);
123    });
124  }
125
126  [Symbol.dispose]() {
127    this.trash.dispose();
128  }
129
130  private onPanAnimationStep(msSinceStartOfAnimation: number) {
131    const step = (this.targetPanOffsetPx - this.panOffsetPx) * SNAP_FACTOR;
132    if (this.panning !== Pan.None) {
133      const velocity = 1 + msSinceStartOfAnimation * ACCELERATION_PER_MS;
134      // Pan at least as fast as the snapping animation to avoid a
135      // discontinuity.
136      const targetStep = Math.max(KEYBOARD_PAN_PX_PER_FRAME * velocity, step);
137      this.targetPanOffsetPx += this.panning * targetStep;
138    }
139    this.panOffsetPx += step;
140    if (Math.abs(step) > 1e-1) {
141      this.onPanned(step);
142    } else {
143      this.panAnimation.stop();
144    }
145  }
146
147  private onZoomAnimationStep(msSinceStartOfAnimation: number) {
148    if (this.mousePositionX === null) return;
149    const step = (this.targetZoomRatio - this.zoomRatio) * SNAP_FACTOR;
150    if (this.zooming !== Zoom.None) {
151      const velocity = 1 + msSinceStartOfAnimation * ACCELERATION_PER_MS;
152      // Zoom at least as fast as the snapping animation to avoid a
153      // discontinuity.
154      const targetStep = Math.max(ZOOM_RATIO_PER_FRAME * velocity, step);
155      this.targetZoomRatio += this.zooming * targetStep;
156    }
157    this.zoomRatio += step;
158    if (Math.abs(step) > 1e-6) {
159      this.onZoomed(this.mousePositionX, step);
160    } else {
161      this.zoomAnimation.stop();
162    }
163  }
164
165  private onMouseMove(e: MouseEvent) {
166    this.mousePositionX = currentTargetOffset(e).x;
167  }
168
169  // Due to a bug in chrome, we get onKeyDown events fired where the payload is
170  // not a KeyboardEvent when selecting an item from an autocomplete suggestion.
171  // See https://issues.chromium.org/issues/41425904
172  // Thus, we can't assume we get an KeyboardEvent and must check manually.
173  private onKeyDown(e: Event) {
174    if (e instanceof KeyboardEvent) {
175      if (elementIsEditable(e.target)) return;
176
177      if (e.ctrlKey || e.metaKey) return;
178
179      if (keyToPan(e) !== Pan.None) {
180        if (this.panning !== keyToPan(e)) {
181          this.panAnimation.stop();
182          this.panOffsetPx = 0;
183          this.targetPanOffsetPx = keyToPan(e) * INITIAL_PAN_STEP_PX;
184        }
185        this.panning = keyToPan(e);
186        this.panAnimation.start(DEFAULT_ANIMATION_DURATION);
187      }
188
189      if (keyToZoom(e) !== Zoom.None) {
190        if (this.zooming !== keyToZoom(e)) {
191          this.zoomAnimation.stop();
192          this.zoomRatio = 0;
193          this.targetZoomRatio = keyToZoom(e) * INITIAL_ZOOM_STEP;
194        }
195        this.zooming = keyToZoom(e);
196        this.zoomAnimation.start(DEFAULT_ANIMATION_DURATION);
197      }
198    }
199  }
200
201  private onKeyUp(e: Event) {
202    if (e instanceof KeyboardEvent) {
203      if (e.ctrlKey || e.metaKey) return;
204
205      if (keyToPan(e) === this.panning) {
206        this.panning = Pan.None;
207      }
208      if (keyToZoom(e) === this.zooming) {
209        this.zooming = Zoom.None;
210      }
211    }
212  }
213}
214