snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
354
packages/excalidraw/tests/wheel.test.tsx
Normal file
354
packages/excalidraw/tests/wheel.test.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import React from "react";
|
||||
import { fireEvent, waitFor } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import {
|
||||
CLASSES,
|
||||
MIN_ZOOM,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { resolveInputDevice } from "../appState";
|
||||
import { Excalidraw } from "../index";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { GlobalTestState, mockBoundingClientRect, render } from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
// `withBatchedUpdatesThrottled` — which the drag-pan applies pointer movement
|
||||
// through, once per animation frame — is synchronous under the test setup;
|
||||
// this lets a test hold the throttled calls back until it releases the frame
|
||||
const throttle = vi.hoisted(() => {
|
||||
const pending = new Set<() => void>();
|
||||
return {
|
||||
holding: false,
|
||||
pending,
|
||||
releaseFrame: () => {
|
||||
const frames = [...pending];
|
||||
pending.clear();
|
||||
frames.forEach((frame) => frame());
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../reactUtils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../reactUtils")>();
|
||||
const withBatchedUpdatesThrottled = (fn: (event?: unknown) => void) => {
|
||||
const batched = actual.withBatchedUpdates(fn);
|
||||
let run: (() => void) | null = null;
|
||||
const frame = () => {
|
||||
const pendingRun = run;
|
||||
run = null;
|
||||
pendingRun?.();
|
||||
};
|
||||
const ret = (event?: unknown) => {
|
||||
if (!throttle.holding) {
|
||||
batched(event);
|
||||
return;
|
||||
}
|
||||
run = () => batched(event);
|
||||
throttle.pending.add(frame);
|
||||
};
|
||||
ret.flush = () => {
|
||||
throttle.pending.delete(frame);
|
||||
frame();
|
||||
};
|
||||
ret.cancel = () => {
|
||||
throttle.pending.delete(frame);
|
||||
run = null;
|
||||
};
|
||||
return ret;
|
||||
};
|
||||
return { ...actual, withBatchedUpdatesThrottled };
|
||||
});
|
||||
|
||||
// the wheel listener is attached natively on the excalidraw container (not
|
||||
// via React), so we dispatch a bubbling wheel event from the canvas
|
||||
const wheel = (init: WheelEventInit) =>
|
||||
fireEvent.wheel(GlobalTestState.interactiveCanvas, init);
|
||||
|
||||
const getViewport = () => ({
|
||||
scrollX: h.state.scrollX,
|
||||
scrollY: h.state.scrollY,
|
||||
zoom: h.state.zoom.value,
|
||||
});
|
||||
|
||||
/** `MouseEvent.buttons` with only the wheel (middle) button pressed */
|
||||
const WHEEL_BUTTON = 4;
|
||||
|
||||
const wheelButtonPointer = {
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
// `button` identifies the pressed button, `buttons` the held-down set
|
||||
button: 1,
|
||||
buttons: WHEEL_BUTTON,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
};
|
||||
|
||||
describe("wheel navigation", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("pans on plain wheel, horizontally with shift, and zooms on ctrl/cmd+wheel", () => {
|
||||
const start = getViewport();
|
||||
|
||||
wheel({ deltaX: 30, deltaY: 40 });
|
||||
expect(getViewport()).toEqual({
|
||||
...start,
|
||||
scrollX: start.scrollX - 30,
|
||||
scrollY: start.scrollY - 40,
|
||||
});
|
||||
|
||||
const beforeShift = getViewport();
|
||||
wheel({ deltaY: 40, shiftKey: true });
|
||||
expect(getViewport()).toEqual({
|
||||
...beforeShift,
|
||||
scrollX: beforeShift.scrollX - 40,
|
||||
});
|
||||
|
||||
const beforeZoom = getViewport();
|
||||
wheel({ deltaY: -100, ctrlKey: true });
|
||||
const zoomedIn = h.state.zoom.value;
|
||||
expect(zoomedIn).toBeGreaterThan(beforeZoom.zoom);
|
||||
wheel({ deltaY: 100, metaKey: true });
|
||||
expect(h.state.zoom.value).toBeLessThan(zoomedIn);
|
||||
});
|
||||
|
||||
it("does nothing on a zoom tick at the zoom limit", () => {
|
||||
API.setAppState({ zoom: { value: getNormalizedZoom(MIN_ZOOM) } });
|
||||
const start = getViewport();
|
||||
|
||||
wheel({ deltaY: 100, ctrlKey: true });
|
||||
|
||||
expect(getViewport()).toEqual(start);
|
||||
// in particular no bitmap-cache flip, which would redraw the scene
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(false);
|
||||
});
|
||||
|
||||
describe("wheel button held down", () => {
|
||||
it("zooms instead of panning, whatever the modifiers", () => {
|
||||
const start = getViewport();
|
||||
|
||||
wheel({ deltaY: -100, buttons: WHEEL_BUTTON });
|
||||
const zoomedIn = h.state.zoom.value;
|
||||
expect(zoomedIn).toBeGreaterThan(start.zoom);
|
||||
|
||||
wheel({ deltaY: 100, buttons: WHEEL_BUTTON });
|
||||
const zoomedOut = h.state.zoom.value;
|
||||
expect(zoomedOut).toBeLessThan(zoomedIn);
|
||||
|
||||
// shift would otherwise pan horizontally
|
||||
wheel({ deltaY: -100, buttons: WHEEL_BUTTON, shiftKey: true });
|
||||
expect(h.state.zoom.value).toBeGreaterThan(zoomedOut);
|
||||
});
|
||||
|
||||
it("zooms during the wheel-button drag-pan the button started", () => {
|
||||
fireEvent.pointerDown(
|
||||
GlobalTestState.interactiveCanvas,
|
||||
wheelButtonPointer,
|
||||
);
|
||||
|
||||
// the drag-pan owns the viewport: wheel input without the button
|
||||
// held is swallowed...
|
||||
const start = getViewport();
|
||||
wheel({ deltaY: 40 });
|
||||
expect(getViewport()).toEqual(start);
|
||||
|
||||
// ...but with the button held, it zooms
|
||||
wheel({ deltaY: -100, buttons: WHEEL_BUTTON });
|
||||
expect(h.state.zoom.value).toBeGreaterThan(start.zoom);
|
||||
|
||||
fireEvent.pointerUp(window, { ...wheelButtonPointer, buttons: 0 });
|
||||
|
||||
// pan released — plain wheel pans again
|
||||
const released = getViewport();
|
||||
wheel({ deltaY: 40 });
|
||||
expect(h.state.scrollY).toBeCloseTo(
|
||||
released.scrollY - 40 / released.zoom,
|
||||
);
|
||||
});
|
||||
|
||||
describe("while moving the pointer between zooms", () => {
|
||||
const moved = { ...wheelButtonPointer, clientX: 130, clientY: 120 };
|
||||
|
||||
/** starts a wheel-button drag-pan; returns the scene point grabbed */
|
||||
const grab = () => {
|
||||
fireEvent.pointerDown(
|
||||
GlobalTestState.interactiveCanvas,
|
||||
wheelButtonPointer,
|
||||
);
|
||||
// wheel zoom anchors on the last pointer position
|
||||
fireEvent.pointerMove(
|
||||
GlobalTestState.interactiveCanvas,
|
||||
wheelButtonPointer,
|
||||
);
|
||||
return viewportCoordsToSceneCoords(wheelButtonPointer, h.state);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
// release the pan even when an assertion failed mid-way, so the
|
||||
// module-level pan state doesn't leak into the next test
|
||||
fireEvent.pointerUp(window, { ...moved, buttons: 0 });
|
||||
});
|
||||
|
||||
const expectGrabbedUnderCursor = (grabbed: { x: number; y: number }) => {
|
||||
const underCursor = viewportCoordsToSceneCoords(moved, h.state);
|
||||
expect(underCursor.x).toBeCloseTo(grabbed.x);
|
||||
expect(underCursor.y).toBeCloseTo(grabbed.y);
|
||||
};
|
||||
|
||||
it("keeps the zoom when the pan lands in the same React flush", () => {
|
||||
const grabbed = grab();
|
||||
const startZoom = h.state.zoom.value;
|
||||
|
||||
// the drag-pan's frame runs before React has flushed the zoom the
|
||||
// wheel queued, so both land in one flush — a pan written from the
|
||||
// pre-zoom state would overwrite the zoom's scroll
|
||||
React.act(() => {
|
||||
wheel({ deltaY: -100, ...wheelButtonPointer });
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, moved);
|
||||
});
|
||||
|
||||
expect(h.state.zoom.value).toBeGreaterThan(startZoom);
|
||||
expectGrabbedUnderCursor(grabbed);
|
||||
});
|
||||
|
||||
it("applies a move still waiting for its frame before zooming", () => {
|
||||
const grabbed = grab();
|
||||
const start = getViewport();
|
||||
|
||||
throttle.holding = true;
|
||||
try {
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, moved);
|
||||
expect(getViewport()).toEqual(start);
|
||||
|
||||
wheel({ deltaY: -100, ...moved });
|
||||
React.act(() => {
|
||||
throttle.releaseFrame();
|
||||
});
|
||||
} finally {
|
||||
throttle.holding = false;
|
||||
}
|
||||
|
||||
expect(h.state.zoom.value).toBeGreaterThan(start.zoom);
|
||||
expectGrabbedUnderCursor(grabbed);
|
||||
});
|
||||
|
||||
it("keeps drawing from zoom-scaled bitmaps until the gesture is over", async () => {
|
||||
grab();
|
||||
|
||||
wheel({ deltaY: -100, ...wheelButtonPointer });
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(true);
|
||||
|
||||
// a pan frame between two ticks must not switch back to crisp
|
||||
// rasterization — that re-rasterizes every element at a zoom the
|
||||
// next tick is about to change again
|
||||
fireEvent.pointerMove(GlobalTestState.interactiveCanvas, moved);
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(true);
|
||||
|
||||
wheel({ deltaY: -100, ...moved });
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(true);
|
||||
|
||||
// ...only once the zoom has been idle for a moment
|
||||
await waitFor(() => {
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("inputDevice preference", () => {
|
||||
it("defaults to auto, which resolves to trackpad until detection exists", () => {
|
||||
expect(h.state.inputDevice).toBe("auto");
|
||||
expect(resolveInputDevice(h.state.inputDevice)).toBe("trackpad");
|
||||
});
|
||||
|
||||
describe("mouse", () => {
|
||||
beforeEach(() => {
|
||||
API.setAppState({ inputDevice: "mouse" });
|
||||
});
|
||||
|
||||
it("zooms on plain wheel and pans vertically on ctrl/cmd+wheel", () => {
|
||||
const start = getViewport();
|
||||
wheel({ deltaY: -100 });
|
||||
expect(h.state.zoom.value).toBeGreaterThan(start.zoom);
|
||||
|
||||
const zoomed = getViewport();
|
||||
wheel({ deltaY: 40, ctrlKey: true });
|
||||
expect(getViewport()).toEqual({
|
||||
...zoomed,
|
||||
scrollY: zoomed.scrollY - 40 / zoomed.zoom,
|
||||
});
|
||||
|
||||
const panned = getViewport();
|
||||
wheel({ deltaY: 40, metaKey: true });
|
||||
expect(getViewport()).toEqual({
|
||||
...panned,
|
||||
scrollY: panned.scrollY - 40 / panned.zoom,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps shift+wheel panning horizontally", () => {
|
||||
const start = getViewport();
|
||||
wheel({ deltaY: 40, shiftKey: true });
|
||||
expect(getViewport()).toEqual({
|
||||
...start,
|
||||
scrollX: start.scrollX - 40 / start.zoom,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps zooming with the wheel button held", () => {
|
||||
const start = getViewport();
|
||||
wheel({ deltaY: -100, buttons: WHEEL_BUTTON, ctrlKey: true });
|
||||
expect(h.state.zoom.value).toBeGreaterThan(start.zoom);
|
||||
});
|
||||
|
||||
it("pans sideways on a horizontal-only wheel instead of zooming", () => {
|
||||
// a tilt wheel or a sideways two-finger scroll
|
||||
const start = getViewport();
|
||||
wheel({ deltaX: 30 });
|
||||
expect(getViewport()).toEqual({
|
||||
...start,
|
||||
scrollX: start.scrollX - 30 / start.zoom,
|
||||
});
|
||||
expect(h.state.shouldCacheIgnoreZoom).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("wheel over a frame label", () => {
|
||||
beforeEach(async () => {
|
||||
// frame labels render only for frames inside the measured viewport
|
||||
mockBoundingClientRect();
|
||||
await render(<Excalidraw />);
|
||||
await waitFor(() => expect(h.state.width).toBe(200));
|
||||
});
|
||||
|
||||
it("is handled once", async () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 80,
|
||||
height: 50,
|
||||
});
|
||||
API.setElements([frame]);
|
||||
const label = await waitFor(() => {
|
||||
const element = document.querySelector(`.${CLASSES.FRAME_NAME}`);
|
||||
expect(element).not.toBe(null);
|
||||
return element as HTMLElement;
|
||||
});
|
||||
|
||||
// the label is DOM inside the editor container, whose wheel listener
|
||||
// accepts it; a React `onWheel` on the label itself used to handle the
|
||||
// same event a second time, panning and zooming twice per tick
|
||||
const start = getViewport();
|
||||
fireEvent.wheel(label, { deltaY: 40 });
|
||||
expect(h.state.scrollY).toBeCloseTo(start.scrollY - 40 / start.zoom);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user