From 24efadfc65cb7b46ca57a70b9452192815fd8c6e Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 24 Jun 2026 17:28:50 +0000 Subject: [PATCH] feat: Constrain Signed-off-by: Mark Tolmacs --- packages/excalidraw/actions/actionCanvas.tsx | 70 +++--- packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/App.tsx | 33 ++- packages/excalidraw/scroll.ts | 104 ++++++++- .../__snapshots__/contextmenu.test.tsx.snap | 17 ++ .../tests/__snapshots__/history.test.tsx.snap | 63 ++++++ .../regressionTests.test.tsx.snap | 52 +++++ .../tests/scrollConstraints.test.tsx | 212 ++++++++++++++++++ packages/excalidraw/types.ts | 12 + packages/excalidraw/wysiwyg/textWysiwyg.tsx | 2 +- .../tests/__snapshots__/export.test.ts.snap | 1 + 11 files changed, 531 insertions(+), 37 deletions(-) create mode 100644 packages/excalidraw/tests/scrollConstraints.test.tsx diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index d1bce4aee6..bef6eb376b 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -45,6 +45,7 @@ import { t } from "../i18n"; import { getNormalizedZoom } from "../scene"; import { centerScrollOn } from "../scene/scroll"; import { getStateForZoom } from "../scene/zoom"; +import { constrainScrollState } from "../scroll"; import { getShortcutKey } from "../shortcut"; import { register } from "./register"; @@ -141,19 +142,20 @@ export const actionZoomIn = register({ icon: ZoomInIcon, trackEvent: { category: "canvas" }, perform: (_elements, appState, _, app) => { + const nextState = { + ...appState, + ...getStateForZoom( + { + viewportX: appState.width / 2 + appState.offsetLeft, + viewportY: appState.height / 2 + appState.offsetTop, + nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP), + }, + appState, + ), + userToFollow: null, + }; return { - appState: { - ...appState, - ...getStateForZoom( - { - viewportX: appState.width / 2 + appState.offsetLeft, - viewportY: appState.height / 2 + appState.offsetTop, - nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP), - }, - appState, - ), - userToFollow: null, - }, + appState: { ...nextState, ...constrainScrollState(nextState) }, captureUpdate: CaptureUpdateAction.EVENTUALLY, }; }, @@ -182,19 +184,20 @@ export const actionZoomOut = register({ viewMode: true, trackEvent: { category: "canvas" }, perform: (_elements, appState, _, app) => { + const nextState = { + ...appState, + ...getStateForZoom( + { + viewportX: appState.width / 2 + appState.offsetLeft, + viewportY: appState.height / 2 + appState.offsetTop, + nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP), + }, + appState, + ), + userToFollow: null, + }; return { - appState: { - ...appState, - ...getStateForZoom( - { - viewportX: appState.width / 2 + appState.offsetLeft, - viewportY: appState.height / 2 + appState.offsetTop, - nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP), - }, - appState, - ), - userToFollow: null, - }, + appState: { ...nextState, ...constrainScrollState(nextState) }, captureUpdate: CaptureUpdateAction.EVENTUALLY, }; }, @@ -222,6 +225,8 @@ export const actionResetZoom = register({ icon: ZoomResetIcon, viewMode: true, trackEvent: { category: "canvas" }, + // resetting to 100% may violate active scroll constraints + predicate: (elements, appState) => !appState.scrollConstraints, perform: (_elements, appState, _, app) => { return { appState: { @@ -246,6 +251,8 @@ export const actionResetZoom = register({ className="reset-zoom-button zoom-button" title={t("buttons.resetZoom")} aria-label={t("buttons.resetZoom")} + // keep the zoom level visible, but non-resettable while constrained + disabled={!!appState.scrollConstraints} onClick={() => { updateData(null); }} @@ -254,7 +261,8 @@ export const actionResetZoom = register({ ), - keyTest: (event) => + keyTest: (event, appState) => + !appState.scrollConstraints && (event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) && (event[KEYS.CTRL_OR_CMD] || event.shiftKey), }); @@ -399,6 +407,7 @@ export const actionZoomToFitSelectionInViewport = register({ label: "labels.zoomToFitViewport", icon: zoomAreaIcon, trackEvent: { category: "canvas" }, + predicate: (elements, appState) => !appState.scrollConstraints, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); return zoomToFit({ @@ -413,7 +422,8 @@ export const actionZoomToFitSelectionInViewport = register({ }, // NOTE shift-2 should have been assigned actionZoomToFitSelection. // TBD on how proceed - keyTest: (event) => + keyTest: (event, appState) => + !appState.scrollConstraints && event.code === CODES.TWO && event.shiftKey && !event.altKey && @@ -425,6 +435,7 @@ export const actionZoomToFitSelection = register({ label: "helpDialog.zoomToSelection", icon: zoomAreaIcon, trackEvent: { category: "canvas" }, + predicate: (elements, appState) => !appState.scrollConstraints, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); return zoomToFit({ @@ -438,7 +449,8 @@ export const actionZoomToFitSelection = register({ }); }, // NOTE this action should use shift-2 per figma, alas - keyTest: (event) => + keyTest: (event, appState) => + !appState.scrollConstraints && event.code === CODES.THREE && event.shiftKey && !event.altKey && @@ -451,6 +463,7 @@ export const actionZoomToFit = register({ icon: zoomAreaIcon, viewMode: true, trackEvent: { category: "canvas" }, + predicate: (elements, appState) => !appState.scrollConstraints, perform: (elements, appState, _, app) => zoomToFit({ targetElements: elements, @@ -461,7 +474,8 @@ export const actionZoomToFit = register({ fitToViewport: false, canvasOffsets: app.getEditorUIOffsets(), }), - keyTest: (event) => + keyTest: (event, appState) => + !appState.scrollConstraints && event.code === CODES.ONE && event.shiftKey && !event.altKey && diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 3c4dd53658..ab85060fe0 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -91,6 +91,7 @@ export const getDefaultAppState = (): Omit< scrolledOutside: false, scrollX: 0, scrollY: 0, + scrollConstraints: null, selectedElementIds: {}, hoveredElementIds: {}, selectedGroupIds: {}, @@ -226,6 +227,7 @@ const APP_STATE_STORAGE_CONF = (< scrolledOutside: { browser: true, export: false, server: false }, scrollX: { browser: true, export: false, server: false }, scrollY: { browser: true, export: false, server: false }, + scrollConstraints: { browser: false, export: false, server: false }, selectedElementIds: { browser: true, export: false, server: false }, hoveredElementIds: { browser: false, export: false, server: false }, selectedGroupIds: { browser: true, export: false, server: false }, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 41e1c858ea..389a13b494 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -415,6 +415,7 @@ import { type ScrollToContentOptions, SCROLL_TO_CONTENT_ANIMATION_KEY, scrollToElements, + constrainScrollState, } from "../scroll"; import { setEraserCursor, @@ -498,6 +499,7 @@ import type { GenerateDiagramToCode, NullableGridSize, Offsets, + ScrollConstraints, } from "../types"; import type { RoughCanvas } from "roughjs/bin/canvas"; import type { Action, ActionResult } from "../actions/types"; @@ -760,6 +762,7 @@ class App extends React.Component { clear: this.resetHistory, }, scrollToContent: this.scrollToContent, + setScrollConstraints: this.setScrollConstraints, getSceneElements: this.getSceneElements, getAppState: () => this.state, getFiles: () => this.files, @@ -4373,6 +4376,7 @@ class App extends React.Component { return; } + this.setScrollConstraints(null); scrollToElements(this.state, elements, this.setState.bind(this), opts); }; @@ -4390,6 +4394,31 @@ class App extends React.Component { this.setState({ shouldCacheIgnoreZoom: false }); this.maybeUnfollowRemoteUser(); this.setState(state); + this.constrainViewportToScrollConstraints(); + }; + + /** clamps scroll/zoom back into `appState.scrollConstraints` (no-op when + * unconstrained). Runs as a queued update, so it sees the preceding change. */ + private constrainViewportToScrollConstraints = () => { + this.setState((prevState) => + prevState.scrollConstraints ? constrainScrollState(prevState) : null, + ); + }; + + /** + * 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. + */ + 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"]) => { @@ -5572,7 +5601,7 @@ class App extends React.Component { const initialScale = gesture.initialScale; if (initialScale) { - this.setState((state) => ({ + this.translateCanvas((state) => ({ ...getStateForZoom( { viewportX: this.lastViewportPosition.x, @@ -12867,6 +12896,8 @@ class App extends React.Component { cb && cb(); }, ); + // a smaller viewport may push the min zoom up / shrink the pan range + this.constrainViewportToScrollConstraints(); } }; diff --git a/packages/excalidraw/scroll.ts b/packages/excalidraw/scroll.ts index b7e8a09f55..bc1ac93772 100644 --- a/packages/excalidraw/scroll.ts +++ b/packages/excalidraw/scroll.ts @@ -1,13 +1,19 @@ -import { easeOut } from "@excalidraw/common"; +import { easeOut, MAX_ZOOM, MIN_ZOOM } from "@excalidraw/common"; import { clamp } from "@excalidraw/math"; import type { ExcalidrawElement } from "@excalidraw/element/types"; import { zoomToFit } from "./actions/actionCanvas"; import { AnimationController } from "./renderer/animation"; +import { getNormalizedZoom } from "./scene"; import { calculateScrollCenter } from "./scene/scroll"; -import type { AppState, NormalizedZoomValue, Offsets } from "./types"; +import type { + AppState, + NormalizedZoomValue, + Offsets, + ScrollConstraints, +} from "./types"; export const SCROLL_TO_CONTENT_ANIMATION_KEY = "animateScrollToContent"; @@ -39,6 +45,86 @@ export type ScrollToContentOptions = ( type Viewport = Pick; +/** + * The smallest zoom at which the constraint box still fully covers the viewport. + * Below this, the viewport would extend past the box on its binding dimension, + * so it becomes the enforced minimum zoom. When the box is smaller than the + * viewport this is > 1, forcing a zoom-in (best-effort fit). + */ +export const getMinZoomForConstraints = ( + constraints: ScrollConstraints, + viewport: { width: number; height: number }, +): number => + clamp( + Math.max( + viewport.width / constraints.width, + viewport.height / constraints.height, + ), + MIN_ZOOM, + MAX_ZOOM, + ); + +/** + * Clamps a single scroll axis so the visible scene span stays inside the box. + * The visible span is `[-scroll, -scroll + visibleSize]`; we keep it within + * `[boxStart, boxStart + boxSize]`. When the box can't cover the viewport on + * this axis (only at the MAX_ZOOM cap for a tiny box) we center the box instead. + */ +const constrainScrollAxis = ( + scroll: number, + boxStart: number, + boxSize: number, + visibleSize: number, +): number => { + const max = -boxStart; + const min = visibleSize - (boxStart + boxSize); + return min > max ? (min + max) / 2 : clamp(scroll, min, max); +}; + +/** + * Clamps a proposed scroll/zoom so that, when `scrollConstraints` is set, the + * viewport cannot pan or zoom out of the box. Returns the input scroll/zoom + * unchanged when there are no constraints. Because the whole viewport is kept + * inside the box, any zoom anchor (which lives within the viewport) is also + * guaranteed to stay inside the box. + */ +export const constrainScrollState = ( + state: Pick< + AppState, + "scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints" + >, +): Viewport => { + const { scrollConstraints, width, height } = state; + + if (!scrollConstraints) { + return { scrollX: state.scrollX, scrollY: state.scrollY, zoom: state.zoom }; + } + + const minZoom = getMinZoomForConstraints(scrollConstraints, { + width, + height, + }); + const zoomValue = getNormalizedZoom( + clamp(state.zoom.value, minZoom, MAX_ZOOM), + ); + + return { + scrollX: constrainScrollAxis( + state.scrollX, + scrollConstraints.x, + scrollConstraints.width, + width / zoomValue, + ), + scrollY: constrainScrollAxis( + state.scrollY, + scrollConstraints.y, + scrollConstraints.height, + height / zoomValue, + ), + zoom: { value: zoomValue }, + }; +}; + /** * Scrolls (and optionally zooms) the viewport so that the given target is in * view, optionally animating the transition. @@ -72,6 +158,8 @@ const getTargetViewport = ( targetElements: readonly ExcalidrawElement[], opts?: ScrollToContentOptions, ): Viewport => { + let viewport: Viewport; + if (opts?.fitToContent || opts?.fitToViewport) { const { appState } = zoomToFit({ canvasOffsets: opts.canvasOffsets, @@ -83,17 +171,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 programmatic scrolling within the constraint box, if any + return constrainScrollState({ ...state, ...viewport }); }; /** Eases the viewport from its current position to `target` over `duration`, diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 4237c50cb5..2843b16ac3 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -962,6 +962,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -1159,6 +1160,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": true, @@ -1374,6 +1376,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -1706,6 +1709,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -2038,6 +2042,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": true, @@ -2253,6 +2258,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -2495,6 +2501,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -2796,6 +2803,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "id3": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -3167,6 +3175,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -3688,6 +3697,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -4012,6 +4022,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -4338,6 +4349,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "id3": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -5624,6 +5636,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -6842,6 +6855,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -7801,6 +7815,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -8799,6 +8814,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": true, @@ -9794,6 +9810,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index c48e3aea0a..ba46b1e33b 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -86,6 +86,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "id4": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -721,6 +722,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "id4": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -1285,6 +1287,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -1646,6 +1649,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -2009,6 +2013,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -2270,6 +2275,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -2761,6 +2767,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -3065,6 +3072,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -3385,6 +3393,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -3680,6 +3689,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -3967,6 +3977,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -4203,6 +4214,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -4461,6 +4473,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -4733,6 +4746,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -4963,6 +4977,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -5193,6 +5208,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -5441,6 +5457,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -5698,6 +5715,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -5956,6 +5974,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "id1": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -6286,6 +6305,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "id8": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -6714,6 +6734,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh "id1": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -7090,6 +7111,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -7400,6 +7422,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -7694,6 +7717,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -7925,6 +7949,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -8278,6 +8303,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -8634,6 +8660,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte "id3": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -9038,6 +9065,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -9326,6 +9354,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -9591,6 +9620,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -9857,6 +9887,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -10093,6 +10124,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -10388,6 +10420,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -10707,6 +10740,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -10947,6 +10981,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -11870,6 +11905,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -12131,6 +12167,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -12367,6 +12404,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -12605,6 +12643,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -13012,6 +13051,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -13220,6 +13260,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -13431,6 +13472,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -13730,6 +13772,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -14032,6 +14075,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": -50, "scrollY": -50, "searchMatches": null, @@ -14275,6 +14319,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -14513,6 +14558,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -14753,6 +14799,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -14999,6 +15046,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -15334,6 +15382,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -15502,6 +15551,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -15790,6 +15840,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -16054,6 +16105,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -16207,6 +16259,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -16491,6 +16544,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -16653,6 +16707,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -17403,6 +17458,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -18051,6 +18107,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -18697,6 +18754,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -19450,6 +19508,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -20220,6 +20279,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements' "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -20701,6 +20761,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups "id1": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -21213,6 +21274,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del "id3": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, @@ -21673,6 +21735,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "searchMatches": null, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 9353a224de..557d3e636c 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -89,6 +89,7 @@ exports[`given element A and group of elements B and given both are selected whe "id6": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -516,6 +517,7 @@ exports[`given element A and group of elements B and given both are selected whe "id6": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -929,6 +931,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -1496,6 +1499,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -1706,6 +1710,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -2091,6 +2096,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -2335,6 +2341,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -2518,6 +2525,7 @@ exports[`regression tests > can drag element that covers another element, while "id6": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -2842,6 +2850,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -3100,6 +3109,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -3342,6 +3352,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -3579,6 +3590,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "id3": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -3838,6 +3850,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "id6": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -4151,6 +4164,7 @@ exports[`regression tests > deleting last but one element in editing group shoul }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -4591,6 +4605,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "id3": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -4875,6 +4890,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "id3": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -5151,6 +5167,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -5360,6 +5377,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -5559,6 +5577,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -5956,6 +5975,7 @@ exports[`regression tests > drags selected elements from point inside common bou "id3": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -6251,6 +6271,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -7048,6 +7069,7 @@ exports[`regression tests > given a group of selected elements with an element t "id6": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -7382,6 +7404,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -7662,6 +7685,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -7898,6 +7922,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -8137,6 +8162,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -8318,6 +8344,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -8499,6 +8526,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -8680,6 +8708,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -8914,6 +8943,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -9146,6 +9176,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -9343,6 +9374,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -9577,6 +9609,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -9758,6 +9791,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -9990,6 +10024,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -10171,6 +10206,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -10368,6 +10404,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -10553,6 +10590,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "id6": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -11083,6 +11121,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -11362,6 +11401,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": "-6.25000", "scrollY": 0, "scrolledOutside": false, @@ -11488,6 +11528,7 @@ exports[`regression tests > shift click on selected element should deselect it o "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -11690,6 +11731,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "id3": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -12011,6 +12053,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "id6": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -12442,6 +12485,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "id3": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -13081,6 +13125,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 60, "scrollY": 60, "scrolledOutside": false, @@ -13207,6 +13252,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -13840,6 +13886,7 @@ exports[`regression tests > switches from group of selected elements to another "id6": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -14179,6 +14226,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "id3": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -14442,6 +14490,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 20, "scrollY": "-18.53553", "scrolledOutside": false, @@ -14566,6 +14615,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -14932,6 +14982,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -15059,6 +15110,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, diff --git a/packages/excalidraw/tests/scrollConstraints.test.tsx b/packages/excalidraw/tests/scrollConstraints.test.tsx new file mode 100644 index 0000000000..63001485b7 --- /dev/null +++ b/packages/excalidraw/tests/scrollConstraints.test.tsx @@ -0,0 +1,212 @@ +import React from "react"; + +import { KEYS, MAX_ZOOM } from "@excalidraw/common"; + +import { Excalidraw } from "../index"; +import { + actionResetZoom, + actionZoomToFit, + actionZoomToFitSelection, +} from "../actions/actionCanvas"; +import { getNormalizedZoom } from "../scene"; +import { constrainScrollState, getMinZoomForConstraints } from "../scroll"; + +import { Keyboard } from "./helpers/ui"; +import { + mockBoundingClientRect, + render, + restoreOriginalGetBoundingClientRect, + waitFor, +} from "./test-utils"; + +import type { AppState, ScrollConstraints } from "../types"; + +const { h } = window; + +// `mockBoundingClientRect()` makes the viewport 200 x 100 +const VIEWPORT = { width: 200, height: 100 }; + +const makeState = ( + overrides: Partial & { + scrollConstraints: ScrollConstraints | null; + }, +): Pick< + AppState, + "scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints" +> => ({ + scrollX: 0, + scrollY: 0, + zoom: { value: getNormalizedZoom(1) }, + width: VIEWPORT.width, + height: VIEWPORT.height, + ...overrides, +}); + +describe("constrainScrollState (pure)", () => { + it("returns scroll/zoom unchanged when there are no constraints", () => { + const state = makeState({ + scrollX: 123, + scrollY: -45, + zoom: { value: getNormalizedZoom(0.5) }, + scrollConstraints: null, + }); + + expect(constrainScrollState(state)).toEqual({ + scrollX: 123, + scrollY: -45, + zoom: { value: getNormalizedZoom(0.5) }, + }); + }); + + describe("box larger than the viewport", () => { + const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 }; + + it("clamps scroll so the viewport can't leave the box", () => { + // pan past the box's top-left corner → clamp to the corner (scroll 0) + const corner = constrainScrollState( + makeState({ scrollX: 100, scrollY: 100, scrollConstraints: box }), + ); + expect(corner.scrollX).toBeCloseTo(0); + expect(corner.scrollY).toBeCloseTo(0); + + // pan past the box's far edges → clamp to width/height - boxSize + const farEdge = constrainScrollState( + makeState({ scrollX: -5000, scrollY: -5000, scrollConstraints: box }), + ); + expect(farEdge.scrollX).toBeCloseTo(VIEWPORT.width - box.width); // -800 + expect(farEdge.scrollY).toBeCloseTo(VIEWPORT.height - box.height); // -900 + }); + + it("enforces a minimum zoom so the box keeps covering the viewport", () => { + const minZoom = getMinZoomForConstraints(box, VIEWPORT); + expect(minZoom).toBeCloseTo(0.2); // max(200/1000, 100/1000) + + const { zoom } = constrainScrollState( + makeState({ + zoom: { value: getNormalizedZoom(0.1) }, + scrollConstraints: box, + }), + ); + expect(zoom.value).toBeCloseTo(minZoom); + }); + + it("leaves an in-bounds viewport untouched", () => { + const inBounds = makeState({ + scrollX: -100, + scrollY: -100, + zoom: { value: getNormalizedZoom(1) }, + scrollConstraints: box, + }); + expect(constrainScrollState(inBounds)).toMatchObject({ + scrollX: -100, + scrollY: -100, + zoom: { value: getNormalizedZoom(1) }, + }); + }); + }); + + describe("box smaller than the viewport (best-effort fit)", () => { + it("forces a zoom-in and removes pan room on the binding axis", () => { + const box: ScrollConstraints = { x: 0, y: 0, width: 50, height: 50 }; + // minZoom = max(200/50, 100/50) = 4 + expect(getMinZoomForConstraints(box, VIEWPORT)).toBeCloseTo(4); + + const result = constrainScrollState( + makeState({ + scrollX: 999, + scrollY: 999, + zoom: { value: getNormalizedZoom(1) }, + scrollConstraints: box, + }), + ); + + expect(result.zoom.value).toBeCloseTo(4); + // width binds: visible width (200/4=50) == box width → no x pan room + expect(result.scrollX).toBeCloseTo(0); + // height has slack (100/4=25 < 50) → clamped to the top edge here + expect(result.scrollY).toBeCloseTo(0); + }); + + it("centers the box when it can't cover the viewport even at MAX_ZOOM", () => { + const box: ScrollConstraints = { x: 0, y: 0, width: 1, height: 1 }; + expect(getMinZoomForConstraints(box, VIEWPORT)).toBe(MAX_ZOOM); + + const result = constrainScrollState( + makeState({ scrollX: 1000, scrollY: 1000, scrollConstraints: box }), + ); + + expect(result.zoom.value).toBe(MAX_ZOOM); + // centered: (min + max) / 2, with min = w/zoom - (x+w), max = -x + const centeredX = (VIEWPORT.width / MAX_ZOOM - 1 + 0) / 2; + expect(result.scrollX).toBeCloseTo(centeredX); + }); + }); +}); + +describe("setScrollConstraints (integration)", () => { + beforeEach(() => { + mockBoundingClientRect(); + }); + + afterEach(() => { + restoreOriginalGetBoundingClientRect(); + }); + + it("snaps the current viewport inside the box when constraints are set", async () => { + await render(); + await waitFor(() => expect(h.state.width).toBe(200)); + + // start well outside the future box + React.act(() => { + h.app.setScrollConstraints({ x: 0, y: 0, width: 1000, height: 1000 }); + }); + + // viewport must now be within [-800, 0] x [-900, 0] + expect(h.state.scrollX).toBeLessThanOrEqual(0); + expect(h.state.scrollX).toBeGreaterThanOrEqual(-800); + expect(h.state.scrollY).toBeLessThanOrEqual(0); + expect(h.state.scrollY).toBeGreaterThanOrEqual(-900); + expect(h.state.zoom.value).toBeGreaterThanOrEqual(0.2); + }); + + it("prevents keyboard panning out of the box and restores freedom on clear", async () => { + await render(); + await waitFor(() => expect(h.state.width).toBe(200)); + + React.act(() => { + h.app.setScrollConstraints({ x: 0, y: 0, width: 300, height: 200 }); + }); + + // hammer page-down (pans down) far past the box; scroll must stay clamped + for (let i = 0; i < 20; i++) { + Keyboard.keyPress(KEYS.PAGE_DOWN); + } + expect(h.state.scrollY).toBeGreaterThanOrEqual(h.state.height - 200); + + // clearing constraints lets it scroll freely again + React.act(() => { + h.app.setScrollConstraints(null); + }); + const before = h.state.scrollY; + Keyboard.keyPress(KEYS.PAGE_DOWN); + expect(h.state.scrollY).toBeLessThan(before); + }); + + it("disables reset-zoom and zoom-to-fit actions while constrained", async () => { + await render(); + await waitFor(() => expect(h.state.width).toBe(200)); + + expect(h.app.actionManager.isActionEnabled(actionResetZoom)).toBe(true); + expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(true); + + React.act(() => { + h.app.setScrollConstraints({ x: 0, y: 0, width: 500, height: 500 }); + }); + + expect(h.app.actionManager.isActionEnabled(actionResetZoom)).toBe(false); + expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(false); + expect(h.app.actionManager.isActionEnabled(actionZoomToFitSelection)).toBe( + false, + ); + }); +}); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 0457591a75..f93adf11cf 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -273,6 +273,14 @@ export type ObservedElementsAppState = { export type BoxSelectionMode = "contain" | "overlap"; +/** A box, in scene coordinates, that pan & zoom are constrained to. */ +export type ScrollConstraints = { + x: number; + y: number; + width: number; + height: number; +}; + export interface AppState { contextMenu: { items: ContextMenuItems; @@ -380,6 +388,9 @@ export interface AppState { viewBackgroundColor: string; scrollX: number; scrollY: number; + /** when set, pan & zoom are constrained so the viewport stays within this + * scene-coordinate box (see `setScrollConstraints`) */ + scrollConstraints: ScrollConstraints | null; cursorButton: "up" | "down"; scrolledOutside: boolean; name: string | null; @@ -971,6 +982,7 @@ export interface ExcalidrawImperativeAPI { getFiles: () => InstanceType["files"]; getName: InstanceType["getName"]; scrollToContent: InstanceType["scrollToContent"]; + setScrollConstraints: InstanceType["setScrollConstraints"]; registerAction: (action: Action) => void; refresh: InstanceType["refresh"]; setToast: InstanceType["setToast"]; diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index eeee206fa7..9a65fad510 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx @@ -635,7 +635,7 @@ export const textWysiwyg = ({ event.preventDefault(); app.actionManager.executeAction(actionZoomOut); updateWysiwygStyle(); - } else if (!event.shiftKey && actionResetZoom.keyTest(event)) { + } else if (!event.shiftKey && actionResetZoom.keyTest(event, app.state)) { event.preventDefault(); app.actionManager.executeAction(actionResetZoom); updateWysiwygStyle(); diff --git a/packages/utils/tests/__snapshots__/export.test.ts.snap b/packages/utils/tests/__snapshots__/export.test.ts.snap index e808df6e22..aef46783d3 100644 --- a/packages/utils/tests/__snapshots__/export.test.ts.snap +++ b/packages/utils/tests/__snapshots__/export.test.ts.snap @@ -86,6 +86,7 @@ exports[`exportToSvg > with default arguments 1`] = ` }, "previousSelectedElementIds": {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false,