feat: Unify scroll constraint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-06-26 17:33:37 +00:00
parent dba56d3edc
commit 5974e463d8
5 changed files with 71 additions and 43 deletions
+3 -2
View File
@@ -26,7 +26,8 @@ const ScrollConstraintsDebugFooter = ({
const setLock = useCallback( const setLock = useCallback(
(nextLock: ScrollConstraints) => { (nextLock: ScrollConstraints) => {
excalidrawAPI?.setScrollConstraints(nextLock); // pass an empty target so the constraints are applied without scrolling
excalidrawAPI?.scrollToContent([], { scrollConstraints: nextLock });
setActiveLock(nextLock); setActiveLock(nextLock);
}, },
[excalidrawAPI], [excalidrawAPI],
@@ -38,7 +39,7 @@ const ScrollConstraintsDebugFooter = ({
} }
if (activeLock) { if (activeLock) {
excalidrawAPI.setScrollConstraints(null); excalidrawAPI.scrollToContent([], { scrollConstraints: null });
setActiveLock(null); setActiveLock(null);
return; return;
} }
+10 -24
View File
@@ -501,7 +501,6 @@ import type {
GenerateDiagramToCode, GenerateDiagramToCode,
NullableGridSize, NullableGridSize,
Offsets, Offsets,
ScrollConstraints,
} from "../types"; } from "../types";
import type { RoughCanvas } from "roughjs/bin/canvas"; import type { RoughCanvas } from "roughjs/bin/canvas";
import type { Action, ActionResult } from "../actions/types"; import type { Action, ActionResult } from "../actions/types";
@@ -768,7 +767,6 @@ class App extends React.Component<AppProps, AppState> {
clear: this.resetHistory, clear: this.resetHistory,
}, },
scrollToContent: this.scrollToContent, scrollToContent: this.scrollToContent,
setScrollConstraints: this.setScrollConstraints,
getSceneElements: this.getSceneElements, getSceneElements: this.getSceneElements,
getAppState: () => this.state, getAppState: () => this.state,
getFiles: () => this.files, getFiles: () => this.files,
@@ -4370,6 +4368,16 @@ class App extends React.Component<AppProps, AppState> {
elements = this.scene.getNonDeletedElements(); 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 (!elements.length) {
if (typeof target === "string" && isElementLink(target)) { if (typeof target === "string" && isElementLink(target)) {
this.setState({ this.setState({
@@ -4384,8 +4392,6 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
this.setScrollConstraints(null);
// Navigating to an element by id or element-link defaults to zooming the // Navigating to an element by id or element-link defaults to zooming the
// element into view, animated — matching the historical element-link // element into view, animated — matching the historical element-link
// behavior — unless the caller opts out. // behavior — unless the caller opts out.
@@ -4454,26 +4460,6 @@ class App extends React.Component<AppProps, AppState> {
SCROLL_CONSTRAINTS_SNAP_BACK_DELAY, 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"]) => { setToast = (toast: AppState["toast"]) => {
this.setState({ toast }); this.setState({ toast });
}; };
+10 -4
View File
@@ -41,6 +41,7 @@ export type ScrollToContentOptions = (
minZoom?: number; minZoom?: number;
maxZoom?: number; maxZoom?: number;
canvasOffsets?: Offsets; canvasOffsets?: Offsets;
scrollConstraints?: ScrollConstraints | null;
}; };
type Viewport = Pick<AppState, "scrollX" | "scrollY" | "zoom">; type Viewport = Pick<AppState, "scrollX" | "scrollY" | "zoom">;
@@ -263,6 +264,8 @@ const getTargetViewport = (
targetElements: readonly ExcalidrawElement[], targetElements: readonly ExcalidrawElement[],
opts?: ScrollToContentOptions, opts?: ScrollToContentOptions,
): Viewport => { ): Viewport => {
let viewport: Viewport;
if (opts?.fitToContent || opts?.fitToViewport) { if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({ const { appState } = zoomToFit({
canvasOffsets: opts.canvasOffsets, canvasOffsets: opts.canvasOffsets,
@@ -274,16 +277,19 @@ const getTargetViewport = (
maxZoom: opts.maxZoom, maxZoom: opts.maxZoom,
}); });
return { viewport = {
scrollX: appState.scrollX, scrollX: appState.scrollX,
scrollY: appState.scrollY, scrollY: appState.scrollY,
zoom: appState.zoom, 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 });
}; };
/** /**
@@ -19,6 +19,7 @@ import {
} from "../scroll"; } from "../scroll";
import { AnimationController } from "../renderer/animation"; import { AnimationController } from "../renderer/animation";
import { API } from "./helpers/api";
import { Keyboard } from "./helpers/ui"; import { Keyboard } from "./helpers/ui";
import { import {
mockBoundingClientRect, mockBoundingClientRect,
@@ -151,7 +152,7 @@ describe("constrainScrollState (pure)", () => {
}); });
}); });
describe("setScrollConstraints (integration)", () => { describe("scrollToContent scrollConstraints (integration)", () => {
beforeEach(() => { beforeEach(() => {
mockBoundingClientRect(); mockBoundingClientRect();
}); });
@@ -166,7 +167,9 @@ describe("setScrollConstraints (integration)", () => {
// start well outside the future box // start well outside the future box
React.act(() => { 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] // 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)); await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => { 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 // 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 // clearing constraints lets it scroll freely again
React.act(() => { React.act(() => {
h.app.setScrollConstraints(null); h.app.scrollToContent([], { scrollConstraints: null });
}); });
const before = h.state.scrollY; const before = h.state.scrollY;
Keyboard.keyPress(KEYS.PAGE_DOWN); Keyboard.keyPress(KEYS.PAGE_DOWN);
expect(h.state.scrollY).toBeLessThan(before); expect(h.state.scrollY).toBeLessThan(before);
}); });
it("clamps the scroll target into the box when scrolling to content", async () => {
await render(<Excalidraw />);
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 () => { it("disables reset-zoom and zoom-to-fit actions while constrained", async () => {
await render(<Excalidraw />); await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200)); await waitFor(() => expect(h.state.width).toBe(200));
@@ -208,7 +240,9 @@ describe("setScrollConstraints (integration)", () => {
expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(true); expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(true);
React.act(() => { 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); expect(h.app.actionManager.isActionEnabled(actionResetZoom)).toBe(false);
@@ -468,12 +502,14 @@ describe("rubberband tolerance (integration)", () => {
await waitFor(() => expect(h.state.width).toBe(200)); await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => { React.act(() => {
h.app.setScrollConstraints({ h.app.scrollToContent([], {
x: 0, scrollConstraints: {
y: 0, x: 0,
width: 1000, y: 0,
height: 1000, width: 1000,
tolerance: 25, height: 1000,
tolerance: 25,
},
}); });
}); });
+1 -2
View File
@@ -411,7 +411,7 @@ export interface AppState {
scrollX: number; scrollX: number;
scrollY: number; scrollY: number;
/** when set, pan & zoom are constrained so the viewport stays within this /** 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; scrollConstraints: ScrollConstraints | null;
cursorButton: "up" | "down"; cursorButton: "up" | "down";
scrolledOutside: boolean; scrolledOutside: boolean;
@@ -1004,7 +1004,6 @@ export interface ExcalidrawImperativeAPI {
getFiles: () => InstanceType<typeof App>["files"]; getFiles: () => InstanceType<typeof App>["files"];
getName: InstanceType<typeof App>["getName"]; getName: InstanceType<typeof App>["getName"];
scrollToContent: InstanceType<typeof App>["scrollToContent"]; scrollToContent: InstanceType<typeof App>["scrollToContent"];
setScrollConstraints: InstanceType<typeof App>["setScrollConstraints"];
registerAction: (action: Action) => void; registerAction: (action: Action) => void;
refresh: InstanceType<typeof App>["refresh"]; refresh: InstanceType<typeof App>["refresh"];
setToast: InstanceType<typeof App>["setToast"]; setToast: InstanceType<typeof App>["setToast"];