Files
upstream-excalidraw/packages/excalidraw/components/App.wheel.ts
2026-09-16 05:18:53 +05:00

185 lines
6.5 KiB
TypeScript

import { CLASSES, KEYS, MIN_ZOOM, ZOOM_STEP } from "@excalidraw/common";
import { resolveInputDevice } from "../appState";
import { withBatchedUpdates } from "../reactUtils";
import { getNormalizedZoom } from "../scene";
import { getViewportForZoomWithScrollConstraints } from "../viewport";
import type App from "./App";
/** `MouseEvent.buttons` bit of the wheel (middle) button */
const WHEEL_BUTTON_MASK = 4;
/**
* Wheel input over the editor: pans the canvas, or zooms it around the
* pointer (`viewport.lastPosition`) — on ctrl/cmd+wheel (which is also how
* a trackpad pinch is delivered), while the wheel button itself is held
* down, or on a plain wheel when the input device is a mouse
* (`appState.inputDevice`; ctrl/cmd+wheel pans instead then).
*/
export class AppWheel {
constructor(
private app: App,
private dependencies: {
/** whether a drag-pan (wheel button, space+drag, hand tool) is in
* progress — wheel input is ignored meanwhile */
isPanning: () => boolean;
/** applies the pointer movement the drag-pan is holding back for its
* next frame, if any */
flushPanMove: () => void;
},
) {}
/** the editor surfaces whose wheel input the editor consumes; everywhere
* else (menus, sidebars, …) the DOM keeps scrolling. The frame-name labels
* are DOM, but sit inside the container this listener is attached to */
private isOverEditorSurface = (event: WheelEvent) => {
const { ownerWindow } = this.app;
return (
event.target instanceof ownerWindow.HTMLCanvasElement ||
event.target instanceof ownerWindow.HTMLTextAreaElement ||
event.target instanceof ownerWindow.HTMLIFrameElement ||
(event.target instanceof ownerWindow.HTMLElement &&
event.target.classList.contains(CLASSES.FRAME_NAME))
);
};
handle = withBatchedUpdates((event: WheelEvent) => {
// NOTE no preventDefault so the page can scroll over the editor
if (!this.app.isNavigationEnabled()) {
return;
}
if (!this.isOverEditorSurface(event)) {
// prevent zooming the browser (but allow scrolling DOM)
if (event[KEYS.CTRL_OR_CMD]) {
event.preventDefault();
}
return;
}
event.preventDefault();
// scrolling with the wheel button held down zooms: the wheel sits under
// the same finger, so a wheel-button pan can be zoomed without reaching
// for a modifier — hence it wins over any modifier, and over the
// drag-pan the button itself started
const isWheelButtonHeld = !!(event.buttons & WHEEL_BUTTON_MASK);
if (this.dependencies.isPanning()) {
if (!isWheelButtonHeld) {
return;
}
// the drag-pan applies pointer movement once per frame; a move still
// waiting for its frame has to land before the zoom, or it lands after
// it — at the new zoom, on a viewport the zoom anchored without it —
// and the point grabbed by the pan drifts from under the cursor a
// little on every tick
this.dependencies.flushPanMove();
}
const { deltaX, deltaY } = event;
if (!deltaX && !deltaY) {
return;
}
// note that event.ctrlKey is necessary to handle pinch zooming
const hasZoomModifier = event.metaKey || event.ctrlKey;
const shouldZoom =
// a horizontal-only wheel (tilt wheel, sideways two-finger scroll)
// has nothing to zoom by; it pans sideways below instead
deltaY !== 0 &&
(isWheelButtonHeld ||
(resolveInputDevice(this.app.state.inputDevice) === "mouse"
? // a mouse has no pinch: a plain wheel zooms, and any modifier
// pans instead (ctrl/cmd vertically, shift horizontally)
!hasZoomModifier && !event.shiftKey
: hasZoomModifier));
if (shouldZoom) {
this.zoomBy(deltaY);
return;
}
// scroll horizontally when shift pressed
if (event.shiftKey) {
this.app.viewport.translate(({ zoom, scrollX }) => ({
// on Mac, shift+wheel tends to result in deltaX
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
}));
return;
}
this.app.viewport.translate(({ zoom, scrollX, scrollY }) => ({
scrollX: scrollX - deltaX / zoom.value,
scrollY: scrollY - deltaY / zoom.value,
}));
});
/**
* Prevents the browser's own zoom over the non-interactive editor while
* letting regular scroll through (trackpad pinch is delivered as
* ctrl+wheel).
*/
preventBrowserZoom = (event: WheelEvent) => {
if (event[KEYS.CTRL_OR_CMD]) {
event.preventDefault();
}
};
/** zooms around the pointer by a wheel delta (positive = zoom out) */
private zoomBy = (deltaY: number) => {
const { viewport } = this.app;
const sign = Math.sign(deltaY);
const MAX_STEP = ZOOM_STEP * 100;
const absDelta = Math.abs(deltaY);
let delta = deltaY;
if (absDelta > MAX_STEP) {
delta = MAX_STEP * sign;
}
// where the pointer is now — the updater below runs when React flushes,
// by which time the pointer may have moved on (`lastPosition` is live)
const { x: viewportX, y: viewportY } = viewport.lastPosition;
const didTranslate = viewport.translate(
// computed from the state the zoom applies to, not `app.state`: wheel
// ticks arriving before React has flushed the previous one would
// otherwise all start from the same zoom and collapse into one step
(state) => {
let newZoom = state.zoom.value - delta / 100;
// increase zoom steps the more zoomed-in we are (applies to >100% only)
newZoom +=
Math.log10(Math.max(1, state.zoom.value)) *
-sign *
// reduced amplification for small deltas (small movements on a trackpad)
Math.min(1, absDelta / 20);
const minZoom = state.scrollConstraints?.lockZoom
? state.scrollConstraints.zoom
: MIN_ZOOM;
const nextZoom = getNormalizedZoom(Math.max(newZoom, minZoom));
if (nextZoom === state.zoom.value) {
// at a zoom limit there is nothing to do; flipping the bitmap-cache
// flag would only redraw the scene on every tick
return null;
}
return {
...getViewportForZoomWithScrollConstraints(
{ viewportX, viewportY, nextZoom },
state,
),
shouldCacheIgnoreZoom: true,
};
},
{
zoomPreConstrained: true,
preserveScrollConstraintsSnapBack: true,
},
);
if (didTranslate) {
this.app.resetShouldCacheIgnoreZoomDebounced();
}
};
}