blob: 4536b9e6fd97055788bbc7ad1ccba1122ee443cd [file] [log] [blame]
// Copyright (C) 2018 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {DisposableStack} from '../base/disposable_stack';
import {currentTargetOffset, elementIsEditable} from '../base/dom_utils';
import {raf} from '../core/raf_scheduler';
import {Animation} from './animation';
import {DragGestureHandler} from '../base/drag_gesture_handler';
// When first starting to pan or zoom, move at least this many units.
const INITIAL_PAN_STEP_PX = 50;
const INITIAL_ZOOM_STEP = 0.1;
// The snappiness (spring constant) of pan and zoom animations [0..1].
const SNAP_FACTOR = 0.4;
// How much the velocity of a pan or zoom animation increases per millisecond.
const ACCELERATION_PER_MS = 1 / 50;
// The default duration of a pan or zoom animation. The animation may run longer
// if the user keeps holding the respective button down or shorter if the button
// is released. This value so chosen so that it is longer than the typical key
// repeat timeout to avoid breaks in the animation.
const DEFAULT_ANIMATION_DURATION = 700;
// The minimum number of units to pan or zoom per frame (before the
// ACCELERATION_PER_MS multiplier is applied).
const ZOOM_RATIO_PER_FRAME = 0.008;
const KEYBOARD_PAN_PX_PER_FRAME = 8;
// Scroll wheel animation steps.
const HORIZONTAL_WHEEL_PAN_SPEED = 1;
const WHEEL_ZOOM_SPEED = -0.02;
const EDITING_RANGE_CURSOR = 'ew-resize';
const DRAG_CURSOR = 'default';
const PAN_CURSOR = 'move';
// Use key mapping based on the 'KeyboardEvent.code' property vs the
// 'KeyboardEvent.key', because the former corresponds to the physical key
// position rather than the glyph printed on top of it, and is unaffected by
// the user's keyboard layout.
// For example, 'KeyW' always corresponds to the key at the physical location of
// the 'w' key on an English QWERTY keyboard, regardless of the user's keyboard
// layout, or at least the layout they have configured in their OS.
// Seeing as most users use the keys in the English QWERTY "WASD" position for
// controlling kb+mouse applications like games, it's a good bet that these are
// the keys most poeple are going to find natural for navigating the UI.
// See https://www.w3.org/TR/uievents-code/#key-alphanumeric-writing-system
export enum KeyMapping {
KEY_PAN_LEFT = 'KeyA',
KEY_PAN_RIGHT = 'KeyD',
KEY_ZOOM_IN = 'KeyW',
KEY_ZOOM_OUT = 'KeyS',
}
enum Pan {
None = 0,
Left = -1,
Right = 1,
}
function keyToPan(e: KeyboardEvent): Pan {
if (e.code === KeyMapping.KEY_PAN_LEFT) return Pan.Left;
if (e.code === KeyMapping.KEY_PAN_RIGHT) return Pan.Right;
return Pan.None;
}
enum Zoom {
None = 0,
In = 1,
Out = -1,
}
function keyToZoom(e: KeyboardEvent): Zoom {
if (e.code === KeyMapping.KEY_ZOOM_IN) return Zoom.In;
if (e.code === KeyMapping.KEY_ZOOM_OUT) return Zoom.Out;
return Zoom.None;
}
/**
* Enables horizontal pan and zoom with mouse-based drag and WASD navigation.
*/
export class PanAndZoomHandler implements Disposable {
private mousePositionX: number | null = null;
private boundOnMouseMove = this.onMouseMove.bind(this);
private boundOnWheel = this.onWheel.bind(this);
private boundOnKeyDown = this.onKeyDown.bind(this);
private boundOnKeyUp = this.onKeyUp.bind(this);
private shiftDown = false;
private panning: Pan = Pan.None;
private panOffsetPx = 0;
private targetPanOffsetPx = 0;
private zooming: Zoom = Zoom.None;
private zoomRatio = 0;
private targetZoomRatio = 0;
private panAnimation = new Animation(this.onPanAnimationStep.bind(this));
private zoomAnimation = new Animation(this.onZoomAnimationStep.bind(this));
private element: HTMLElement;
private onPanned: (movedPx: number) => void;
private onZoomed: (zoomPositionPx: number, zoomRatio: number) => void;
private editSelection: (currentPx: number) => boolean;
private onSelection: (
dragStartX: number,
dragStartY: number,
prevX: number,
currentX: number,
currentY: number,
editing: boolean,
) => void;
private endSelection: (edit: boolean) => void;
private trash: DisposableStack;
constructor({
element,
onPanned,
onZoomed,
editSelection,
onSelection,
endSelection,
}: {
element: HTMLElement;
onPanned: (movedPx: number) => void;
onZoomed: (zoomPositionPx: number, zoomRatio: number) => void;
editSelection: (currentPx: number) => boolean;
onSelection: (
dragStartX: number,
dragStartY: number,
prevX: number,
currentX: number,
currentY: number,
editing: boolean,
) => void;
endSelection: (edit: boolean) => void;
}) {
this.element = element;
this.onPanned = onPanned;
this.onZoomed = onZoomed;
this.editSelection = editSelection;
this.onSelection = onSelection;
this.endSelection = endSelection;
this.trash = new DisposableStack();
document.body.addEventListener('keydown', this.boundOnKeyDown);
document.body.addEventListener('keyup', this.boundOnKeyUp);
this.element.addEventListener('mousemove', this.boundOnMouseMove);
this.element.addEventListener('wheel', this.boundOnWheel, {passive: true});
this.trash.defer(() => {
this.element.removeEventListener('wheel', this.boundOnWheel);
this.element.removeEventListener('mousemove', this.boundOnMouseMove);
document.body.removeEventListener('keyup', this.boundOnKeyUp);
document.body.removeEventListener('keydown', this.boundOnKeyDown);
});
let prevX = -1;
let dragStartX = -1;
let dragStartY = -1;
let edit = false;
this.trash.use(
new DragGestureHandler(
this.element,
(x, y) => {
if (this.shiftDown) {
this.onPanned(prevX - x);
} else {
this.onSelection(dragStartX, dragStartY, prevX, x, y, edit);
}
prevX = x;
},
(x, y) => {
prevX = x;
dragStartX = x;
dragStartY = y;
edit = this.editSelection(x);
// Set the cursor style based on where the cursor is when the drag
// starts.
if (edit) {
this.element.style.cursor = EDITING_RANGE_CURSOR;
} else if (!this.shiftDown) {
this.element.style.cursor = DRAG_CURSOR;
}
},
() => {
// Reset the cursor now the drag has ended.
this.element.style.cursor = this.shiftDown ? PAN_CURSOR : DRAG_CURSOR;
dragStartX = -1;
dragStartY = -1;
this.endSelection(edit);
},
),
);
}
[Symbol.dispose]() {
this.trash.dispose();
}
private onPanAnimationStep(msSinceStartOfAnimation: number) {
const step = (this.targetPanOffsetPx - this.panOffsetPx) * SNAP_FACTOR;
if (this.panning !== Pan.None) {
const velocity = 1 + msSinceStartOfAnimation * ACCELERATION_PER_MS;
// Pan at least as fast as the snapping animation to avoid a
// discontinuity.
const targetStep = Math.max(KEYBOARD_PAN_PX_PER_FRAME * velocity, step);
this.targetPanOffsetPx += this.panning * targetStep;
}
this.panOffsetPx += step;
if (Math.abs(step) > 1e-1) {
this.onPanned(step);
} else {
this.panAnimation.stop();
}
}
private onZoomAnimationStep(msSinceStartOfAnimation: number) {
if (this.mousePositionX === null) return;
const step = (this.targetZoomRatio - this.zoomRatio) * SNAP_FACTOR;
if (this.zooming !== Zoom.None) {
const velocity = 1 + msSinceStartOfAnimation * ACCELERATION_PER_MS;
// Zoom at least as fast as the snapping animation to avoid a
// discontinuity.
const targetStep = Math.max(ZOOM_RATIO_PER_FRAME * velocity, step);
this.targetZoomRatio += this.zooming * targetStep;
}
this.zoomRatio += step;
if (Math.abs(step) > 1e-6) {
this.onZoomed(this.mousePositionX, step);
} else {
this.zoomAnimation.stop();
}
}
private onMouseMove(e: MouseEvent) {
this.mousePositionX = currentTargetOffset(e).x;
// Only change the cursor when hovering, the DragGestureHandler handles
// changing the cursor during drag events. This avoids the problem of
// the cursor flickering between styles if you drag fast and get too
// far from the current time range.
if (e.buttons === 0) {
if (this.editSelection(this.mousePositionX)) {
this.element.style.cursor = EDITING_RANGE_CURSOR;
} else {
this.element.style.cursor = this.shiftDown ? PAN_CURSOR : DRAG_CURSOR;
}
}
}
private onWheel(e: WheelEvent) {
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
this.onPanned(e.deltaX * HORIZONTAL_WHEEL_PAN_SPEED);
raf.scheduleRedraw();
} else if (e.ctrlKey && this.mousePositionX !== null) {
const sign = e.deltaY < 0 ? -1 : 1;
const deltaY = sign * Math.log2(1 + Math.abs(e.deltaY));
this.onZoomed(this.mousePositionX, deltaY * WHEEL_ZOOM_SPEED);
raf.scheduleRedraw();
}
}
// Due to a bug in chrome, we get onKeyDown events fired where the payload is
// not a KeyboardEvent when selecting an item from an autocomplete suggestion.
// See https://issues.chromium.org/issues/41425904
// Thus, we can't assume we get an KeyboardEvent and must check manually.
private onKeyDown(e: Event) {
if (e instanceof KeyboardEvent) {
if (elementIsEditable(e.target)) return;
this.updateShift(e.shiftKey);
if (e.ctrlKey || e.metaKey) return;
if (keyToPan(e) !== Pan.None) {
if (this.panning !== keyToPan(e)) {
this.panAnimation.stop();
this.panOffsetPx = 0;
this.targetPanOffsetPx = keyToPan(e) * INITIAL_PAN_STEP_PX;
}
this.panning = keyToPan(e);
this.panAnimation.start(DEFAULT_ANIMATION_DURATION);
}
if (keyToZoom(e) !== Zoom.None) {
if (this.zooming !== keyToZoom(e)) {
this.zoomAnimation.stop();
this.zoomRatio = 0;
this.targetZoomRatio = keyToZoom(e) * INITIAL_ZOOM_STEP;
}
this.zooming = keyToZoom(e);
this.zoomAnimation.start(DEFAULT_ANIMATION_DURATION);
}
}
}
private onKeyUp(e: Event) {
if (e instanceof KeyboardEvent) {
this.updateShift(e.shiftKey);
if (e.ctrlKey || e.metaKey) return;
if (keyToPan(e) === this.panning) {
this.panning = Pan.None;
}
if (keyToZoom(e) === this.zooming) {
this.zooming = Zoom.None;
}
}
}
// TODO(hjd): Move this shift handling into the viewer page.
private updateShift(down: boolean) {
if (down === this.shiftDown) return;
this.shiftDown = down;
if (this.shiftDown) {
this.element.style.cursor = PAN_CURSOR;
} else if (this.mousePositionX !== null) {
this.element.style.cursor = DRAG_CURSOR;
}
}
}