snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Has been cancelled
Some checks failed
Tests / test (push) Has been cancelled
This commit is contained in:
184
packages/excalidraw/components/App.wheel.ts
Normal file
184
packages/excalidraw/components/App.wheel.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user