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 & { 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 & Pick, ): ScrollConstraints => ({ lockScroll: false, lockZoom: false, zoom: 1, overscroll: 0, ...overrides, }); const waitForAnimationToStop = (key: string, maxFrames = 200) => { return React.act( () => new Promise((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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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); }); });