185 lines
6.5 KiB
TypeScript
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();
|
|
}
|
|
};
|
|
}
|