UNPKG

js-draw

Version:

Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.

523 lines (522 loc) 23.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PanZoomMode = void 0; const math_1 = require("@js-draw/math"); const Pointer_1 = require("../Pointer"); const types_1 = require("../types"); const untilNextAnimationFrame_1 = __importDefault(require("../util/untilNextAnimationFrame")); const Viewport_1 = require("../Viewport"); const BaseTool_1 = __importDefault(require("./BaseTool")); const keybindings_1 = require("./keybindings"); 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 || (exports.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 (0, untilNextAnimationFrame_1.default)(); 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} */ class PanZoom extends BaseTool_1.default { 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() ?? math_1.Vec2.zero; this.inertialScroller?.stop(); this.velocity = inertialScrollerVelocity; this.lastPointerDownTimestamp = currentPointer.timeStamp; const allAreTouch = this.allPointersAreOfType(pointers, Pointer_1.PointerDevice.Touch); const isRightClick = this.allPointersAreOfType(pointers, Pointer_1.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_1.Viewport.transformBy(math_1.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 = math_1.Mat33.translation(delta) .rightMul(math_1.Mat33.scaling2D(scaleFactor, canvasCenter)) .rightMul(math_1.Mat33.zRotation(deltaRotation, canvasCenter)); this.lastScreenCenter = screenCenter; this.transform = Viewport_1.Viewport.transformBy(this.transform.transform.rightMul(transformUpdate)); return transformUpdate; } handleOneFingerMove(pointer) { const delta = this.getCenterDelta(pointer.screenPos); const transformUpdate = math_1.Mat33.translation(delta); this.transform = Viewport_1.Viewport.transformBy(this.transform.transform.rightMul(transformUpdate)); this.updateVelocity(pointer.screenPos); this.lastScreenCenter = pointer.screenPos; return transformUpdate; } onPointerMove({ allPointers }) { this.transform ??= Viewport_1.Viewport.transformBy(math_1.Mat33.identity); let transformUpdate = math_1.Mat33.identity; if (allPointers.length === 2) { transformUpdate = this.handleTwoFingerMove(allPointers); } else if (allPointers.length === 1) { transformUpdate = this.handleOneFingerMove(allPointers[0]); } Viewport_1.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 = math_1.Vec2.zero; }; const minInertialScrollDt = 30; const shouldInertialScroll = event.current.device === Pointer_1.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_1.Viewport.transformBy(this.transform.transform.rightMul(math_1.Mat33.translation(canvasDelta))); this.transform.apply(this.editor); }, onComplete); } else { onComplete(); } } onGestureCancel() { this.inertialScroller?.stop(); this.velocity = math_1.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_1.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_1.Viewport.transformBy(math_1.Mat33.identity); const canvasPos = this.editor.viewport.screenToCanvas(screenPos); const toCanvas = this.editor.viewport.screenToCanvasTransform; // Transform without including translation const translation = toCanvas.transformVec3(math_1.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 = math_1.Mat33.scaling2D(Math.max(0.4, Math.min(Math.pow(pinchZoomScaleFactor, -pinchAmount), 4)), canvasPos).rightMul(math_1.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_1.Viewport.transformBy(math_1.Mat33.identity); let translation = math_1.Vec2.zero; let scale = 1; let rotation = 0; // Keyboard shortcut handling const shortcucts = this.editor.shortcuts; if (shortcucts.matchesShortcut(keybindings_1.moveLeftKeyboardShortcutId, event)) { translation = math_1.Vec2.of(-1, 0); } else if (shortcucts.matchesShortcut(keybindings_1.moveRightKeyboardShortcutId, event)) { translation = math_1.Vec2.of(1, 0); } else if (shortcucts.matchesShortcut(keybindings_1.moveUpKeyboardShortcutId, event)) { translation = math_1.Vec2.of(0, -1); } else if (shortcucts.matchesShortcut(keybindings_1.moveDownKeyboardShortcutId, event)) { translation = math_1.Vec2.of(0, 1); } else if (shortcucts.matchesShortcut(keybindings_1.zoomInKeyboardShortcutId, event)) { scale = 1 / 2; } else if (shortcucts.matchesShortcut(keybindings_1.zoomOutKeyboardShortcutId, event)) { scale = 2; } else if (shortcucts.matchesShortcut(keybindings_1.rotateClockwiseKeyboardShortcutId, event)) { rotation = 1; } else if (shortcucts.matchesShortcut(keybindings_1.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 = math_1.Mat33.scaling2D(scale, transformCenter) .rightMul(math_1.Mat33.zRotation(rotation, transformCenter)) .rightMul(math_1.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(types_1.EditorEventType.ToolUpdated, { kind: types_1.EditorEventType.ToolUpdated, tool: this, }); } } /** * Returns a bitmask indicating the currently-enabled modes. * @see {@link setModeEnabled} */ getMode() { return this.mode; } } exports.default = PanZoom;