feat: Unify scroll constraint
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
// 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";
|
||||
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({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
tolerance: 25,
|
||||
h.app.scrollToContent([], {
|
||||
scrollConstraints: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
tolerance: 25,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user