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(
(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;
}
+10 -24
View File
@@ -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<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
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 });
};
+9 -3
View File
@@ -41,6 +41,7 @@ export type ScrollToContentOptions = (
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
scrollConstraints?: ScrollConstraints | null;
};
type Viewport = Pick<AppState, "scrollX" | "scrollY" | "zoom">;
@@ -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 };
}
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";
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(<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 () => {
await render(<Excalidraw />);
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({
h.app.scrollToContent([], {
scrollConstraints: {
x: 0,
y: 0,
width: 1000,
height: 1000,
tolerance: 25,
},
});
});
+1 -2
View File
@@ -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<typeof App>["files"];
getName: InstanceType<typeof App>["getName"];
scrollToContent: InstanceType<typeof App>["scrollToContent"];
setScrollConstraints: InstanceType<typeof App>["setScrollConstraints"];
registerAction: (action: Action) => void;
refresh: InstanceType<typeof App>["refresh"];
setToast: InstanceType<typeof App>["setToast"];