diff --git a/excalidraw-app/components/AppFooter.tsx b/excalidraw-app/components/AppFooter.tsx index bac6951feb..8cb2fe5fc5 100644 --- a/excalidraw-app/components/AppFooter.tsx +++ b/excalidraw-app/components/AppFooter.tsx @@ -26,7 +26,8 @@ const ScrollConstraintsDebugFooter = ({ const setLock = useCallback( (nextLock: ScrollConstraints) => { - excalidrawAPI?.setScrollConstraints(nextLock); + // pass an empty target so the constraints are applied without scrolling + excalidrawAPI?.scrollToContent([], { scrollConstraints: nextLock }); setActiveLock(nextLock); }, [excalidrawAPI], @@ -38,7 +39,7 @@ const ScrollConstraintsDebugFooter = ({ } if (activeLock) { - excalidrawAPI.setScrollConstraints(null); + excalidrawAPI.scrollToContent([], { scrollConstraints: null }); setActiveLock(null); return; } diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 8cc29942f5..e47bc16807 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -501,7 +501,6 @@ import type { GenerateDiagramToCode, NullableGridSize, Offsets, - ScrollConstraints, } from "../types"; import type { RoughCanvas } from "roughjs/bin/canvas"; import type { Action, ActionResult } from "../actions/types"; @@ -768,7 +767,6 @@ class App extends React.Component { clear: this.resetHistory, }, scrollToContent: this.scrollToContent, - setScrollConstraints: this.setScrollConstraints, getSceneElements: this.getSceneElements, getAppState: () => this.state, getFiles: () => this.files, @@ -4370,6 +4368,16 @@ class App extends React.Component { elements = this.scene.getNonDeletedElements(); } + const scrollConstraints = opts?.scrollConstraints ?? null; + if (scrollConstraints || this.state.scrollConstraints) { + flushSync(() => { + this.setState((prevState) => ({ + scrollConstraints, + ...constrainScrollState({ ...prevState, scrollConstraints }), + })); + }); + } + if (!elements.length) { if (typeof target === "string" && isElementLink(target)) { this.setState({ @@ -4384,8 +4392,6 @@ class App extends React.Component { return; } - this.setScrollConstraints(null); - // Navigating to an element by id or element-link defaults to zooming the // element into view, animated — matching the historical element-link // behavior — unless the caller opts out. @@ -4454,26 +4460,6 @@ class App extends React.Component { SCROLL_CONSTRAINTS_SNAP_BACK_DELAY, ); - /** - * Constrains pan & zoom to a scene-coordinate box, so the viewport can't be - * scrolled or zoomed out past it. Pass `null` to remove the constraint. When - * the box is smaller than the viewport, zoom is increased best-effort to fit. - * - * Optional `minZoom`/`maxZoom` take precedence over the box: `minZoom` lets - * the viewport zoom out past the fit zoom, while `maxZoom` caps zoom-in below - * the global limit. - */ - setScrollConstraints = (scrollConstraints: ScrollConstraints | null) => { - // apply the constraint and clamp the viewport in a single, synchronously - // flushed update so the new scroll/zoom is reflected immediately - flushSync(() => { - this.setState((prevState) => ({ - scrollConstraints, - ...constrainScrollState({ ...prevState, scrollConstraints }), - })); - }); - }; - setToast = (toast: AppState["toast"]) => { this.setState({ toast }); }; diff --git a/packages/excalidraw/scroll.ts b/packages/excalidraw/scroll.ts index 207fe534b2..4a359c44c2 100644 --- a/packages/excalidraw/scroll.ts +++ b/packages/excalidraw/scroll.ts @@ -41,6 +41,7 @@ export type ScrollToContentOptions = ( minZoom?: number; maxZoom?: number; canvasOffsets?: Offsets; + scrollConstraints?: ScrollConstraints | null; }; type Viewport = Pick; @@ -263,6 +264,8 @@ const getTargetViewport = ( targetElements: readonly ExcalidrawElement[], opts?: ScrollToContentOptions, ): Viewport => { + let viewport: Viewport; + if (opts?.fitToContent || opts?.fitToViewport) { const { appState } = zoomToFit({ canvasOffsets: opts.canvasOffsets, @@ -274,16 +277,19 @@ const getTargetViewport = ( maxZoom: opts.maxZoom, }); - return { + viewport = { scrollX: appState.scrollX, scrollY: appState.scrollY, zoom: appState.zoom, }; + } else { + // keep the current zoom, only recenter the viewport on the target + const { scrollX, scrollY } = calculateScrollCenter(targetElements, state); + viewport = { scrollX, scrollY, zoom: state.zoom }; } - // keep the current zoom, only recenter the viewport on the target - const { scrollX, scrollY } = calculateScrollCenter(targetElements, state); - return { scrollX, scrollY, zoom: state.zoom }; + // keep the target inside any active scroll constraints (no-op otherwise) + return constrainScrollState({ ...state, ...viewport }); }; /** diff --git a/packages/excalidraw/tests/scrollConstraints.test.tsx b/packages/excalidraw/tests/scrollConstraints.test.tsx index 8c683762ec..b0601939f7 100644 --- a/packages/excalidraw/tests/scrollConstraints.test.tsx +++ b/packages/excalidraw/tests/scrollConstraints.test.tsx @@ -19,6 +19,7 @@ import { } from "../scroll"; import { AnimationController } from "../renderer/animation"; +import { API } from "./helpers/api"; import { Keyboard } from "./helpers/ui"; import { mockBoundingClientRect, @@ -151,7 +152,7 @@ describe("constrainScrollState (pure)", () => { }); }); -describe("setScrollConstraints (integration)", () => { +describe("scrollToContent scrollConstraints (integration)", () => { beforeEach(() => { mockBoundingClientRect(); }); @@ -166,7 +167,9 @@ describe("setScrollConstraints (integration)", () => { // start well outside the future box React.act(() => { - h.app.setScrollConstraints({ x: 0, y: 0, width: 1000, height: 1000 }); + h.app.scrollToContent([], { + scrollConstraints: { x: 0, y: 0, width: 1000, height: 1000 }, + }); }); // viewport must now be within [-800, 0] x [-900, 0] @@ -182,7 +185,9 @@ describe("setScrollConstraints (integration)", () => { await waitFor(() => expect(h.state.width).toBe(200)); React.act(() => { - h.app.setScrollConstraints({ x: 0, y: 0, width: 300, height: 200 }); + h.app.scrollToContent([], { + scrollConstraints: { x: 0, y: 0, width: 300, height: 200 }, + }); }); // hammer page-down (pans down) far past the box; scroll must stay clamped @@ -193,13 +198,40 @@ describe("setScrollConstraints (integration)", () => { // clearing constraints lets it scroll freely again React.act(() => { - h.app.setScrollConstraints(null); + h.app.scrollToContent([], { scrollConstraints: null }); }); const before = h.state.scrollY; Keyboard.keyPress(KEYS.PAGE_DOWN); expect(h.state.scrollY).toBeLessThan(before); }); + it("clamps the scroll target into the box when scrolling to content", async () => { + await render(); + await waitFor(() => expect(h.state.width).toBe(200)); + + // an element well outside the future constraint box + const rect = API.createElement({ + type: "rectangle", + x: 2000, + y: 2000, + width: 100, + height: 100, + }); + API.setElements([rect]); + + React.act(() => { + h.app.scrollToContent(rect, { + scrollConstraints: { x: 0, y: 0, width: 1000, height: 1000 }, + animate: false, + }); + }); + + // recentering on the element would scroll way past the box; it must be + // clamped to the far edges instead (scrollX -800, scrollY -900 at zoom 1) + expect(h.state.scrollX).toBeCloseTo(-800); + expect(h.state.scrollY).toBeCloseTo(-900); + }); + it("disables reset-zoom and zoom-to-fit actions while constrained", async () => { await render(); await waitFor(() => expect(h.state.width).toBe(200)); @@ -208,7 +240,9 @@ describe("setScrollConstraints (integration)", () => { expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(true); React.act(() => { - h.app.setScrollConstraints({ x: 0, y: 0, width: 500, height: 500 }); + h.app.scrollToContent([], { + scrollConstraints: { x: 0, y: 0, width: 500, height: 500 }, + }); }); expect(h.app.actionManager.isActionEnabled(actionResetZoom)).toBe(false); @@ -468,12 +502,14 @@ describe("rubberband tolerance (integration)", () => { await waitFor(() => expect(h.state.width).toBe(200)); React.act(() => { - h.app.setScrollConstraints({ - x: 0, - y: 0, - width: 1000, - height: 1000, - tolerance: 25, + h.app.scrollToContent([], { + scrollConstraints: { + x: 0, + y: 0, + width: 1000, + height: 1000, + tolerance: 25, + }, }); }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 20db41d923..f71763d574 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -411,7 +411,7 @@ export interface AppState { scrollX: number; scrollY: number; /** when set, pan & zoom are constrained so the viewport stays within this - * scene-coordinate box (see `setScrollConstraints`) */ + * scene-coordinate box (see `scrollToContent`'s `scrollConstraints` option) */ scrollConstraints: ScrollConstraints | null; cursorButton: "up" | "down"; scrolledOutside: boolean; @@ -1004,7 +1004,6 @@ export interface ExcalidrawImperativeAPI { getFiles: () => InstanceType["files"]; getName: InstanceType["getName"]; scrollToContent: InstanceType["scrollToContent"]; - setScrollConstraints: InstanceType["setScrollConstraints"]; registerAction: (action: Action) => void; refresh: InstanceType["refresh"]; setToast: InstanceType["setToast"];