Files
upstream-excalidraw/packages/excalidraw/tests/wheel.test.tsx
2026-09-16 05:31:04 +05:00

355 lines
11 KiB
TypeScript

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);
});
});