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