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

1327 lines
40 KiB
TypeScript

import React from "react";
import { vi } from "vitest";
import { KEYS, MAX_ZOOM } from "@excalidraw/common";
import { Excalidraw } from "../index";
import {
actionResetZoom,
actionZoomIn,
actionZoomOut,
actionZoomToFit,
actionZoomToFitSelection,
} from "../actions/actionCanvas";
import { getNormalizedZoom } from "../scene";
import {
snapBackToConstraints,
SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY,
SCROLL_TO_CONTENT_ANIMATION_KEY,
} from "../components/App.viewport";
import {
constrainScrollState,
DEFAULT_OVERSCROLL,
getViewportForZoomWithScrollConstraints,
} from "../viewport";
import { AnimationController } from "../renderer/animation";
import { API } from "./helpers/api";
import { Keyboard, Pointer } from "./helpers/ui";
import {
fireEvent,
GlobalTestState,
mockBoundingClientRect,
render,
restoreOriginalGetBoundingClientRect,
waitFor,
} from "./test-utils";
import type { AppState, ScrollConstraints } from "../types";
const { h } = window;
// `mockBoundingClientRect()` makes the viewport 200 x 100
const VIEWPORT = { width: 200, height: 100 };
const makeState = (
overrides: Partial<AppState> & {
scrollConstraints: ScrollConstraints | null;
},
): Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
> => ({
scrollX: 0,
scrollY: 0,
zoom: { value: getNormalizedZoom(1) },
width: VIEWPORT.width,
height: VIEWPORT.height,
...overrides,
});
/** A lock box with sensible defaults; pass `lockScroll`/`lockZoom`/`zoom`/
* `overscroll`/`offsets` to override. */
const makeLock = (
overrides: Partial<ScrollConstraints> &
Pick<ScrollConstraints, "x" | "y" | "width" | "height">,
): ScrollConstraints => ({
lockScroll: false,
lockZoom: false,
zoom: 1,
overscroll: 0,
...overrides,
});
const waitForAnimationToStop = (key: string, maxFrames = 200) => {
return React.act(
() =>
new Promise<void>((resolve) => {
let remaining = maxFrames;
const check = () => {
if (!AnimationController.running(key) || --remaining <= 0) {
resolve();
} else {
requestAnimationFrame(check);
}
};
requestAnimationFrame(check);
}),
);
};
describe("constrainScrollState (pure)", () => {
it("returns scroll/zoom unchanged when there is no lock", () => {
const state = makeState({
scrollX: 123,
scrollY: -45,
zoom: { value: getNormalizedZoom(0.5) },
scrollConstraints: null,
});
expect(constrainScrollState(state)).toEqual({
scrollX: 123,
scrollY: -45,
zoom: { value: getNormalizedZoom(0.5) },
});
});
it("leaves scroll free when only zoom is locked", () => {
const lock = makeLock({
x: 0,
y: 0,
width: 1000,
height: 1000,
lockZoom: true,
zoom: 0.2,
});
const result = constrainScrollState(
makeState({ scrollX: 9999, scrollY: 9999, scrollConstraints: lock }),
);
expect(result.scrollX).toBe(9999);
expect(result.scrollY).toBe(9999);
});
it("leaves zoom free when only scroll is locked", () => {
const lock = makeLock({
x: 0,
y: 0,
width: 1000,
height: 1000,
lockScroll: true,
});
// a zoom that would have been forced up by the old box-cover fit zoom
// (0.2 for this box) stays as-is when only scroll is locked
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.15) },
scrollConstraints: lock,
}),
);
expect(zoom.value).toBeCloseTo(0.15);
});
describe("scroll lock", () => {
const lock = makeLock({
x: 0,
y: 0,
width: 1000,
height: 1000,
lockScroll: true,
});
it("clamps scroll so the viewport can't leave the box", () => {
// pan past the box's top-left corner → clamp to the corner (scroll 0)
const corner = constrainScrollState(
makeState({ scrollX: 100, scrollY: 100, scrollConstraints: lock }),
);
expect(corner.scrollX).toBeCloseTo(0);
expect(corner.scrollY).toBeCloseTo(0);
// pan past the box's far edges → clamp to width/height - boxSize
const farEdge = constrainScrollState(
makeState({ scrollX: -5000, scrollY: -5000, scrollConstraints: lock }),
);
expect(farEdge.scrollX).toBeCloseTo(VIEWPORT.width - lock.width); // -800
expect(farEdge.scrollY).toBeCloseTo(VIEWPORT.height - lock.height); // -900
});
it("leaves an in-bounds viewport untouched", () => {
const inBounds = makeState({
scrollX: -100,
scrollY: -100,
zoom: { value: getNormalizedZoom(1) },
scrollConstraints: lock,
});
expect(constrainScrollState(inBounds)).toMatchObject({
scrollX: -100,
scrollY: -100,
zoom: { value: getNormalizedZoom(1) },
});
});
it("centers the box when the viewport is larger than it", () => {
// at zoom 0.1 the visible width (200/0.1 = 2000) exceeds the box (1000),
// so there's no pan room → the box is centered on the axis
const result = constrainScrollState(
makeState({
scrollX: 999,
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: lock,
}),
);
// centered: (min + max) / 2, min = w/zoom - (x+w), max = -x
const centeredX = (VIEWPORT.width / 0.1 - lock.width + 0) / 2;
expect(result.scrollX).toBeCloseTo(centeredX);
});
});
});
describe("zoom lock (pure)", () => {
const base = { x: 0, y: 0, width: 1000, height: 1000 } as const;
it("floors zoom-out at the locked zoom", () => {
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: makeLock({ ...base, lockZoom: true, zoom: 0.5 }),
}),
);
expect(zoom.value).toBeCloseTo(0.5);
});
it("always allows zooming in up to MAX_ZOOM", () => {
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(MAX_ZOOM) },
scrollConstraints: makeLock({ ...base, lockZoom: true, zoom: 0.5 }),
}),
);
expect(zoom.value).toBeCloseTo(MAX_ZOOM);
});
it("leaves an in-range zoom untouched", () => {
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(1.5) },
scrollConstraints: makeLock({ ...base, lockZoom: true, zoom: 0.5 }),
}),
);
expect(zoom.value).toBeCloseTo(1.5);
});
});
describe("offsets (pure)", () => {
const base = { x: 0, y: 0, width: 1000, height: 1000 } as const;
it("extends the scrollable area past each edge", () => {
const offsets = { top: 10, right: 20, bottom: 30, left: 40 };
const lock = makeLock({ ...base, lockScroll: true, offsets });
// pan past the top-left corner → clamp to the offsets corner
const topLeft = constrainScrollState(
makeState({ scrollX: 999, scrollY: 999, scrollConstraints: lock }),
);
expect(topLeft.scrollX).toBeCloseTo(40); // left offset
expect(topLeft.scrollY).toBeCloseTo(10); // top offset
// pan past the far edges → clamp to width/height - boxSize - far offset
const farEdge = constrainScrollState(
makeState({ scrollX: -5000, scrollY: -5000, scrollConstraints: lock }),
);
expect(farEdge.scrollX).toBeCloseTo(VIEWPORT.width - base.width - 20); // -820
expect(farEdge.scrollY).toBeCloseTo(VIEWPORT.height - base.height - 30); // -930
});
it("keeps the offsets a fixed screen distance regardless of zoom", () => {
// 40 screen px of top offset at zoom 2 → 20 scene px
const result = constrainScrollState(
makeState({
scrollY: 999,
zoom: { value: getNormalizedZoom(2) },
scrollConstraints: makeLock({
...base,
lockScroll: true,
offsets: { top: 40 },
}),
}),
);
expect(result.scrollY).toBeCloseTo(20);
});
it("defaults to no extra room", () => {
const result = constrainScrollState(
makeState({
scrollX: 999,
scrollConstraints: makeLock({ ...base, lockScroll: true }),
}),
);
expect(result.scrollX).toBeCloseTo(0);
});
it("stacks with the rubberband overscroll", () => {
const overscroll = 30; // screen px
const result = constrainScrollState(
makeState({
scrollX: 999,
scrollConstraints: makeLock({
...base,
lockScroll: true,
overscroll,
offsets: { left: 40 },
}),
}),
overscroll,
);
// left offset (40) + overscroll give (30) at zoom 1
expect(result.scrollX).toBeCloseTo(70);
});
});
describe("rubberband overscroll (pure)", () => {
const base = { x: 0, y: 0, width: 1000, height: 1000 } as const;
it("allows scroll overscroll up to `overscroll` screen pixels", () => {
const overscroll = 30; // screen px
const result = constrainScrollState(
makeState({
scrollX: 999,
scrollConstraints: makeLock({ ...base, lockScroll: true, overscroll }),
}),
overscroll,
);
expect(result.scrollX).toBeCloseTo(overscroll / 1); // 30 (zoom 1)
});
it("keeps the overscroll a fixed screen distance regardless of zoom", () => {
const overscroll = 30; // screen px
const result = constrainScrollState(
makeState({
scrollX: 999,
zoom: { value: getNormalizedZoom(2) },
scrollConstraints: makeLock({ ...base, lockScroll: true, overscroll }),
}),
overscroll,
);
// 30 screen px at zoom 2 -> 15 scene px of overscroll
expect(result.scrollX).toBeCloseTo(overscroll / 2); // 15
});
it("hard-clamps when overscroll is 0 (default)", () => {
const result = constrainScrollState(
makeState({
scrollX: 999,
scrollConstraints: makeLock({ ...base, lockScroll: true }),
}),
);
expect(result.scrollX).toBeCloseTo(0);
});
it("gives ±overscroll around center when the box can't cover the viewport", () => {
const overscroll = 30; // screen px
// at zoom 0.1 the visible width (200/0.1 = 2000) exceeds the box (1000),
// so the box rests centered (scrollX 500) — but overscroll must still
// allow give around that resting position, regardless of the free space
const state = makeState({
scrollX: 9999,
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: makeLock({ ...base, lockScroll: true, overscroll }),
});
// 30 screen px at zoom 0.1 -> 300 scene px of give past center
expect(constrainScrollState(state, overscroll).scrollX).toBeCloseTo(
500 + 300,
);
// the hard clamp still rests at dead center
expect(constrainScrollState(state).scrollX).toBeCloseTo(500);
});
});
describe("animateToConstraints (rubberband snap-back)", () => {
afterEach(() => {
AnimationController.reset();
});
const lock = makeLock({
x: 0,
y: 0,
width: 1000,
height: 1000,
lockScroll: true,
});
it("starts an animation toward the box when overscrolled", () => {
const onFrame = vi.fn();
// scrollX 200 is outside the hard range [-800, 0]
snapBackToConstraints(
makeState({ scrollX: 200, scrollConstraints: lock }),
onFrame,
);
expect(
AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY),
).toBe(true);
expect(onFrame).toHaveBeenCalled();
});
it("is a no-op when already within the box", () => {
const onFrame = vi.fn();
snapBackToConstraints(
makeState({ scrollX: -100, scrollConstraints: lock }),
onFrame,
);
expect(
AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY),
).toBe(false);
expect(onFrame).not.toHaveBeenCalled();
});
it("does not supersede an active viewport navigation", () => {
AnimationController.start(SCROLL_TO_CONTENT_ANIMATION_KEY, () => ({}));
snapBackToConstraints(
makeState({ scrollX: 200, scrollConstraints: lock }),
vi.fn(),
);
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
true,
);
expect(
AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY),
).toBe(false);
});
});
describe("getViewportForZoomWithScrollConstraints", () => {
it("preserves the screen-space overscroll distance while zooming", () => {
const state = {
...makeState({
scrollY: 50,
scrollConstraints: makeLock({
x: 0,
y: 0,
width: 1000,
height: 1000,
lockScroll: true,
overscroll: 50,
}),
}),
offsetLeft: 0,
offsetTop: 0,
} as AppState;
const viewport = getViewportForZoomWithScrollConstraints(
{
viewportX: 0,
viewportY: 0,
nextZoom: getNormalizedZoom(2),
},
state,
);
const restingViewport = constrainScrollState({ ...state, ...viewport });
expect(viewport.zoom.value).toBe(2);
expect(
(viewport.scrollY - restingViewport.scrollY) * viewport.zoom.value,
).toBeCloseTo(50);
});
});
describe("setViewport lock (integration)", () => {
beforeEach(() => {
mockBoundingClientRect();
});
afterEach(() => {
restoreOriginalGetBoundingClientRect();
AnimationController.reset();
});
it("installs a scroll lock and prevents keyboard panning out of the box", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 1000, 1000],
fit: "scale-down",
animation: false,
lock: { scroll: true, overscroll: false },
});
});
expect(h.state.scrollConstraints).not.toBe(null);
// hammer page-down (pans down) far past the box; scroll must stay clamped
for (let i = 0; i < 20; i++) {
Keyboard.keyPress(KEYS.PAGE_DOWN);
}
expect(h.state.scrollY).toBeGreaterThanOrEqual(h.state.height - 1000);
// clearing the lock lets it scroll freely again
React.act(() => {
h.app.viewport.setViewport(null);
});
expect(h.state.scrollConstraints).toBe(null);
const before = h.state.scrollY;
Keyboard.keyPress(KEYS.PAGE_DOWN);
expect(h.state.scrollY).toBeLessThan(before);
});
it("withholds user viewport input until an animated lock settles", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
const rect = API.createElement({
type: "rectangle",
x: 2000,
y: 2000,
width: 100,
height: 100,
});
API.setElements([rect]);
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 1000, 1000],
fit: "scale-down",
animation: false,
lock: { scroll: true },
});
});
expect(h.state.scrollConstraints).not.toBe(null);
window.EXCALIDRAW_THROTTLE_RENDER = true;
try {
React.act(() => {
h.app.viewport.setViewport({
target: rect,
fit: "scale-down",
animation: { duration: 10 },
lock: { scroll: true },
});
});
// the animation is in flight and the lock has NOT been installed yet —
// it's chained to run once the scroll settles
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
true,
);
// The previous active lock is superseded immediately; the destination
// lock is held internally until the animation lands.
expect(h.state.scrollConstraints).toBe(null);
expect(h.app.viewport.isLockedTransitionPending).toBe(true);
const viewportDuringTransition = {
scrollX: h.state.scrollX,
scrollY: h.state.scrollY,
zoom: h.state.zoom.value,
};
// Pan, wheel/pinch zoom, zoom controls, and fit actions are all
// consumed while the pending lock owns the viewport. None may cancel
// the animation or perturb its current frame.
Keyboard.keyPress(KEYS.PAGE_DOWN);
fireEvent.wheel(GlobalTestState.interactiveCanvas, {
ctrlKey: true,
deltaY: -10,
});
React.act(() => {
h.app.actionManager.executeAction(actionZoomIn, "ui");
h.app.actionManager.executeAction(actionResetZoom, "ui");
h.app.actionManager.executeAction(actionZoomToFit, "ui");
h.app.actionManager.executeAction(actionZoomToFitSelection, "ui");
});
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
true,
);
expect({
scrollX: h.state.scrollX,
scrollY: h.state.scrollY,
zoom: h.state.zoom.value,
}).toEqual(viewportDuringTransition);
await waitForAnimationToStop(SCROLL_TO_CONTENT_ANIMATION_KEY);
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
false,
);
expect(h.app.viewport.isLockedTransitionPending).toBe(false);
expect(h.state.scrollConstraints).toMatchObject({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
lockScroll: true,
});
} finally {
window.EXCALIDRAW_THROTTLE_RENDER = undefined;
}
});
it("lets a newer animated lock supersede an older one", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
const firstTarget = API.createElement({
type: "rectangle",
x: 1000,
y: 1000,
width: 100,
height: 100,
});
const secondTarget = API.createElement({
type: "rectangle",
x: 3000,
y: 3000,
width: 200,
height: 200,
});
API.setElements([firstTarget, secondTarget]);
window.EXCALIDRAW_THROTTLE_RENDER = true;
try {
React.act(() => {
h.app.viewport.setViewport({
target: firstTarget,
animation: { duration: 1000 },
lock: { scroll: true },
});
});
expect(h.app.viewport.isLockedTransitionPending).toBe(true);
React.act(() => {
h.app.viewport.setViewport({
target: secondTarget,
animation: { duration: 10 },
lock: { scroll: true, zoom: true },
});
});
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
true,
);
expect(h.app.viewport.isLockedTransitionPending).toBe(true);
await waitForAnimationToStop(SCROLL_TO_CONTENT_ANIMATION_KEY);
expect(h.app.viewport.isLockedTransitionPending).toBe(false);
expect(h.state.scrollConstraints).toMatchObject({
x: secondTarget.x,
y: secondTarget.y,
width: secondTarget.width,
height: secondTarget.height,
lockScroll: true,
lockZoom: true,
});
} finally {
window.EXCALIDRAW_THROTTLE_RENDER = undefined;
}
});
it("clears both active and pending locks with setViewport(null)", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 1000, 1000],
animation: false,
lock: { scroll: true },
});
});
expect(h.state.scrollConstraints).not.toBe(null);
React.act(() => {
h.app.viewport.setViewport({
target: [2000, 2000, 3000, 3000],
animation: { duration: 1000 },
lock: { scroll: true, zoom: true },
});
});
// The superseded active lock is removed while the target lock is pending.
expect(h.state.scrollConstraints).toBe(null);
expect(h.app.viewport.isLockedTransitionPending).toBe(true);
React.act(() => {
h.app.viewport.setViewport(null);
});
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
false,
);
expect(h.app.viewport.isLockedTransitionPending).toBe(false);
expect(h.state.scrollConstraints).toBe(null);
expect(h.state.shouldCacheIgnoreZoom).toBe(false);
});
it("still lets user input cancel an unlocked viewport animation", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.viewport.setViewport({
target: [2000, 2000, 3000, 3000],
// long enough that it cannot complete on its own while the test
// waits for a frame below, even on a saturated CI machine
animation: { duration: 60_000 },
});
});
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
true,
);
expect(h.app.viewport.isLockedTransitionPending).toBe(false);
// the animation's frames draw from zoom-scaled bitmaps
await waitFor(() => {
expect(h.state.shouldCacheIgnoreZoom).toBe(true);
});
React.act(() => {
h.app.viewport.translate({ scrollX: 123, scrollY: 456 });
});
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
false,
);
expect(h.state.scrollX).toBe(123);
expect(h.state.scrollY).toBe(456);
// the interrupted animation's completion handler no longer runs, so the
// interrupting gesture switches back to crisp rasterization itself
expect(h.state.shouldCacheIgnoreZoom).toBe(false);
});
it("applies back-to-back lock updates in call order", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 1000, 1000],
animation: false,
lock: { scroll: true },
});
h.app.viewport.setViewport({
target: [1000, 1000, 2000, 2000],
animation: false,
lock: { scroll: true, zoom: true },
});
h.app.viewport.setViewport({
target: [2000, 2000, 3000, 3000],
animation: false,
});
});
expect(h.state.scrollConstraints).toBe(null);
expect(h.state.zoom.value).toBeCloseTo(0.1);
expect(h.state.scrollX).toBeCloseTo(-1500);
expect(h.state.scrollY).toBeCloseTo(-2000);
});
it("supports rect targets and defaults missing dimensions to the viewport", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.viewport.setViewport({
target: { x: 10, y: 20, width: 50 },
fit: "scale-down",
animation: false,
lock: { scroll: true },
});
});
expect(h.state.scrollConstraints).toMatchObject({
x: 10,
y: 20,
width: 50,
height: h.state.height,
});
React.act(() => {
h.app.viewport.setViewport({
target: { x: 30, y: 40, height: 60 },
fit: "scale-down",
animation: false,
lock: { scroll: true },
});
});
expect(h.state.scrollConstraints).toMatchObject({
x: 30,
y: 40,
width: h.state.width,
height: 60,
});
React.act(() => {
h.app.viewport.setViewport({
target: { x: 70, y: 80 },
fit: "scale-down",
animation: false,
lock: { scroll: true },
});
});
expect(h.state.scrollConstraints).toMatchObject({
x: 70,
y: 80,
width: h.state.width,
height: h.state.height,
});
});
it("filters deleted, non-scene, and nullable element targets", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const warnMessage =
"supplied element target(s) for setViewport contain deleted or non-existent elements which have been filtered out";
try {
const validRect = API.createElement({
type: "rectangle",
x: 100,
y: 200,
width: 300,
height: 400,
});
const deletedRect = API.createElement({
type: "rectangle",
isDeleted: true,
});
const missingRect = API.createElement({
type: "rectangle",
});
API.setElements([validRect, deletedRect]);
React.act(() => {
h.app.viewport.setViewport({
target: [
validRect,
deletedRect,
missingRect,
undefined,
null,
] as unknown as readonly typeof validRect[],
fit: "scale-down",
animation: false,
lock: { scroll: true },
});
});
expect(warnSpy).toHaveBeenCalledWith(warnMessage);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(h.state.scrollConstraints).toMatchObject({
x: validRect.x,
y: validRect.y,
width: validRect.width,
height: validRect.height,
});
const scrollConstraints = h.state.scrollConstraints;
warnSpy.mockClear();
React.act(() => {
h.app.viewport.setViewport({
target: [
deletedRect,
missingRect,
undefined,
null,
] as unknown as readonly typeof validRect[],
fit: "scale-down",
animation: false,
lock: { scroll: true },
});
});
expect(warnSpy).toHaveBeenCalledWith(warnMessage);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(h.state.scrollConstraints).toBe(scrollConstraints);
} finally {
warnSpy.mockRestore();
}
});
it("keeps zoom-to-fit actions enabled while locked, fitting the locked box", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
expect(h.app.actionManager.isActionEnabled(actionResetZoom)).toBe(true);
expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(true);
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 500, 500],
fit: "scale-down",
animation: false,
lock: { scroll: true, zoom: true },
});
});
expect(h.app.actionManager.isActionEnabled(actionResetZoom)).toBe(true);
expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(true);
expect(h.app.actionManager.isActionEnabled(actionZoomToFitSelection)).toBe(
true,
);
const restingViewport = {
scrollX: h.state.scrollX,
scrollY: h.state.scrollY,
zoom: h.state.zoom.value,
};
// zoom & pan away from the resting viewport...
React.act(() => {
h.app.actionManager.executeAction(actionZoomIn);
h.app.actionManager.executeAction(actionZoomIn);
});
expect(h.state.zoom.value).toBeGreaterThan(restingViewport.zoom);
// ...then zoom-to-fit → lands back on the locked box, not the elements
// (the scene is empty, so fitting elements would be degenerate anyway)
React.act(() => {
h.app.actionManager.executeAction(actionZoomToFit);
});
expect(h.state.zoom.value).toBeCloseTo(restingViewport.zoom);
expect(h.state.scrollX).toBeCloseTo(restingViewport.scrollX);
expect(h.state.scrollY).toBeCloseTo(restingViewport.scrollY);
});
it("resets zoom to the locked minimum zoom when zoom is locked", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
// a target smaller than the viewport → zoomToTarget fills it at zoom > 1
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 50,
});
API.setElements([rect]);
React.act(() => {
h.app.viewport.setViewport({
target: rect,
fit: "contain",
animation: false,
lock: { zoom: true },
});
});
const lockedZoom = h.state.scrollConstraints?.zoom;
expect(lockedZoom).toBeGreaterThan(1);
// zoom in, then reset → should land back on the locked minimum, not 100%
React.act(() => {
h.app.actionManager.executeAction(actionZoomIn);
});
expect(h.state.zoom.value).toBeGreaterThan(lockedZoom!);
React.act(() => {
h.app.actionManager.executeAction(actionResetZoom);
});
expect(h.state.zoom.value).toBeCloseTo(lockedZoom!);
});
it("zoom-out cannot go below the locked minimum zoom", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 50,
});
API.setElements([rect]);
React.act(() => {
h.app.viewport.setViewport({
target: rect,
fit: "contain",
animation: false,
lock: { zoom: true },
});
});
const lockedZoom = h.state.scrollConstraints?.zoom;
expect(lockedZoom).toBeGreaterThan(1);
// hammer zoom-out far past the locked floor; it must stay clamped
for (let i = 0; i < 30; i++) {
React.act(() => {
h.app.actionManager.executeAction(actionZoomOut);
});
}
expect(h.state.zoom.value).toBeCloseTo(lockedZoom!);
});
});
describe("rubberband overscroll (integration)", () => {
beforeEach(() => {
mockBoundingClientRect();
});
afterEach(() => {
restoreOriginalGetBoundingClientRect();
AnimationController.reset();
});
it("lets the user pan past the box edge (bounded by overscroll)", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 1000, 1000],
fit: "scale-down",
animation: false,
lock: { scroll: true, overscroll: 25 },
});
});
// pan up to (and past) the top edge (hard max scrollY = 0)
for (let i = 0; i < 20; i++) {
Keyboard.keyPress(KEYS.PAGE_UP);
}
// overscrolled past 0, but bounded by the give (25 screen px / zoom 1)
expect(h.state.scrollY).toBeGreaterThan(0);
expect(h.state.scrollY).toBeLessThanOrEqual(
25 / h.state.zoom.value + 0.001,
);
});
it("hard-clamps interactive zoom so it never enters the overscroll zone", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 1000, 1000],
fit: "scale-down",
animation: false,
lock: { scroll: true, overscroll: 50 },
});
});
// fitted at zoom 0.1; the box rests centered horizontally (scrollX 500)
expect(h.state.zoom.value).toBeCloseTo(0.1);
expect(h.state.scrollX).toBeCloseTo(500);
// interactive zoom-in anchored at the top-left corner (the wheel-zoom
// path): keeping that focal point fixed would leave scrollX at 500,
// which at zoom 0.2 is 100 screen px past the hard bound (scrollX 0) —
// outside the 50px give. The zoom must be hard-clamped (equivalent to
// sliding the zoom origin back into bounds), not rubberbanded.
React.act(() => {
h.app.viewport.translate((state: AppState) => ({
...getViewportForZoomWithScrollConstraints(
{ viewportX: 0, viewportY: 0, nextZoom: getNormalizedZoom(0.2) },
state,
),
}));
});
expect(h.state.zoom.value).toBeCloseTo(0.2);
expect(h.state.scrollX).toBeCloseTo(0);
// panning, by contrast, still gets the rubberband give
Keyboard.keyPress(KEYS.PAGE_UP);
expect(h.state.scrollY).toBeGreaterThan(0);
expect(h.state.scrollY).toBeLessThanOrEqual(
50 / h.state.zoom.value + 0.001,
);
});
it("allows simultaneous pinch-zoom and rubberband pan on touch", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 1000, 1000],
fit: "scale-down",
animation: false,
lock: { scroll: true, overscroll: 50 },
});
});
// fitted at zoom 0.1; the box exactly fills the viewport vertically,
// so the hard bound is scrollY = 0
const initialZoom = h.state.zoom.value;
expect(initialZoom).toBeCloseTo(0.1);
expect(h.state.scrollY).toBeCloseTo(0);
const finger1 = new Pointer("touch", 1);
const finger2 = new Pointer("touch", 2);
// spread the fingers (zoom in) while dragging both downward (pan past
// the top edge) — in a single two-finger gesture. Anchored near the top
// edge so the zoom's focal scroll doesn't counteract the pan.
finger1.downAt(50, 2);
finger2.downAt(60, 2);
finger1.move(-2, 5);
finger2.move(2, 5);
// zoomed in AND overscrolled past the top edge, bounded by the give
expect(h.state.zoom.value).toBeGreaterThan(initialZoom);
expect(h.state.scrollY).toBeGreaterThan(0);
expect(h.state.scrollY).toBeLessThanOrEqual(
50 / h.state.zoom.value + 0.001,
);
// zooming must keep working while overscrolled (used to be pinned),
// without yanking the viewport back inside the box
const zoomedWhileOverscrolled = h.state.zoom.value;
finger1.move(-2, 0);
finger2.move(2, 0);
expect(h.state.zoom.value).toBeGreaterThan(zoomedWhileOverscrolled);
expect(h.state.scrollY).toBeGreaterThan(0);
finger1.up();
finger2.up();
});
it("withholds the rubberband snap-back until the touch gesture ends", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 1000, 1000],
fit: "scale-down",
animation: false,
lock: { scroll: true, overscroll: 50 },
});
});
const finger1 = new Pointer("touch", 1);
const finger2 = new Pointer("touch", 2);
// two-finger pan past the top edge, into the overscroll zone
finger1.downAt(50, 2);
finger2.downAt(60, 2);
finger1.move(0, 5);
finger2.move(0, 5);
const heldScrollY = h.state.scrollY;
expect(heldScrollY).toBeGreaterThan(0);
// the debounced snap-back elapses while the fingers are still down —
// it must be withheld, not fight the held gesture
React.act(() => {
// eslint-disable-next-line dot-notation -- private; simulates the debounce delay elapsing
h.app.viewport["snapBackDebounced"].flush();
});
expect(
AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY),
).toBe(false);
expect(h.state.scrollY).toBe(heldScrollY);
// lifting one finger ends the gesture → the rubberband releases
finger1.up();
expect(
AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY),
).toBe(true);
finger2.up();
});
it("withholds the rubberband snap-back until a desktop pan ends", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 1000, 1000],
fit: "scale-down",
animation: false,
lock: { scroll: true, overscroll: 50 },
});
h.app.setActiveTool({ type: "hand" });
});
const mouse = new Pointer("mouse");
// Drag down past the top edge and hold the mouse button there.
mouse.downAt(50, 2);
mouse.move(0, 5);
const heldScrollY = h.state.scrollY;
expect(heldScrollY).toBeGreaterThan(0);
// The debounce may elapse while the mouse is still held, but the viewport
// must remain attached to the pointer until the pan session ends.
React.act(() => {
// eslint-disable-next-line dot-notation -- private; simulates the debounce delay elapsing
h.app.viewport["snapBackDebounced"].flush();
});
expect(
AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY),
).toBe(false);
expect(h.state.scrollY).toBe(heldScrollY);
mouse.up();
expect(
AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY),
).toBe(true);
// Direct manipulation takes ownership of the viewport again.
mouse.downAt(50, 7);
mouse.move(0, 1);
expect(
AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY),
).toBe(false);
mouse.up();
});
it("keeps the rubberband overscroll when a pan lands in the same React flush as a wheel-zoom tick", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 1000, 1000],
fit: "scale-down",
animation: false,
lock: { scroll: true, overscroll: 50 },
});
});
// rubberband past the top edge
for (let i = 0; i < 20; i++) {
Keyboard.keyPress(KEYS.PAGE_UP);
}
expect(h.state.scrollY).toBeGreaterThan(
constrainScrollState(h.state).scrollY,
);
// a zoom tick and a pan queued in one flush (a pan frame runs before
// React has flushed the zoom): the pan's constraint pass must not read
// the zoom change as its own and hard-clamp the overscroll to zero
React.act(() => {
fireEvent.wheel(GlobalTestState.interactiveCanvas, {
ctrlKey: true,
deltaY: -10,
});
fireEvent.wheel(GlobalTestState.interactiveCanvas, { deltaY: 1 });
});
expect(h.state.scrollY).toBeGreaterThan(
constrainScrollState(h.state).scrollY,
);
});
it("continues rubberband snap-back while wheel-zooming", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 1000, 1000],
fit: "scale-down",
animation: false,
lock: { scroll: true, overscroll: 50 },
});
h.app.setActiveTool({ type: "hand" });
});
const mouse = new Pointer("mouse");
window.EXCALIDRAW_THROTTLE_RENDER = true;
try {
mouse.downAt(50, 2);
mouse.move(0, 5);
mouse.up();
expect(
AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY),
).toBe(true);
expect(h.state.scrollY).toBeGreaterThan(
constrainScrollState(h.state).scrollY,
);
const zoomBefore = h.state.zoom.value;
fireEvent.wheel(GlobalTestState.interactiveCanvas, {
ctrlKey: true,
deltaY: -10,
});
expect(h.state.zoom.value).toBeGreaterThan(zoomBefore);
expect(h.state.scrollY).toBeGreaterThan(
constrainScrollState(h.state).scrollY,
);
expect(
AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY),
).toBe(true);
await waitForAnimationToStop(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY);
expect(
AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY),
).toBe(false);
expect(h.state.zoom.value).toBeGreaterThan(zoomBefore);
const restingViewport = constrainScrollState(h.state);
expect(h.state.scrollX).toBeCloseTo(restingViewport.scrollX);
expect(h.state.scrollY).toBeCloseTo(restingViewport.scrollY);
expect(h.state.zoom.value).toBeCloseTo(restingViewport.zoom.value);
} finally {
window.EXCALIDRAW_THROTTLE_RENDER = undefined;
}
});
it("defaults to DEFAULT_OVERSCROLL when omitted or `true`, 0 when `false`", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
const install = (overscroll?: boolean | number) => {
React.act(() => {
h.app.viewport.setViewport({
target: [0, 0, 1000, 1000],
fit: "scale-down",
animation: false,
lock: { scroll: true, overscroll },
});
});
};
install(undefined);
expect(h.state.scrollConstraints?.overscroll).toBe(DEFAULT_OVERSCROLL);
install(true);
expect(h.state.scrollConstraints?.overscroll).toBe(DEFAULT_OVERSCROLL);
install(false);
expect(h.state.scrollConstraints?.overscroll).toBe(0);
install(25);
expect(h.state.scrollConstraints?.overscroll).toBe(25);
});
});