js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
516 lines (515 loc) • 23.3 kB
JavaScript
import { Mat33, Vec3, Vec2 } from '@js-draw/math';
import { PointerDevice } from '../Pointer.mjs';
import { EditorEventType } from '../types.mjs';
import untilNextAnimationFrame from '../util/untilNextAnimationFrame.mjs';
import { Viewport } from '../Viewport.mjs';
import BaseTool from './BaseTool.mjs';
import { moveDownKeyboardShortcutId, moveLeftKeyboardShortcutId, moveRightKeyboardShortcutId, moveUpKeyboardShortcutId, rotateClockwiseKeyboardShortcutId, rotateCounterClockwiseKeyboardShortcutId, zoomInKeyboardShortcutId, zoomOutKeyboardShortcutId, } from './keybindings.mjs';
export var PanZoomMode;
(function (PanZoomMode) {
/** Touch gestures with a single pointer. Ignores non-touch gestures. */
PanZoomMode[PanZoomMode["OneFingerTouchGestures"] = 1] = "OneFingerTouchGestures";
/** Touch gestures with exactly two pointers. Ignores non-touch gestures. */
PanZoomMode[PanZoomMode["TwoFingerTouchGestures"] = 2] = "TwoFingerTouchGestures";
PanZoomMode[PanZoomMode["RightClickDrags"] = 4] = "RightClickDrags";
/** Single-pointer gestures of *any* type (including touch). */
PanZoomMode[PanZoomMode["SinglePointerGestures"] = 8] = "SinglePointerGestures";
/** Keyboard navigation (e.g. LeftArrow to move left). */
PanZoomMode[PanZoomMode["Keyboard"] = 16] = "Keyboard";
/** If provided, prevents **this** tool from rotating the viewport (other tools may still do so). */
PanZoomMode[PanZoomMode["RotationLocked"] = 32] = "RotationLocked";
})(PanZoomMode || (PanZoomMode = {}));
class InertialScroller {
constructor(initialVelocity, scrollBy, onComplete) {
this.initialVelocity = initialVelocity;
this.scrollBy = scrollBy;
this.onComplete = onComplete;
this.running = false;
this.start();
}
async start() {
if (this.running) {
return;
}
this.currentVelocity = this.initialVelocity;
let lastTime = performance.now();
this.running = true;
const maxSpeed = 5000; // units/s
const minSpeed = 200; // units/s
if (this.currentVelocity.magnitude() > maxSpeed) {
this.currentVelocity = this.currentVelocity.normalized().times(maxSpeed);
}
while (this.running && this.currentVelocity.magnitude() > minSpeed) {
const nowTime = performance.now();
const dt = (nowTime - lastTime) / 1000;
this.currentVelocity = this.currentVelocity.times(Math.pow(1 / 8, dt));
this.scrollBy(this.currentVelocity.times(dt));
await untilNextAnimationFrame();
lastTime = nowTime;
}
if (this.running) {
this.stop();
}
}
getCurrentVelocity() {
if (!this.running) {
return null;
}
return this.currentVelocity;
}
stop() {
if (this.running) {
this.running = false;
this.onComplete();
}
}
}
/**
* This tool moves the viewport in response to touchpad, touchscreen, mouse, and keyboard events.
*
* Which events are handled, and which are skipped, are determined by the tool's `mode`. For example,
* a `PanZoom` tool with `mode = PanZoomMode.TwoFingerTouchGestures|PanZoomMode.RightClickDrags` would
* respond to right-click drag events and two-finger touch gestures.
*
* @see {@link setModeEnabled}
*/
export default class PanZoom extends BaseTool {
constructor(editor, mode, description) {
super(editor.notifier, description);
this.editor = editor;
this.mode = mode;
this.transform = null;
// Constants
// initialRotationSnapAngle is larger than afterRotationStartSnapAngle to
// make it more difficult to start rotating (and easier to continue rotating).
this.initialRotationSnapAngle = 0.22; // radians
this.afterRotationStartSnapAngle = 0.07; // radians
this.pinchZoomStartThreshold = 1.08; // scale factor
// Last timestamp at which a pointerdown event was received
this.lastPointerDownTimestamp = 0;
this.initialTouchAngle = 0;
this.initialViewportRotation = 0;
this.initialViewportScale = 0;
// Set to `true` only when scaling has started (if two fingers are down and have moved
// far enough).
this.isScaling = false;
this.isRotating = false;
this.inertialScroller = null;
this.velocity = null;
}
// The pan/zoom tool can be used in a read-only editor.
canReceiveInputInReadOnlyEditor() {
return true;
}
// Returns information about the pointers in a gesture
computePinchData(p1, p2) {
// Swap the pointers to ensure consistent ordering.
if (p1.id < p2.id) {
const tmp = p1;
p1 = p2;
p2 = tmp;
}
const screenBetween = p2.screenPos.minus(p1.screenPos);
const angle = screenBetween.angle();
const dist = screenBetween.magnitude();
const canvasCenter = p2.canvasPos.plus(p1.canvasPos).times(0.5);
const screenCenter = p2.screenPos.plus(p1.screenPos).times(0.5);
return { canvasCenter, screenCenter, angle, dist };
}
allPointersAreOfType(pointers, kind) {
return pointers.every((pointer) => pointer.device === kind);
}
onPointerDown({ allPointers: pointers, current: currentPointer, }) {
let handlingGesture = false;
const inertialScrollerVelocity = this.inertialScroller?.getCurrentVelocity() ?? Vec2.zero;
this.inertialScroller?.stop();
this.velocity = inertialScrollerVelocity;
this.lastPointerDownTimestamp = currentPointer.timeStamp;
const allAreTouch = this.allPointersAreOfType(pointers, PointerDevice.Touch);
const isRightClick = this.allPointersAreOfType(pointers, PointerDevice.RightButtonMouse);
if (allAreTouch && pointers.length === 2 && this.mode & PanZoomMode.TwoFingerTouchGestures) {
const { screenCenter, angle, dist } = this.computePinchData(pointers[0], pointers[1]);
this.lastTouchDist = dist;
this.startTouchDist = dist;
this.lastScreenCenter = screenCenter;
this.initialTouchAngle = angle;
this.initialViewportRotation = this.editor.viewport.getRotationAngle();
this.initialViewportScale = this.editor.viewport.getScaleFactor();
this.isScaling = false;
// We're initially rotated if `initialViewportRotation` isn't near a multiple of pi/2.
// In other words, if sin(2 initialViewportRotation) is near zero.
this.isRotating = Math.abs(Math.sin(this.initialViewportRotation * 2)) > 1e-3;
handlingGesture = true;
}
else if (pointers.length === 1 &&
((this.mode & PanZoomMode.OneFingerTouchGestures && allAreTouch) ||
(isRightClick && this.mode & PanZoomMode.RightClickDrags) ||
this.mode & PanZoomMode.SinglePointerGestures)) {
this.lastScreenCenter = pointers[0].screenPos;
this.isScaling = false;
handlingGesture = true;
}
if (handlingGesture) {
this.lastTimestamp = performance.now();
this.transform ??= Viewport.transformBy(Mat33.identity);
this.editor.display.setDraftMode(true);
}
return handlingGesture;
}
updateVelocity(currentCenter) {
const deltaPos = currentCenter.minus(this.lastScreenCenter);
let deltaTime = (performance.now() - this.lastTimestamp) / 1000;
// Ignore duplicate events, unless there has been enough time between them.
if (deltaPos.magnitude() === 0 && deltaTime < 0.1) {
return;
}
// We divide by deltaTime. Don't divide by zero.
if (deltaTime === 0) {
return;
}
// Don't divide by almost zero, either
deltaTime = Math.max(deltaTime, 0.01);
const currentVelocity = deltaPos.times(1 / deltaTime);
let smoothedVelocity = currentVelocity;
if (this.velocity) {
smoothedVelocity = this.velocity.lerp(currentVelocity, 0.5);
}
this.velocity = smoothedVelocity;
}
// Returns the change in position of the center of the given group of pointers.
// Assumes this.lastScreenCenter has been set appropriately.
getCenterDelta(screenCenter) {
// Use transformVec3 to avoid translating the delta
const delta = this.editor.viewport.screenToCanvasTransform.transformVec3(screenCenter.minus(this.lastScreenCenter));
return delta;
}
// Snaps `angle` to common desired rotations. For example, if `touchAngle` corresponds
// to a viewport rotation of 90.1 degrees, this function returns a rotation delta that,
// when applied to the viewport, rotates the viewport to 90.0 degrees.
//
// Returns a snapped rotation delta that, when applied to the viewport, rotates the viewport,
// from its position on the last touchDown event, by `touchAngle - initialTouchAngle`.
toSnappedRotationDelta(touchAngle) {
const deltaAngle = touchAngle - this.initialTouchAngle;
let fullRotation = deltaAngle + this.initialViewportRotation;
const snapToMultipleOf = Math.PI / 2;
const roundedFullRotation = Math.round(fullRotation / snapToMultipleOf) * snapToMultipleOf;
// The maximum angle for which we snap the given angle to a multiple of
// `snapToMultipleOf`.
// Use a smaller snap angle if already rotated (to avoid pinch zoom gestures from
// starting rotation).
const maxSnapAngle = this.isRotating
? this.afterRotationStartSnapAngle
: this.initialRotationSnapAngle;
// Snap the rotation
if (Math.abs(fullRotation - roundedFullRotation) < maxSnapAngle) {
fullRotation = roundedFullRotation;
// Work around a rotation/matrix multiply bug.
// (See commit after 4abe27ff8e7913155828f98dee77b09c57c51d30).
// TODO: Fix the underlying issue and remove this.
if (fullRotation !== 0) {
fullRotation += 0.0001;
}
}
return fullRotation - this.editor.viewport.getRotationAngle();
}
/**
* Given a scale update, `scaleFactor`, returns a new scale factor snapped
* to a power of two (if within some tolerance of that scale).
*/
toSnappedScaleFactor(touchDist) {
// scaleFactor is applied to the current transformation of the viewport.
const newScale = (this.initialViewportScale * touchDist) / this.startTouchDist;
const currentScale = this.editor.viewport.getScaleFactor();
const logNewScale = Math.log(newScale) / Math.log(10);
const roundedLogNewScale = Math.round(logNewScale);
const logTolerance = 0.04;
if (Math.abs(roundedLogNewScale - logNewScale) < logTolerance) {
return Math.pow(10, roundedLogNewScale) / currentScale;
}
return touchDist / this.lastTouchDist;
}
handleTwoFingerMove(allPointers) {
const { screenCenter, canvasCenter, angle, dist } = this.computePinchData(allPointers[0], allPointers[1]);
const delta = this.getCenterDelta(screenCenter);
let deltaRotation;
if (this.isRotationLocked()) {
deltaRotation = 0;
}
else {
deltaRotation = this.toSnappedRotationDelta(angle);
}
// If any rotation, make a note of this (affects rotation snap
// angles).
if (Math.abs(deltaRotation) > 1e-8) {
this.isRotating = true;
}
this.updateVelocity(screenCenter);
if (!this.isScaling) {
const initialScaleFactor = dist / this.startTouchDist;
// Only start scaling if scaling done so far exceeds some threshold.
const upperBound = this.pinchZoomStartThreshold;
const lowerBound = 1 / this.pinchZoomStartThreshold;
if (initialScaleFactor > upperBound || initialScaleFactor < lowerBound) {
this.isScaling = true;
}
}
let scaleFactor = 1;
if (this.isScaling) {
scaleFactor = this.toSnappedScaleFactor(dist);
// Don't set lastDist until we start scaling --
this.lastTouchDist = dist;
}
const transformUpdate = Mat33.translation(delta)
.rightMul(Mat33.scaling2D(scaleFactor, canvasCenter))
.rightMul(Mat33.zRotation(deltaRotation, canvasCenter));
this.lastScreenCenter = screenCenter;
this.transform = Viewport.transformBy(this.transform.transform.rightMul(transformUpdate));
return transformUpdate;
}
handleOneFingerMove(pointer) {
const delta = this.getCenterDelta(pointer.screenPos);
const transformUpdate = Mat33.translation(delta);
this.transform = Viewport.transformBy(this.transform.transform.rightMul(transformUpdate));
this.updateVelocity(pointer.screenPos);
this.lastScreenCenter = pointer.screenPos;
return transformUpdate;
}
onPointerMove({ allPointers }) {
this.transform ??= Viewport.transformBy(Mat33.identity);
let transformUpdate = Mat33.identity;
if (allPointers.length === 2) {
transformUpdate = this.handleTwoFingerMove(allPointers);
}
else if (allPointers.length === 1) {
transformUpdate = this.handleOneFingerMove(allPointers[0]);
}
Viewport.transformBy(transformUpdate).apply(this.editor);
this.lastTimestamp = performance.now();
}
onPointerUp(event) {
const onComplete = () => {
if (this.transform) {
this.transform.unapply(this.editor);
this.editor.dispatch(this.transform, false);
}
this.editor.display.setDraftMode(false);
this.transform = null;
this.velocity = Vec2.zero;
};
const minInertialScrollDt = 30;
const shouldInertialScroll = event.current.device === PointerDevice.Touch &&
event.allPointers.length === 1 &&
this.velocity !== null &&
event.current.timeStamp - this.lastPointerDownTimestamp > minInertialScrollDt;
if (shouldInertialScroll && this.velocity !== null) {
const oldVelocity = this.velocity;
// If the user drags the screen, then stops, then lifts the pointer,
// we want the final velocity to reflect the stop at the end (so the velocity
// should be near zero). Handle this:
this.updateVelocity(event.current.screenPos);
// Work around an input issue. Some devices that disable the touchscreen when a stylus
// comes near the screen fire a touch-end event at the position of the stylus when a
// touch gesture is canceled. Because the stylus is often far away from the last touch,
// this causes a great displacement between the second-to-last (from the touchscreen) and
// last (from the pen that is now near the screen) events. Only allow velocity to decrease
// to work around this:
if (oldVelocity.magnitude() < this.velocity.magnitude()) {
this.velocity = oldVelocity;
}
// Cancel any ongoing inertial scrolling.
this.inertialScroller?.stop();
this.inertialScroller = new InertialScroller(this.velocity, (scrollDelta) => {
if (!this.transform) {
return;
}
const canvasDelta = this.editor.viewport.screenToCanvasTransform.transformVec3(scrollDelta);
// Scroll by scrollDelta
this.transform.unapply(this.editor);
this.transform = Viewport.transformBy(this.transform.transform.rightMul(Mat33.translation(canvasDelta)));
this.transform.apply(this.editor);
}, onComplete);
}
else {
onComplete();
}
}
onGestureCancel() {
this.inertialScroller?.stop();
this.velocity = Vec2.zero;
this.transform?.unapply(this.editor);
this.editor.display.setDraftMode(false);
this.transform = null;
}
// Applies [transformUpdate] to the editor. This stacks on top of the
// current transformation, if it exists.
updateTransform(transformUpdate, announce = false) {
let newTransform = transformUpdate;
if (this.transform) {
newTransform = this.transform.transform.rightMul(transformUpdate);
}
this.transform?.unapply(this.editor);
this.transform = Viewport.transformBy(newTransform);
this.transform.apply(this.editor);
if (announce) {
this.editor.announceForAccessibility(this.transform.description(this.editor, this.editor.localization));
}
}
/**
* Updates the current transform and clears it. Use this method for events that are not part of
* a larger gesture (i.e. have no start and end event). For example, this would be used for `onwheel`
* events, but not for `onpointer` events.
*/
applyAndFinalizeTransform(transformUpdate) {
this.updateTransform(transformUpdate, true);
this.transform = null;
}
onWheel({ delta, screenPos }) {
this.inertialScroller?.stop();
// Reset the transformation -- wheel events are individual events, so we don't
// need to unapply/reapply.
this.transform = Viewport.transformBy(Mat33.identity);
const canvasPos = this.editor.viewport.screenToCanvas(screenPos);
const toCanvas = this.editor.viewport.screenToCanvasTransform;
// Transform without including translation
const translation = toCanvas.transformVec3(Vec3.of(-delta.x, -delta.y, 0));
let pinchAmount = delta.z;
// Clamp the magnitude of pinchAmount
pinchAmount = Math.atan(pinchAmount / 2) * 2;
const pinchZoomScaleFactor = 1.04;
const transformUpdate = Mat33.scaling2D(Math.max(0.4, Math.min(Math.pow(pinchZoomScaleFactor, -pinchAmount), 4)), canvasPos).rightMul(Mat33.translation(translation));
this.applyAndFinalizeTransform(transformUpdate);
return true;
}
onKeyPress(event) {
this.inertialScroller?.stop();
if (!(this.mode & PanZoomMode.Keyboard)) {
return false;
}
// No need to keep the same the transform for keyboard events.
this.transform = Viewport.transformBy(Mat33.identity);
let translation = Vec2.zero;
let scale = 1;
let rotation = 0;
// Keyboard shortcut handling
const shortcucts = this.editor.shortcuts;
if (shortcucts.matchesShortcut(moveLeftKeyboardShortcutId, event)) {
translation = Vec2.of(-1, 0);
}
else if (shortcucts.matchesShortcut(moveRightKeyboardShortcutId, event)) {
translation = Vec2.of(1, 0);
}
else if (shortcucts.matchesShortcut(moveUpKeyboardShortcutId, event)) {
translation = Vec2.of(0, -1);
}
else if (shortcucts.matchesShortcut(moveDownKeyboardShortcutId, event)) {
translation = Vec2.of(0, 1);
}
else if (shortcucts.matchesShortcut(zoomInKeyboardShortcutId, event)) {
scale = 1 / 2;
}
else if (shortcucts.matchesShortcut(zoomOutKeyboardShortcutId, event)) {
scale = 2;
}
else if (shortcucts.matchesShortcut(rotateClockwiseKeyboardShortcutId, event)) {
rotation = 1;
}
else if (shortcucts.matchesShortcut(rotateCounterClockwiseKeyboardShortcutId, event)) {
rotation = -1;
}
else {
return false;
}
// For each keypress,
translation = translation.times(30); // Move at most 30 units
rotation *= Math.PI / 8; // Rotate at least a sixteenth of a rotation
// Transform the canvas, not the viewport:
translation = translation.times(-1);
rotation = rotation * -1;
scale = 1 / scale;
// Work around an issue that seems to be related to rotation matrices losing precision on inversion.
// TODO: Figure out why and implement a better solution.
if (rotation !== 0) {
rotation += 0.0001;
}
if (this.isRotationLocked()) {
rotation = 0;
}
const toCanvas = this.editor.viewport.screenToCanvasTransform;
// Transform without translating (treat toCanvas as a linear instead of
// an affine transformation).
translation = toCanvas.transformVec3(translation);
// Rotate/scale about the center of the canvas
const transformCenter = this.editor.viewport.visibleRect.center;
const transformUpdate = Mat33.scaling2D(scale, transformCenter)
.rightMul(Mat33.zRotation(rotation, transformCenter))
.rightMul(Mat33.translation(translation));
this.applyAndFinalizeTransform(transformUpdate);
return true;
}
isRotationLocked() {
return !!(this.mode & PanZoomMode.RotationLocked);
}
/**
* Changes the types of gestures used by this pan/zoom tool.
*
* @see {@link PanZoomMode} {@link setMode}
*
* @example
* ```ts,runnable
* import { Editor, PanZoomTool, PanZoomMode } from 'js-draw';
*
* const editor = new Editor(document.body);
*
* // By default, there are multiple PanZoom tools that handle different events.
* // This gets all PanZoomTools.
* const panZoomToolList = editor.toolController.getMatchingTools(PanZoomTool);
*
* // The first PanZoomTool is the highest priority -- by default,
* // this tool is responsible for handling multi-finger touch gestures.
* //
* // Lower-priority PanZoomTools handle one-finger touch gestures and
* // key-presses.
* const panZoomTool = panZoomToolList[0];
*
* // Lock rotation for multi-finger touch gestures.
* panZoomTool.setModeEnabled(PanZoomMode.RotationLocked, true);
* ```
*/
setModeEnabled(mode, enabled) {
let newMode = this.mode;
if (enabled) {
newMode |= mode;
}
else {
newMode &= ~mode;
}
this.setMode(newMode);
}
/**
* Sets all modes for this tool using a bitmask.
*
* @see {@link setModeEnabled}
*
* @example
* ```ts
* tool.setMode(PanZoomMode.RotationLocked|PanZoomMode.TwoFingerTouchGestures);
* ```
*/
setMode(mode) {
if (mode !== this.mode) {
this.mode = mode;
this.editor.notifier.dispatch(EditorEventType.ToolUpdated, {
kind: EditorEventType.ToolUpdated,
tool: this,
});
}
}
/**
* Returns a bitmask indicating the currently-enabled modes.
* @see {@link setModeEnabled}
*/
getMode() {
return this.mode;
}
}