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(
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
Reference in New Issue
Block a user