From 08827b18f894ae0689d7c5344200977019f988a7 Mon Sep 17 00:00:00 2001 From: dwelle <5153846+dwelle@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:22:23 +0200 Subject: [PATCH] wip --- packages/excalidraw/components/App.tsx | 54 ++++++- .../tests/scrollConstraints.test.tsx | 135 ++++++++++++++++++ 2 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 packages/excalidraw/tests/scrollConstraints.test.tsx diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index e9b9df184b..e9d76ece20 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -610,6 +610,18 @@ let invalidateContextMenu = false; let scrollConstraintsAnimationTimeout: ReturnType | null = null; +const areScrollConstraintsEqual = ( + left: ScrollConstraints | null | undefined, + right: ScrollConstraints | null | undefined, +) => { + return left === right || (!!left && !!right && isShallowEqual(left, right)); +}; + +const SET_SCROLL_CONSTRAINTS_CONTROLLED_WARNING = + "Excalidraw: `setScrollConstraints()` is ignored when the `scrollConstraints` prop is controlled."; +const SCROLL_TO_CONTENT_SCROLL_LOCK_CONTROLLED_WARNING = + "Excalidraw: `scrollToContent()` with `scrollLock` is ignored when the `scrollConstraints` prop is controlled."; + /** * Map of youtube embed video states */ @@ -3495,6 +3507,15 @@ class App extends React.Component { this.setState({ viewModeEnabled: !!this.props.viewModeEnabled }); } + if ( + !areScrollConstraintsEqual( + prevProps.scrollConstraints, + this.props.scrollConstraints, + ) + ) { + this.syncScrollConstraints(this.props.scrollConstraints ?? null); + } + if (prevState.viewModeEnabled !== this.state.viewModeEnabled) { this.addEventListeners(); this.deselectElements(); @@ -4378,6 +4399,11 @@ class App extends React.Component { | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(), opts?: ScrollToContentOptions, ) => { + if (opts?.scrollLock && this.props.scrollConstraints !== undefined) { + console.warn(SCROLL_TO_CONTENT_SCROLL_LOCK_CONTROLLED_WARNING); + opts = { ...opts, scrollLock: undefined }; + } + if (typeof target === "string") { let id: string | null; if (isElementLink(target)) { @@ -4434,7 +4460,7 @@ class App extends React.Component { scrollConstraintsAnimationTimeout = null; } - if (opts?.scrollLock && this.state.scrollConstraints) { + if (opts?.scrollLock != null && this.state.scrollConstraints) { // Clear the previous lock before starting the next locked transition so // stale constraint enforcement cannot snap us back mid-flight. this.setState({ scrollConstraints: null }); @@ -4492,7 +4518,6 @@ class App extends React.Component { this.setState({ scrollConstraints, - viewModeEnabled: true, }); }; @@ -4538,7 +4563,6 @@ class App extends React.Component { scrollY, zoom, scrollConstraints, - viewModeEnabled: true, }); } else { this.setState({ scrollX, scrollY, zoom }); @@ -13065,11 +13089,31 @@ class App extends React.Component { public setScrollConstraints = ( scrollConstraints: ScrollConstraints | null, ) => { + if (this.props.scrollConstraints !== undefined) { + console.warn(SET_SCROLL_CONSTRAINTS_CONTROLLED_WARNING); + return; + } + + this.syncScrollConstraints(scrollConstraints); + }; + + private syncScrollConstraints = ( + scrollConstraints: ScrollConstraints | null, + ) => { + this.debounceConstrainScrollState.cancel(); + + if (scrollConstraintsAnimationTimeout) { + clearTimeout(scrollConstraintsAnimationTimeout); + scrollConstraintsAnimationTimeout = null; + } + + this.cancelInProgressAnimation?.(); + if (scrollConstraints) { this.setState( { scrollConstraints, - viewModeEnabled: true, + shouldCacheIgnoreZoom: false, }, () => { const newState = constrainScrollState( @@ -13097,7 +13141,7 @@ class App extends React.Component { } else { this.setState({ scrollConstraints: null, - viewModeEnabled: false, + shouldCacheIgnoreZoom: false, }); } }; diff --git a/packages/excalidraw/tests/scrollConstraints.test.tsx b/packages/excalidraw/tests/scrollConstraints.test.tsx new file mode 100644 index 0000000000..042bc37ae6 --- /dev/null +++ b/packages/excalidraw/tests/scrollConstraints.test.tsx @@ -0,0 +1,135 @@ +import React from "react"; +import { vi } from "vitest"; + +import { resolvablePromise } from "@excalidraw/common"; + +import { Excalidraw } from "../index"; + +import { API } from "./helpers/api"; +import { act, render, waitFor } from "./test-utils"; + +import type { ExcalidrawImperativeAPI, ScrollConstraints } from "../types"; + +const FIRST_SCROLL_CONSTRAINTS: ScrollConstraints = { + x: 0, + y: 0, + width: 400, + height: 300, + lockZoom: true, + overscrollAllowance: 0, + viewportZoomFactor: 1, + animateOnNextUpdate: false, +}; + +const SECOND_SCROLL_CONSTRAINTS: ScrollConstraints = { + x: 100, + y: 200, + width: 500, + height: 350, + lockZoom: false, + overscrollAllowance: 0.2, + viewportZoomFactor: 0.8, + animateOnNextUpdate: false, +}; + +describe("scrollConstraints", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("syncs prop updates after mount", async () => { + const { rerender } = await render(); + + expect(window.h.state.scrollConstraints).toBe(null); + + rerender(); + + await waitFor(() => { + expect(window.h.state.scrollConstraints).toEqual( + FIRST_SCROLL_CONSTRAINTS, + ); + }); + + rerender(); + + await waitFor(() => { + expect(window.h.state.scrollConstraints).toEqual( + SECOND_SCROLL_CONSTRAINTS, + ); + }); + + rerender(); + + await waitFor(() => { + expect(window.h.state.scrollConstraints).toBe(null); + }); + }); + + it("ignores setScrollConstraints() when the prop is controlled", async () => { + const excalidrawAPIPromise = resolvablePromise(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await render( + { + if (api) { + excalidrawAPIPromise.resolve(api); + } + }} + />, + ); + + const excalidrawAPI = await excalidrawAPIPromise; + + act(() => { + excalidrawAPI.setScrollConstraints(SECOND_SCROLL_CONSTRAINTS); + }); + + expect(warn).toHaveBeenCalledWith( + "Excalidraw: `setScrollConstraints()` is ignored when the `scrollConstraints` prop is controlled. Update the prop value instead.", + ); + expect(window.h.state.scrollConstraints).toEqual(FIRST_SCROLL_CONSTRAINTS); + }); + + it("ignores scrollToContent() scrollLock when the prop is controlled", async () => { + const excalidrawAPIPromise = resolvablePromise(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await render( + { + if (api) { + excalidrawAPIPromise.resolve(api); + } + }} + />, + ); + + const excalidrawAPI = await excalidrawAPIPromise; + const rectangle = API.createElement({ + x: 1000, + y: 1000, + width: 100, + height: 100, + }); + API.setElements([rectangle]); + + act(() => { + excalidrawAPI.scrollToContent(rectangle, { + animate: false, + fitToViewport: true, + scrollLock: { + lockZoom: true, + overscrollAllowance: 0, + }, + }); + }); + + expect(warn).toHaveBeenCalledWith( + "Excalidraw: `scrollToContent()` with `scrollLock` is ignored when the `scrollConstraints` prop is controlled. Update the prop value instead.", + ); + expect(window.h.state.scrollConstraints).toEqual(FIRST_SCROLL_CONSTRAINTS); + }); +});