diff --git a/examples/with-script-in-browser/components/ExampleApp.tsx b/examples/with-script-in-browser/components/ExampleApp.tsx
index 294a11a58d..fd380cbac4 100644
--- a/examples/with-script-in-browser/components/ExampleApp.tsx
+++ b/examples/with-script-in-browser/components/ExampleApp.tsx
@@ -917,8 +917,9 @@ export default function ExampleApp({
}
const elements = excalidrawAPI.getSceneElements();
- excalidrawAPI.scrollToContent(elements[0], {
- fitToViewport: true,
+ excalidrawAPI.setViewport({
+ target: elements[0],
+ fit: "contain",
});
}}
>
@@ -932,34 +933,14 @@ export default function ExampleApp({
}
const elements = excalidrawAPI.getSceneElements();
- excalidrawAPI.scrollToContent(elements[0], {
- fitToContent: true,
- });
-
- excalidrawAPI.scrollToContent(elements[0], {
- fitToContent: true,
+ excalidrawAPI.setViewport({
+ target: elements[0],
+ fit: "scale-down",
});
}}
>
Fit to content, first element
-
diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx
index a87ce1ba27..9ba64e1df9 100644
--- a/excalidraw-app/App.tsx
+++ b/excalidraw-app/App.tsx
@@ -979,7 +979,11 @@ const ExcalidrawWrapper = () => {
onLinkOpen={(element, event) => {
if (element.link && isElementLink(element.link)) {
event.preventDefault();
- excalidrawAPI?.scrollToContent(element.link, { animate: true });
+ excalidrawAPI?.setViewport({
+ target: element.link,
+ fit: "scale-down",
+ animation: true,
+ });
}
}}
>
diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx
index 622f92fb5e..87bf7fb1ad 100644
--- a/excalidraw-app/collab/Collab.tsx
+++ b/excalidraw-app/collab/Collab.tsx
@@ -652,8 +652,7 @@ class Collab extends PureComponent {
appState: zoomToFitBounds({
appState,
bounds: sceneBounds,
- fitToViewport: true,
- viewportZoomFactor: 1,
+ fit: "contain",
}).appState,
});
diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts
index a30b11f2de..7c59964258 100644
--- a/packages/element/src/bounds.ts
+++ b/packages/element/src/bounds.ts
@@ -9,7 +9,6 @@ import {
import {
degreesToRadians,
lineSegment,
- pointDistance,
pointFrom,
pointFromArray,
pointRotateRads,
@@ -1114,33 +1113,6 @@ export const getElementPointsCoords = (
];
};
-export const getClosestElementBounds = (
- elements: readonly ExcalidrawElement[],
- from: { x: number; y: number },
-): Bounds => {
- if (!elements.length) {
- return [0, 0, 0, 0];
- }
-
- let minDistance = Infinity;
- let closestElement = elements[0];
- const elementsMap = arrayToMap(elements);
- elements.forEach((element) => {
- const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
- const distance = pointDistance(
- pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
- pointFrom(from.x, from.y),
- );
-
- if (distance < minDistance) {
- minDistance = distance;
- closestElement = element;
- }
- });
-
- return getElementBounds(closestElement, elementsMap);
-};
-
export interface BoundingBox {
minX: number;
minY: number;
diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md
index 04dc260da1..a03771714d 100644
--- a/packages/excalidraw/CHANGELOG.md
+++ b/packages/excalidraw/CHANGELOG.md
@@ -15,6 +15,42 @@ Please add the latest change on the top under the correct section.
## Excalidraw API
+### Viewport control & scroll/zoom locking
+
+`ExcalidrawAPI.scrollToContent` was rebuilt into `ExcalidrawAPI.setViewport()` — a single API for navigating the viewport (`fit: "scale-down" | "contain" | "none"`), locking scroll/zoom to a target (with rubberband overscroll), fitting around the rendered editor UI, and initializing the viewport on scene load [#11554](https://github.com/excalidraw/excalidraw/pull/11554).
+
+#### Breaking changes
+
+- `ExcalidrawAPI.scrollToContent(target?, opts?)` was replaced with `ExcalidrawAPI.setViewport(opts | null)`.
+
+ ```tsx
+ // before
+ excalidrawAPI.scrollToContent(element, {
+ fitToViewport: true,
+ animate: true,
+ });
+ // after
+ excalidrawAPI.setViewport({ target: element, fit: "contain" });
+ ```
+
+ - `target` is required and no longer defaults to the whole scene — pass `target: excalidrawAPI.getSceneElements()` for the old default. Besides element(s) or an element id / element-link URL, it now also accepts `Bounds` and `{ x, y, width?, height? }` rects (missing dimensions default to the viewport size). Deleted and non-scene elements in the target are filtered out with a console warning.
+ - `fitToContent` / `fitToViewport` were replaced by `fit: "scale-down" | "contain"` respectively, defaulting to `"scale-down"` (zoomed so the target fits, at most 100%). For the old default behavior of recentering on the target at the current zoom, pass `fit: "none"`.
+ - `animate` / `duration` were replaced by `animation: boolean | { duration?: number }`. Navigation now animates by default over 250ms (previously it jumped unless `animate: true`, and animated over 500ms) — pass `animation: false` to jump instantly.
+ - `canvasOffsets` was replaced by `offsets`, which also accepts UI-derived offsets (see Features below).
+ - `viewportZoomFactor`, `minZoom`, and `maxZoom` are no longer supported.
+
+- `zoomToFitBounds` takes `fit: "scale-down" | "contain" | "none"` instead of `fitToViewport` / `viewportZoomFactor`, and no longer rounds the resulting zoom down to the nearest 10% step by default — pass `steppedZoom: true` to keep the old rounding.
+
+#### Features
+
+- `setViewport({ ..., lock })` locks the viewport to the resolved target: `lock.scroll` constrains panning to the target box and `lock.zoom` makes the resolved zoom the minimum zoom (zooming in stays allowed). When the navigation is animated, the lock is installed once the viewport settles. `setViewport(null)` clears an active lock without navigating; a navigation without `lock` also supersedes a previously installed one.
+
+- `lock.overscroll` adds a rubberband to the lock: panning can overshoot the constraint by the given amount (viewport px, zoom-independent) and snaps back once the interaction settles — on touch, once the multi-finger gesture ends. Defaults to `true` = `DEFAULT_OVERSCROLL` (150px, exported from the package root); pass a number to customize, or `false` / `0` for a rigid lock. Zooming is always hard-clamped against the lock, gliding along its edge instead of overscrolling.
+
+- Added `` for initializing the viewport (and optionally installing a lock) once on scene load. Accepts the same options as `setViewport` except `animation`, and takes precedence over `initialData.scrollToContent`.
+
+- The `offsets` option (on `setViewport` and `initialState.viewport`) insets the usable viewport per side so targets aren't fitted underneath overlaid UI. It combines static pixel sides with `ui: true | ViewportOffsetsOptions`, which measures the currently rendered editor UI (toolbar, styles panel, sidebar…) — with configurable padding, per-side overrides, and reserving space for currently-hidden surfaces (`reserve: { stylesPanel?: boolean; sidebar?: boolean }`). The same measurement is exposed through the new `ExcalidrawAPI.getViewportOffsets(opts?)`, and custom UI rendered inside the editor container can opt into being measured via the `data-viewport-ui="top" | "bottom" | "side"` attribute.
+
### Breaking changes
- Theme changes initiated by the default UI are now delegated to ` ...} />` when supplied. If `onThemeChange` is not supplied, light/dark theme toggling still falls back to updating the internal editor state.
@@ -25,11 +61,11 @@ Please add the latest change on the top under the correct section.
- `UIOptions.canvasActions.toggleTheme` still controls default theme UI availability. When it is `null`, it defaults to `true` if `props.theme` is omitted or `props.onThemeChange` is supplied, and otherwise defaults to disabled.
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
- - `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
+ - `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUnmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
### Features
-- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
+- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on unmount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting.
@@ -43,7 +79,10 @@ Please add the latest change on the top under the correct section.
});
api.onEvent("editor:initialize").then((readyApi) => {
- readyApi.scrollToContent();
+ readyApi.setViewport({
+ target: readyApi.getSceneElements(),
+ animation: false,
+ });
});
}}
/>
@@ -53,7 +92,7 @@ Please add the latest change on the top under the correct section.
- Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`.
-- Exported ``, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
+- Exported ``, `useExcalidrawAPI()`, `useExcalidrawStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
```tsx
@@ -62,12 +101,12 @@ Please add the latest change on the top under the correct section.
;
function Logger() {
- // initially null before the ExcalidrawAPIProvider initializes ater
+ // initially null before the ExcalidrawAPIProvider initializes after
// renders
// When unmounts, is reset back to null
const api = useExcalidrawAPI();
- useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
+ useExcalidrawStateValue("viewModeEnabled", (viewModeEnabled) => {
console.log("view mode changed:", viewModeEnabled);
});
@@ -101,6 +140,10 @@ Please add the latest change on the top under the correct section.
## Excalidraw Library
+### Breaking changes
+
+- `getClosestElementBounds` is no longer exported from `@excalidraw/element`. Use `getCommonBounds` / `getElementBounds` from `@excalidraw/element`, or reimplement the closest-target distance check with `getElementBounds` if you need the same behavior [#11554](https://github.com/excalidraw/excalidraw/pull/11554).
+
## 0.18.0 (2025-03-11)
### Highlights
diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx
index cb88ce7580..e98274060c 100644
--- a/packages/excalidraw/actions/actionCanvas.tsx
+++ b/packages/excalidraw/actions/actionCanvas.tsx
@@ -1,5 +1,3 @@
-import { clamp, roundToStep } from "@excalidraw/math";
-
import {
DEFAULT_CANVAS_BACKGROUND_PICKS,
CURSOR_TYPE,
@@ -14,12 +12,10 @@ import {
import { getNonDeletedElements } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
-import { getCommonBounds, type SceneBounds } from "@excalidraw/element";
+import { getCommonBounds } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
-import type { ExcalidrawElement } from "@excalidraw/element/types";
-
import {
getDefaultAppState,
isEraserActive,
@@ -43,13 +39,13 @@ import { setCursor } from "../cursor";
import { t } from "../i18n";
import { getNormalizedZoom } from "../scene";
-import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
+import { constrainScrollState, zoomToFitBounds } from "../viewport";
import { getShortcutKey } from "../shortcut";
import { register } from "./register";
-import type { AppState, Offsets } from "../types";
+import type { AppState } from "../types";
export const actionChangeViewBackgroundColor = register>({
name: "changeViewBackgroundColor",
@@ -138,19 +134,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,
};
},
@@ -179,19 +176,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,
};
},
@@ -220,19 +218,26 @@ export const actionResetZoom = register({
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
+ // reset to 100%, unless a zoom lock floors the zoom higher — then reset to
+ // the locked minimum zoom (the lock's resting zoom level)
+ const nextZoom = appState.scrollConstraints?.lockZoom
+ ? appState.scrollConstraints.zoom
+ : 1;
+ const nextState = {
+ ...appState,
+ ...getStateForZoom(
+ {
+ viewportX: appState.width / 2 + appState.offsetLeft,
+ viewportY: appState.height / 2 + appState.offsetTop,
+ nextZoom: getNormalizedZoom(nextZoom),
+ },
+ appState,
+ ),
+ userToFollow: null,
+ };
return {
- appState: {
- ...appState,
- ...getStateForZoom(
- {
- viewportX: appState.width / 2 + appState.offsetLeft,
- viewportY: appState.height / 2 + appState.offsetTop,
- nextZoom: getNormalizedZoom(1),
- },
- appState,
- ),
- userToFollow: null,
- },
+ // re-clamp so the reset can't escape an active scroll/zoom lock
+ appState: { ...nextState, ...constrainScrollState(nextState) },
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
@@ -256,138 +261,6 @@ export const actionResetZoom = register({
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
});
-const zoomValueToFitBoundsOnViewport = (
- bounds: SceneBounds,
- viewportDimensions: { width: number; height: number },
- viewportZoomFactor: number = 1, // default to 1 if not provided
-) => {
- const [x1, y1, x2, y2] = bounds;
- const commonBoundsWidth = x2 - x1;
- const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
- const commonBoundsHeight = y2 - y1;
- const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
- const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
-
- const adjustedZoomValue =
- smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
-
- return Math.min(adjustedZoomValue, 1);
-};
-
-export const zoomToFitBounds = ({
- bounds,
- appState,
- canvasOffsets,
- fitToViewport = false,
- viewportZoomFactor = 1,
- minZoom = -Infinity,
- maxZoom = Infinity,
-}: {
- bounds: SceneBounds;
- canvasOffsets?: Offsets;
- appState: Readonly;
- /** whether to fit content to viewport (beyond >100%) */
- fitToViewport: boolean;
- /** zoom content to cover X of the viewport, when fitToViewport=true */
- viewportZoomFactor?: number;
- minZoom?: number;
- maxZoom?: number;
-}) => {
- viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
-
- const [x1, y1, x2, y2] = bounds;
- const centerX = (x1 + x2) / 2;
- const centerY = (y1 + y2) / 2;
-
- const canvasOffsetLeft = canvasOffsets?.left ?? 0;
- const canvasOffsetTop = canvasOffsets?.top ?? 0;
- const canvasOffsetRight = canvasOffsets?.right ?? 0;
- const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
-
- const effectiveCanvasWidth =
- appState.width - canvasOffsetLeft - canvasOffsetRight;
- const effectiveCanvasHeight =
- appState.height - canvasOffsetTop - canvasOffsetBottom;
-
- let adjustedZoomValue;
-
- if (fitToViewport) {
- const commonBoundsWidth = x2 - x1;
- const commonBoundsHeight = y2 - y1;
-
- adjustedZoomValue =
- Math.min(
- effectiveCanvasWidth / commonBoundsWidth,
- effectiveCanvasHeight / commonBoundsHeight,
- ) * viewportZoomFactor;
- } else {
- adjustedZoomValue = zoomValueToFitBoundsOnViewport(
- bounds,
- {
- width: effectiveCanvasWidth,
- height: effectiveCanvasHeight,
- },
- viewportZoomFactor,
- );
- }
-
- const newZoomValue = getNormalizedZoom(
- clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
- );
-
- const centerScroll = centerScrollOn({
- scenePoint: { x: centerX, y: centerY },
- viewportDimensions: {
- width: appState.width,
- height: appState.height,
- },
- offsets: canvasOffsets,
- zoom: { value: newZoomValue },
- });
-
- return {
- appState: {
- ...appState,
- scrollX: centerScroll.scrollX,
- scrollY: centerScroll.scrollY,
- zoom: { value: newZoomValue },
- },
- captureUpdate: CaptureUpdateAction.EVENTUALLY,
- };
-};
-
-export const zoomToFit = ({
- canvasOffsets,
- targetElements,
- appState,
- fitToViewport,
- viewportZoomFactor,
- minZoom,
- maxZoom,
-}: {
- canvasOffsets?: Offsets;
- targetElements: readonly ExcalidrawElement[];
- appState: Readonly;
- /** whether to fit content to viewport (beyond >100%) */
- fitToViewport: boolean;
- /** zoom content to cover X of the viewport, when fitToViewport=true */
- viewportZoomFactor?: number;
- minZoom?: number;
- maxZoom?: number;
-}) => {
- const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
-
- return zoomToFitBounds({
- canvasOffsets,
- bounds: commonBounds,
- appState,
- fitToViewport,
- viewportZoomFactor,
- minZoom,
- maxZoom,
- });
-};
-
// Note, this action differs from actionZoomToFitSelection in that it doesn't
// zoom beyond 100%. In other words, if the content is smaller than viewport
// size, it won't be zoomed in.
@@ -396,21 +269,27 @@ 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({
- targetElements: selectedElements.length ? selectedElements : elements,
+ return zoomToFitBounds({
+ bounds: getCommonBounds(
+ getNonDeletedElements(
+ selectedElements.length ? selectedElements : elements,
+ ),
+ ),
appState: {
...appState,
userToFollow: null,
},
- fitToViewport: false,
- canvasOffsets: app.getEditorUIOffsets(),
+ fit: "scale-down",
+ canvasOffsets: app.getViewportOffsets(),
});
},
// 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 &&
@@ -422,20 +301,26 @@ 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({
- targetElements: selectedElements.length ? selectedElements : elements,
+ return zoomToFitBounds({
+ bounds: getCommonBounds(
+ getNonDeletedElements(
+ selectedElements.length ? selectedElements : elements,
+ ),
+ ),
appState: {
...appState,
userToFollow: null,
},
- fitToViewport: true,
- canvasOffsets: app.getEditorUIOffsets(),
+ fit: "contain",
+ canvasOffsets: app.getViewportOffsets(),
});
},
// 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 &&
@@ -448,17 +333,19 @@ export const actionZoomToFit = register({
icon: zoomAreaIcon,
viewMode: true,
trackEvent: { category: "canvas" },
+ predicate: (elements, appState) => !appState.scrollConstraints,
perform: (elements, appState, _, app) =>
- zoomToFit({
- targetElements: elements,
+ zoomToFitBounds({
+ bounds: getCommonBounds(getNonDeletedElements(elements)),
appState: {
...appState,
userToFollow: null,
},
- fitToViewport: false,
- canvasOffsets: app.getEditorUIOffsets(),
+ fit: "scale-down",
+ canvasOffsets: app.getViewportOffsets(),
}),
- 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 d119214dd4..8af41d744a 100644
--- a/packages/excalidraw/components/App.tsx
+++ b/packages/excalidraw/components/App.tsx
@@ -51,6 +51,7 @@ import {
VERTICAL_ALIGN,
YOUTUBE_STATES,
ZOOM_STEP,
+ MIN_ZOOM,
POINTER_EVENTS,
TOOL_TYPE,
supportsResizeObserver,
@@ -261,7 +262,6 @@ import {
getActiveTextElement,
isEligibleFrameChildType,
getBindingStrategyForDraggingBindingElementEndpoints,
- parseElementLinkFromURL,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -289,6 +289,7 @@ import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
SceneElementsMap,
+ NonDeletedSceneElementsMap,
ExcalidrawBindableElement,
} from "@excalidraw/element/types";
@@ -365,7 +366,7 @@ import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
import {
- calculateScrollCenter,
+ getScrollToContentState,
getElementsWithinSelection,
getNormalizedZoom,
getSelectedElements,
@@ -412,10 +413,15 @@ import {
} from "../snapping";
import { Renderer } from "../scene/Renderer";
import {
- type ScrollToContentOptions,
+ type SetViewportOptions,
SCROLL_TO_CONTENT_ANIMATION_KEY,
- scrollToElements,
-} from "../scroll";
+ setViewportToBounds,
+ resolveViewportTarget,
+ getConstrainedTargetViewport,
+ constrainScrollState,
+ animateToConstraints,
+ isViewportOverscrolled,
+} from "../viewport";
import {
setEraserCursor,
setCursor,
@@ -498,6 +504,10 @@ import type {
GenerateDiagramToCode,
NullableGridSize,
Offsets,
+ ViewportOffsets,
+ ViewportOffsetsOptions,
+ ViewportUIDock,
+ ViewportUIName,
} from "../types";
import type { RoughCanvas } from "roughjs/bin/canvas";
import type { Action, ActionResult } from "../actions/types";
@@ -505,6 +515,18 @@ import type { Action, ActionResult } from "../actions/types";
const AppContext = React.createContext(null!);
const AppPropsContext = React.createContext(null!);
+/** single source of truth for the `--right-sidebar-width` CSS variable */
+const RIGHT_SIDEBAR_WIDTH = 302;
+
+/**
+ * Approximate styles panel footprints (panel width + editor edge inset),
+ * used by `getViewportOffsets` to reserve space for the panel before it was
+ * ever rendered (= measured). Keep roughly in sync with the CSS
+ * (`.App-menu__left` width / `.compact-shape-actions-island` min-width +
+ * `--editor-container-padding`).
+ */
+const STYLES_PANEL_APPROX_WIDTH = { full: 216, compact: 64 };
+
const editorInterfaceContextInitialValue: EditorInterface = {
formFactor: "desktop",
desktopUIMode: "full",
@@ -608,6 +630,10 @@ const YOUTUBE_VIDEO_STATES = new Map<
const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4;
+/** how long after the last pan/zoom we animate the rubberband back into the
+ * scroll constraints box */
+const SCROLL_CONSTRAINTS_SNAP_BACK_DELAY = 200;
+
let IS_PLAIN_PASTE = false;
let IS_PLAIN_PASTE_TIMER = 0;
let PLAIN_PASTE_TOAST_SHOWN = false;
@@ -632,6 +658,16 @@ class App extends React.Component {
editorInterfaceContextInitialValue,
);
+ /**
+ * Last-measured footprints of named viewport UI surfaces
+ * (`data-viewport-ui-name`), so `getViewportOffsets` can reserve space
+ * for them while they're hidden.
+ */
+ private viewportUILastMeasured = new Map<
+ ViewportUIName,
+ { side: "left" | "right"; offset: number }
+ >();
+
private excalidrawContainerRef = React.createRef();
public scene: Scene;
@@ -759,7 +795,8 @@ class App extends React.Component {
history: {
clear: this.resetHistory,
},
- scrollToContent: this.scrollToContent,
+ setViewport: this.setViewport,
+ getViewportOffsets: this.getViewportOffsets,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
getFiles: () => this.files,
@@ -2153,7 +2190,7 @@ class App extends React.Component {
["--ui-pointerEvents" as any]: shouldBlockPointerEvents
? POINTER_EVENTS.disabled
: POINTER_EVENTS.enabled,
- ["--right-sidebar-width" as any]: "302px",
+ ["--right-sidebar-width" as any]: `${RIGHT_SIDEBAR_WIDTH}px`,
}}
ref={this.excalidrawContainerRef}
onDrop={this.handleAppOnDrop}
@@ -2983,16 +3020,44 @@ class App extends React.Component {
toast: this.state.toast,
};
- if (initialData?.scrollToContent) {
+ const viewportAppState = {
+ ...restoredAppState,
+ width: this.state.width,
+ height: this.state.height,
+ offsetTop: this.state.offsetTop,
+ offsetLeft: this.state.offsetLeft,
+ };
+ const initialViewport = this.props.initialState?.viewport;
+
+ if (initialViewport) {
+ const restoredNonDeletedElements = restoredElements.filter(
+ (element) => !element.isDeleted,
+ ) as readonly NonDeletedExcalidrawElement[];
+ const restoredElementsMap = arrayToMap(
+ restoredNonDeletedElements,
+ ) as NonDeletedSceneElementsMap;
+
+ const { bounds } = resolveViewportTarget(
+ initialViewport.target,
+ restoredElementsMap,
+ viewportAppState,
+ );
+
+ if (bounds) {
+ restoredAppState = {
+ ...restoredAppState,
+ ...getConstrainedTargetViewport(viewportAppState, bounds, {
+ ...initialViewport,
+ // resolved here (post-mount) so ui-derived offsets measure the
+ // actually-rendered editor UI
+ offsets: this.resolveViewportOffsets(initialViewport.offsets),
+ }),
+ };
+ }
+ } else if (initialData?.scrollToContent) {
restoredAppState = {
...restoredAppState,
- ...calculateScrollCenter(restoredElements, {
- ...restoredAppState,
- width: this.state.width,
- height: this.state.height,
- offsetTop: this.state.offsetTop,
- offsetLeft: this.state.offsetLeft,
- }),
+ ...getScrollToContentState(restoredElements, viewportAppState),
};
}
@@ -3015,7 +3080,11 @@ class App extends React.Component {
});
if (isElementLink(window.location.href)) {
- this.scrollToContent(window.location.href, { animate: false });
+ this.setViewport({
+ target: window.location.href,
+ fit: "scale-down",
+ animation: false,
+ });
}
};
@@ -3065,6 +3134,10 @@ class App extends React.Component {
const prevStylesPanelMode = this.stylesPanelMode;
this.stylesPanelMode = nextStylesPanelMode;
+ // the panel footprint differs between modes (compact vs full), so a
+ // measurement taken in the previous mode no longer applies
+ this.viewportUILastMeasured.delete("stylesPanel");
+
if (prevStylesPanelMode !== "full" && nextStylesPanelMode === "full") {
this.setState((prevState) => ({
preferredSelectionTool: {
@@ -3921,7 +3994,7 @@ class App extends React.Component {
files: BinaryFiles | null;
position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean;
- fitToContent?: boolean;
+ fit?: SetViewportOptions["fit"];
preserveFrameChildrenOrder?: boolean;
}) => {
const elements = restoreElements(opts.elements, null, {
@@ -4060,10 +4133,12 @@ class App extends React.Component {
);
this.setActiveTool({ type: this.state.preferredSelectionTool.type }, true);
- if (opts.fitToContent) {
- this.scrollToContent(duplicatedElements, {
- fitToContent: true,
- canvasOffsets: this.getEditorUIOffsets(),
+ if (opts.fit) {
+ this.setViewport({
+ target: duplicatedElements,
+ fit: opts.fit,
+ animation: false,
+ offsets: { ui: true },
});
}
};
@@ -4252,7 +4327,20 @@ class App extends React.Component {
this.resetContextMenuTimer();
}
+ const wasMultiTouchGesture = gesture.pointers.size >= 2;
gesture.pointers.delete(event.pointerId);
+
+ // the multi-touch viewport gesture just disengaged: release the
+ // rubberband that was withheld while it was active
+ // (see `snapBackToScrollConstraints`)
+ if (
+ wasMultiTouchGesture &&
+ gesture.pointers.size < 2 &&
+ this.state.scrollConstraints
+ ) {
+ this.snapBackToScrollConstraintsDebounced.cancel();
+ this.snapBackToScrollConstraints();
+ }
};
toggleLock = (source: "keyboard" | "ui" = "ui") => {
@@ -4328,41 +4416,87 @@ class App extends React.Component {
*/
value: number,
) => {
- this.setState({
- ...getStateForZoom(
- {
- viewportX: this.state.width / 2 + this.state.offsetLeft,
- viewportY: this.state.height / 2 + this.state.offsetTop,
- nextZoom: getNormalizedZoom(value),
- },
- this.state,
- ),
+ this.setState((state) => {
+ const nextState = {
+ ...state,
+ ...getStateForZoom(
+ {
+ viewportX: state.width / 2 + state.offsetLeft,
+ viewportY: state.height / 2 + state.offsetTop,
+ nextZoom: getNormalizedZoom(value),
+ },
+ state,
+ ),
+ };
+ // re-clamp so a programmatic zoom can't escape an active scroll/zoom lock
+ return { ...nextState, ...constrainScrollState(nextState) };
});
};
- scrollToContent = (
- target?:
- | string
- | ExcalidrawElement
- | readonly NonDeletedExcalidrawElement[],
- opts?: ScrollToContentOptions,
- ) => {
- let elements: readonly NonDeleted[];
- if (typeof target === "string") {
- const id = isElementLink(target)
- ? parseElementLinkFromURL(target)
- : target;
- elements = id ? this.scene.getElementsFromId(id) : [];
- } else if (Array.isArray(target)) {
- elements = target;
- } else if (target) {
- elements = [target as NonDeleted];
- } else {
- elements = this.scene.getNonDeletedElements();
+ /**
+ * Navigates the viewport to a target and, optionally, locks pan/zoom to it.
+ * The resolved target box drives both the navigation (pan + zoom per
+ * `fit`) and the lock: the operations chain — the viewport animates onto
+ * the target, then the lock is installed against the settled viewport.
+ *
+ * Passing `null` clears any active lock without navigating.
+ */
+ /**
+ * Resolves user-supplied viewport offsets ({@link ViewportOffsets}) into
+ * concrete per-side pixel values: static sides are used as-is, and when
+ * `ui` is set, the remaining sides are derived from the currently
+ * rendered editor UI (via `getViewportOffsets`).
+ *
+ * Must be called at the time the offsets are applied (not e.g. cached at
+ * props-definition time), so the UI-derived values reflect the actual
+ * rendered UI.
+ */
+ private resolveViewportOffsets = (
+ offsets: ViewportOffsets | undefined,
+ ): Offsets | undefined => {
+ if (!offsets) {
+ return offsets;
}
- if (!elements.length) {
- if (typeof target === "string" && isElementLink(target)) {
+ const { ui, ...staticOffsets } = offsets;
+
+ if (!ui) {
+ return staticOffsets;
+ }
+
+ const uiOffsets = this.getViewportOffsets(ui === true ? undefined : ui);
+
+ // static sides win over the ui-derived values (incl. `ui`'s own
+ // side overrides)
+ return {
+ top: staticOffsets.top ?? uiOffsets.top,
+ right: staticOffsets.right ?? uiOffsets.right,
+ bottom: staticOffsets.bottom ?? uiOffsets.bottom,
+ left: staticOffsets.left ?? uiOffsets.left,
+ };
+ };
+
+ setViewport = (opts: SetViewportOptions | null) => {
+ // `null` clears all active locks
+ if (opts === null) {
+ if (this.state.scrollConstraints) {
+ this.setState({ scrollConstraints: null });
+ }
+ return;
+ }
+
+ const { target, fit, lock, animation } = opts;
+ const offsets = this.resolveViewportOffsets(opts.offsets);
+
+ // resolve the target to a scene-coordinate box.
+ const { bounds, type } = resolveViewportTarget(
+ target,
+ this.scene.getNonDeletedElementsMap(),
+ this.state,
+ );
+
+ if (!bounds) {
+ if (type === "link") {
this.setState({
toast: {
message: t("elementLink.notFound"),
@@ -4371,28 +4505,36 @@ class App extends React.Component {
},
});
}
-
return;
}
- // 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.
- const resolvedOpts =
- typeof target === "string"
- ? {
- ...opts,
- fitToViewport: undefined,
- fitToContent: opts?.fitToContent ?? true,
- animate: opts?.animate ?? true,
- }
- : opts;
+ // capture the viewport we'll land on now, so the lock can be installed
+ // against the intended final state even if the last animation frame hasn't
+ // committed yet.
+ const viewportUpdate = getConstrainedTargetViewport(this.state, bounds, {
+ fit,
+ offsets,
+ lock,
+ });
- scrollToElements(
+ // chain: once the scroll/zoom has settled, install (or clear) the lock
+ const installLock = () => {
+ if (!viewportUpdate.scrollConstraints && !this.state.scrollConstraints) {
+ // no lock requested and none to supersede
+ return;
+ }
+
+ flushSync(() => {
+ this.setState(viewportUpdate);
+ });
+ };
+
+ setViewportToBounds(
this.state,
- elements,
+ bounds,
+ { fit, animation, offsets },
this.setState.bind(this),
- resolvedOpts,
+ installLock,
);
};
@@ -4403,15 +4545,79 @@ class App extends React.Component {
};
/** use when changing scrollX/scrollY/zoom based on user interaction */
- private translateCanvas: React.Component["setState"] = (
- state,
+ private translateCanvas = (
+ state:
+ | AppState
+ | Pick
+ | null
+ | ((
+ prevState: Readonly,
+ props: Readonly,
+ ) => AppState | Pick | null),
+ opts?: {
+ /** set when the caller has already hard-clamped the update's zoom
+ * component against the scroll lock — the update then gets the pan
+ * rubberband give instead of the zoom hard clamp (see the touch
+ * pinch handler, which composes both in one update) */
+ zoomPreConstrained?: boolean;
+ },
) => {
AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY);
this.setState({ shouldCacheIgnoreZoom: false });
this.maybeUnfollowRemoteUser();
+
+ const prevZoom = this.state.zoom.value;
this.setState(state);
+
+ // constrain against the scroll lock; queued so it sees the update above
+ this.setState((prevState) => {
+ if (!prevState.scrollConstraints) {
+ return null;
+ }
+ // zoom changes are hard-clamped (no rubberband give): the post-zoom
+ // scroll is a continuous function of the zoom origin, so clamping it
+ // effectively slides the origin to the nearest focal point that keeps
+ // the viewport within bounds — zooming glides along the lock edge
+ // instead of overscrolling and snapping back on every zoom tick.
+ // Panning keeps the soft give, snapping back once interaction settles.
+ const zoomed =
+ !opts?.zoomPreConstrained && prevState.zoom.value !== prevZoom;
+ const overscroll = zoomed ? 0 : prevState.scrollConstraints.overscroll;
+ if (overscroll > 0) {
+ this.snapBackToScrollConstraintsDebounced();
+ }
+ return constrainScrollState(prevState, overscroll);
+ });
};
+ /** clamps scroll/zoom back into `appState.scrollConstraints` (no-op when
+ * unconstrained). Runs as a queued update, so it sees the preceding change.
+ * `overscroll` (screen px) relaxes the bounds for rubberbanding. */
+ private constrainViewportToScrollConstraints = (overscroll = 0) => {
+ this.setState((prevState) =>
+ prevState.scrollConstraints
+ ? constrainScrollState(prevState, overscroll)
+ : null,
+ );
+ };
+
+ /** animates an overscrolled viewport back inside the constraint box */
+ private snapBackToScrollConstraints = () => {
+ // withhold the rubberband while a multi-touch gesture is still engaged —
+ // snapping back mid-gesture (e.g. while holding a pinch or between
+ // two-finger pans) fights the user's fingers and reads as jerky. It is
+ // released on gesture end instead (see `removePointer`).
+ if (this.unmounted || gesture.pointers.size >= 2) {
+ return;
+ }
+ animateToConstraints(this.state, (viewport) => this.setState(viewport));
+ };
+
+ private snapBackToScrollConstraintsDebounced = debounce(
+ this.snapBackToScrollConstraints,
+ SCROLL_CONSTRAINTS_SNAP_BACK_DELAY,
+ );
+
setToast = (toast: AppState["toast"]) => {
this.setState({ toast });
};
@@ -4623,43 +4829,137 @@ class App extends React.Component {
},
);
- public getEditorUIOffsets = (): Offsets => {
- const toolbarBottom =
- this.excalidrawContainerRef?.current
- ?.querySelector(".App-toolbar")
- ?.getBoundingClientRect()?.bottom ?? 0;
- const sidebarRect = this.excalidrawContainerRef?.current
- ?.querySelector(".sidebar")
- ?.getBoundingClientRect();
- const propertiesPanelRect = this.excalidrawContainerRef?.current
- ?.querySelector(".App-menu__left")
- ?.getBoundingClientRect();
+ /**
+ * top/right/bottom/left override the final offsets for those sides.
+ *
+ * Default side offsets are measured from the currently rendered UI
+ * surfaces marked with the `data-viewport-ui` attribute (see
+ * {@link ViewportUIDock}), plus padding.
+ *
+ * See {@link ViewportOffsetsOptions} for the individual options
+ * (padding, per-side overrides, reserving space for hidden surfaces).
+ */
+ public getViewportOffsets = (opts?: ViewportOffsetsOptions): Offsets => {
+ const excalidrawContainer = this.excalidrawContainerRef?.current;
+ const excalidrawContainerRect =
+ excalidrawContainer?.getBoundingClientRect();
+ const isRTL = getLanguage().rtl;
- const PADDING = 16;
+ const measuredOffsets = { top: 0, right: 0, bottom: 0, left: 0 };
+ const renderedSurfaces = new Set();
- return getLanguage().rtl
- ? {
- top: toolbarBottom + PADDING,
- right:
- Math.max(
- this.state.width -
- (propertiesPanelRect?.left ?? this.state.width),
- 0,
- ) + PADDING,
- bottom: PADDING,
- left: Math.max(sidebarRect?.right ?? 0, 0) + PADDING,
- }
- : {
- top: toolbarBottom + PADDING,
- right: Math.max(
- this.state.width -
- (sidebarRect?.left ?? this.state.width) +
- PADDING,
- 0,
- ),
- bottom: PADDING,
- left: Math.max(propertiesPanelRect?.right ?? 0, 0) + PADDING,
+ if (excalidrawContainer && excalidrawContainerRect) {
+ for (const node of excalidrawContainer.querySelectorAll(
+ "[data-viewport-ui]",
+ )) {
+ const domRect = node.getBoundingClientRect();
+ // measured relative to the excalidraw container (which the offsets
+ // are relative to), so that embedding the editor at a viewport
+ // offset doesn't skew the values
+ const rect = {
+ top: domRect.top - excalidrawContainerRect.top,
+ right: domRect.right - excalidrawContainerRect.left,
+ bottom: domRect.bottom - excalidrawContainerRect.top,
+ left: domRect.left - excalidrawContainerRect.left,
+ width: domRect.width,
};
+
+ // offsets start at 0, so surfaces translated off-canvas (e.g.
+ // zen-mode transitions) never contribute negative values
+ switch (node.dataset.viewportUi as ViewportUIDock) {
+ case "top":
+ measuredOffsets.top = Math.max(measuredOffsets.top, rect.bottom);
+ break;
+ case "bottom":
+ measuredOffsets.bottom = Math.max(
+ measuredOffsets.bottom,
+ this.state.height - rect.top,
+ );
+ break;
+ case "side": {
+ // which side a panel docks to is resolved from its rendered
+ // position (RTL / host-configured docking flip sides), by
+ // checking which half of the viewport its center sits in;
+ // the offset is then its intrusion depth from that edge
+ const [side, offset] =
+ rect.left + rect.width / 2 < this.state.width / 2
+ ? (["left", rect.right] as const)
+ : (["right", this.state.width - rect.left] as const);
+
+ measuredOffsets[side] = Math.max(measuredOffsets[side], offset);
+
+ const name = node.dataset.viewportUiName as
+ | ViewportUIName
+ | undefined;
+ if (name) {
+ renderedSurfaces.add(name);
+ // don't cache degenerate measurements (surface translated
+ // off-canvas, e.g. during zen-mode transitions)
+ if (offset > 0) {
+ this.viewportUILastMeasured.set(name, { side, offset });
+ }
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ // reserve space for requested surfaces that aren't currently rendered,
+ // using their last-measured footprint (or an approximate default if
+ // they weren't rendered yet); no-op for currently-rendered surfaces
+ // (already measured above) and on phones
+ if (opts?.reserve && this.editorInterface.formFactor !== "phone") {
+ const reserveSurface = (
+ name: ViewportUIName,
+ fallback: { side: "left" | "right"; offset: number },
+ ) => {
+ if (renderedSurfaces.has(name)) {
+ return;
+ }
+ const { side, offset } =
+ this.viewportUILastMeasured.get(name) ?? fallback;
+ measuredOffsets[side] = Math.max(measuredOffsets[side], offset);
+ };
+
+ if (opts.reserve.stylesPanel) {
+ reserveSurface("stylesPanel", {
+ side: isRTL ? "right" : "left",
+ offset:
+ this.stylesPanelMode === "compact"
+ ? STYLES_PANEL_APPROX_WIDTH.compact
+ : STYLES_PANEL_APPROX_WIDTH.full,
+ });
+ }
+ if (opts.reserve.sidebar) {
+ reserveSurface("sidebar", {
+ side: isRTL ? "left" : "right",
+ offset: RIGHT_SIDEBAR_WIDTH,
+ });
+ }
+ }
+
+ const padding = opts?.padding ?? 24;
+ const topPadding = opts?.paddingTop ?? padding;
+ const rightPadding =
+ (isRTL ? opts?.paddingLeft : opts?.paddingRight) ?? padding;
+ const bottomPadding = opts?.paddingBottom ?? padding;
+ const leftPadding =
+ (isRTL ? opts?.paddingRight : opts?.paddingLeft) ?? padding;
+
+ const editorOffsets = {
+ top: measuredOffsets.top + topPadding,
+ right: measuredOffsets.right + rightPadding,
+ bottom: measuredOffsets.bottom + bottomPadding,
+ left: measuredOffsets.left + leftPadding,
+ };
+
+ return {
+ top: opts?.top ?? editorOffsets.top,
+ right: (isRTL ? opts?.left : opts?.right) ?? editorOffsets.right,
+ bottom: opts?.bottom ?? editorOffsets.bottom,
+ left: (isRTL ? opts?.right : opts?.left) ?? editorOffsets.left,
+ };
};
// Input handling
@@ -4792,14 +5092,14 @@ class App extends React.Component {
zoom: this.state.zoom,
},
this.scene.getNonDeletedElementsMap(),
- this.getEditorUIOffsets(),
+ this.getViewportOffsets(),
)
) {
- this.scrollToContent(this.flowChartCreator.pendingNodes, {
- animate: true,
- duration: 300,
- fitToContent: true,
- canvasOffsets: this.getEditorUIOffsets(),
+ this.setViewport({
+ target: getCommonBounds(this.flowChartCreator.pendingNodes),
+ fit: "scale-down",
+ animation: { duration: 300 },
+ offsets: { ui: true },
});
}
@@ -4849,13 +5149,14 @@ class App extends React.Component {
zoom: this.state.zoom,
},
this.scene.getNonDeletedElementsMap(),
- this.getEditorUIOffsets(),
+ this.getViewportOffsets(),
)
) {
- this.scrollToContent(nextNode, {
- animate: true,
- duration: 300,
- canvasOffsets: this.getEditorUIOffsets(),
+ this.setViewport({
+ target: nextNode,
+ fit: "scale-down",
+ animation: { duration: 300 },
+ offsets: { ui: true },
});
}
}
@@ -5416,13 +5717,14 @@ class App extends React.Component {
zoom: this.state.zoom,
},
this.scene.getNonDeletedElementsMap(),
- this.getEditorUIOffsets(),
+ this.getViewportOffsets(),
)
) {
- this.scrollToContent(firstNode, {
- animate: true,
- duration: 300,
- canvasOffsets: this.getEditorUIOffsets(),
+ this.setViewport({
+ target: firstNode,
+ fit: "scale-down",
+ animation: { duration: 300 },
+ offsets: { ui: true },
});
}
}
@@ -5590,9 +5892,15 @@ class App extends React.Component {
return;
}
+ // while rubberband-overscrolled past the scroll constraints, suppress
+ // zooming until the viewport has snapped back inside the box
+ if (isViewportOverscrolled(this.state)) {
+ return;
+ }
+
const initialScale = gesture.initialScale;
if (initialScale) {
- this.setState((state) => ({
+ this.translateCanvas((state) => ({
...getStateForZoom(
{
viewportX: this.lastViewportPosition.x,
@@ -6845,6 +7153,17 @@ class App extends React.Component {
: this.state.zoom.value;
this.setState((state) => {
+ // constrain the zoom and pan components separately: the zoom step is
+ // hard-clamped against the scroll lock (sliding the focal point along
+ // the lock edge), while any pre-existing overscroll plus this frame's
+ // pan delta are re-applied on top and rubberband-clamped by
+ // `translateCanvas` — so pinch-zooming and overscroll-panning compose
+ // instead of the zoom yanking the viewport back inside the box.
+ const rest = constrainScrollState(state); // hard clamp (no give)
+ // pre-existing overscroll, in screen px (zoom-independent)
+ const overscrollX = (state.scrollX - rest.scrollX) * state.zoom.value;
+ const overscrollY = (state.scrollY - rest.scrollY) * state.zoom.value;
+
const zoomState = getStateForZoom(
{
viewportX: center.x,
@@ -6853,16 +7172,23 @@ class App extends React.Component {
},
state,
);
+ const zoomedViewport = constrainScrollState({ ...state, ...zoomState });
+ const zoomValue = zoomedViewport.zoom.value;
- this.translateCanvas({
- zoom: zoomState.zoom,
- // 2x multiplier is just a magic number that makes this work correctly
- // on touchscreen devices (note: if we get report that panning is slower/faster
- // than actual movement, consider swapping with devicePixelRatio)
- scrollX: zoomState.scrollX + 2 * (deltaX / nextZoom),
- scrollY: zoomState.scrollY + 2 * (deltaY / nextZoom),
- shouldCacheIgnoreZoom: true,
- });
+ this.translateCanvas(
+ {
+ zoom: zoomedViewport.zoom,
+ // 2x multiplier is just a magic number that makes this work correctly
+ // on touchscreen devices (note: if we get report that panning is slower/faster
+ // than actual movement, consider swapping with devicePixelRatio)
+ scrollX:
+ zoomedViewport.scrollX + (overscrollX + 2 * deltaX) / zoomValue,
+ scrollY:
+ zoomedViewport.scrollY + (overscrollY + 2 * deltaY) / zoomValue,
+ shouldCacheIgnoreZoom: true,
+ },
+ { zoomPreConstrained: true },
+ );
return null;
});
@@ -12734,6 +13060,12 @@ class App extends React.Component {
const { deltaX, deltaY } = event;
// note that event.ctrlKey is necessary to handle pinch zooming
if (event.metaKey || event.ctrlKey) {
+ // while rubberband-overscrolled past the scroll constraints, suppress
+ // zooming until the viewport has snapped back inside the box
+ if (isViewportOverscrolled(this.state)) {
+ return;
+ }
+
const sign = Math.sign(deltaY);
const MAX_STEP = ZOOM_STEP * 100;
const absDelta = Math.abs(deltaY);
@@ -12750,6 +13082,11 @@ class App extends React.Component {
// reduced amplification for small deltas (small movements on a trackpad)
Math.min(1, absDelta / 20);
+ const minZoom = this.state.scrollConstraints?.lockZoom
+ ? this.state.scrollConstraints.zoom
+ : MIN_ZOOM;
+ newZoom = Math.max(newZoom, minZoom);
+
this.translateCanvas((state) => ({
...getStateForZoom(
{
@@ -12887,6 +13224,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/components/Island.tsx b/packages/excalidraw/components/Island.tsx
index 8219e5fc73..0299ea4973 100644
--- a/packages/excalidraw/components/Island.tsx
+++ b/packages/excalidraw/components/Island.tsx
@@ -3,18 +3,38 @@ import clsx from "clsx";
import "./Island.scss";
+import type { ViewportUIDock, ViewportUIName } from "../types";
+
type IslandProps = {
children: React.ReactNode;
padding?: number;
className?: string | boolean;
style?: object;
+ /** marks the island as a canvas-occluding UI surface measured by
+ * `getViewportOffsets` (see {@link ViewportUIDock}) */
+ "data-viewport-ui"?: ViewportUIDock;
+ /** identifies the surface so `getViewportOffsets` can reserve space for
+ * it while hidden (see {@link ViewportUIName}) */
+ "data-viewport-ui-name"?: ViewportUIName;
};
export const Island = React.forwardRef(
- ({ children, padding, className, style }, ref) => (
+ (
+ {
+ children,
+ padding,
+ className,
+ style,
+ "data-viewport-ui": viewportUI,
+ "data-viewport-ui-name": viewportUIName,
+ },
+ ref,
+ ) => (
{children}
diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx
index 0b61910007..355d39c088 100644
--- a/packages/excalidraw/components/LayerUI.tsx
+++ b/packages/excalidraw/components/LayerUI.tsx
@@ -25,7 +25,7 @@ import { UIAppStateContext } from "../context/ui-appState";
import { useAtom, useAtomValue } from "../editor-jotai";
import { t } from "../i18n";
-import { calculateScrollCenter } from "../scene";
+import { getScrollToContentState } from "../scene";
import {
SelectedShapeActions,
@@ -246,6 +246,8 @@ const LayerUI = ({
{
setAppState((appState) => ({
- ...calculateScrollCenter(elements, appState),
+ ...getScrollToContentState(elements, appState),
}));
}}
>
diff --git a/packages/excalidraw/components/MobileMenu.tsx b/packages/excalidraw/components/MobileMenu.tsx
index ed83263c3c..1d5482d5d1 100644
--- a/packages/excalidraw/components/MobileMenu.tsx
+++ b/packages/excalidraw/components/MobileMenu.tsx
@@ -4,7 +4,7 @@ import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { useTunnels } from "../context/tunnels";
import { t } from "../i18n";
-import { calculateScrollCenter } from "../scene";
+import { getScrollToContentState } from "../scene";
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { ExitViewModeButton, MobileShapeActions } from "./Actions";
@@ -140,6 +140,7 @@ export const MobileMenu = ({
style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN,
}}
+ data-viewport-ui="bottom"
>
{
setAppState((appState) => ({
- ...calculateScrollCenter(elements, appState),
+ ...getScrollToContentState(elements, appState),
}));
}}
>
diff --git a/packages/excalidraw/components/SearchMenu.tsx b/packages/excalidraw/components/SearchMenu.tsx
index 7160d52211..e2b3c891f8 100644
--- a/packages/excalidraw/components/SearchMenu.tsx
+++ b/packages/excalidraw/components/SearchMenu.tsx
@@ -1,4 +1,3 @@
-import { round } from "@excalidraw/math";
import clsx from "clsx";
import debounce from "lodash.debounce";
import { Fragment, memo, useEffect, useMemo, useRef, useState } from "react";
@@ -11,7 +10,10 @@ import {
getLineHeight,
} from "@excalidraw/common";
-import { isElementCompletelyInViewport } from "@excalidraw/element";
+import {
+ getCommonBounds,
+ isElementCompletelyInViewport,
+} from "@excalidraw/element";
import { measureText } from "@excalidraw/element";
@@ -229,31 +231,22 @@ export const SearchMenu = () => {
zoom: app.state.zoom,
},
app.scene.getNonDeletedElementsMap(),
- app.getEditorUIOffsets(),
+ app.getViewportOffsets(),
) ||
isTextTiny
) {
- let zoomOptions: Parameters[1];
+ // tiny, illegible text fills the viewport so it becomes readable;
+ // otherwise just fit the match into view (capped at 100%)
+ const behavior =
+ isTextTiny && fontSize < FONT_SIZE_LEGIBILITY_THRESHOLD
+ ? "contain"
+ : "scale-down";
- if (isTextTiny) {
- if (fontSize >= FONT_SIZE_LEGIBILITY_THRESHOLD) {
- zoomOptions = { fitToContent: true };
- } else {
- zoomOptions = {
- fitToViewport: true,
- // calculate zoom level to make the fontSize ~equal to FONT_SIZE_THRESHOLD, rounded to nearest 10%
- maxZoom: round(FONT_SIZE_LEGIBILITY_THRESHOLD / fontSize, 1),
- };
- }
- } else {
- zoomOptions = { fitToContent: true };
- }
-
- app.scrollToContent(matchAsElement, {
- animate: true,
- duration: 300,
- ...zoomOptions,
- canvasOffsets: app.getEditorUIOffsets(),
+ app.setViewport({
+ target: getCommonBounds([matchAsElement]),
+ fit: behavior,
+ animation: { duration: 300 },
+ offsets: { ui: true },
});
}
}
diff --git a/packages/excalidraw/components/Sidebar/Sidebar.tsx b/packages/excalidraw/components/Sidebar/Sidebar.tsx
index 8226eacef5..2cc83c30b5 100644
--- a/packages/excalidraw/components/Sidebar/Sidebar.tsx
+++ b/packages/excalidraw/components/Sidebar/Sidebar.tsx
@@ -148,6 +148,14 @@ export const SidebarInner = forwardRef(
{ "sidebar--docked": docked },
className,
)}
+ // on mobile the sidebar shouldn't push the viewport around even
+ // when opened (it's treated as a temporary overlay)
+ data-viewport-ui={
+ editorInterface.formFactor !== "phone" ? "side" : undefined
+ }
+ data-viewport-ui-name={
+ editorInterface.formFactor !== "phone" ? "sidebar" : undefined
+ }
ref={islandRef}
>
diff --git a/packages/excalidraw/components/TTDDialog/common.ts b/packages/excalidraw/components/TTDDialog/common.ts
index e02bafc1f6..6f92f99702 100644
--- a/packages/excalidraw/components/TTDDialog/common.ts
+++ b/packages/excalidraw/components/TTDDialog/common.ts
@@ -160,7 +160,7 @@ export const insertToEditor = ({
elements: newElements,
files,
position: "center",
- fitToContent: true,
+ fit: "scale-down",
});
app.setOpenDialog(null);
diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts
index 9cb77a3560..05fb47b7bb 100644
--- a/packages/excalidraw/data/blob.ts
+++ b/packages/excalidraw/data/blob.ts
@@ -13,7 +13,7 @@ import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
import { cleanAppStateForExport } from "../appState";
import { CanvasError, ImageSceneDataError } from "../errors";
-import { calculateScrollCenter } from "../scene";
+import { getScrollToContentState } from "../scene";
import { decodeSvgBase64Payload } from "../scene/export";
import { base64ToString, stringToBase64, toByteString } from "./encode";
@@ -171,7 +171,7 @@ export const loadSceneOrLibraryFromBlob = async (
fileHandle: fileHandle || blob.handle || null,
...cleanAppStateForExport(data.appState || {}),
...(localAppState
- ? calculateScrollCenter(data.elements || [], localAppState)
+ ? getScrollToContentState(data.elements || [], localAppState)
: {}),
},
localAppState,
diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx
index bd9f769c45..0ebf08cdeb 100644
--- a/packages/excalidraw/index.tsx
+++ b/packages/excalidraw/index.tsx
@@ -71,6 +71,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onThemeChange,
onIncrement,
initialData,
+ initialState,
onExcalidrawAPI,
onMount,
onUnmount,
@@ -190,6 +191,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onThemeChange={onThemeChange}
onIncrement={onIncrement}
initialData={initialData}
+ initialState={initialState}
onExcalidrawAPI={handleExcalidrawAPI}
onMount={onMount}
onUnmount={onUnmount}
@@ -394,7 +396,8 @@ export type {
SavedChats,
} from "./components/TTDDialog/types";
-export { zoomToFitBounds } from "./actions/actionCanvas";
+export { zoomToFitBounds, DEFAULT_OVERSCROLL } from "./viewport";
+
export {
getCommonBounds,
getVisibleSceneBounds,
diff --git a/packages/excalidraw/scene/index.ts b/packages/excalidraw/scene/index.ts
index ba6142c875..75ee9933a2 100644
--- a/packages/excalidraw/scene/index.ts
+++ b/packages/excalidraw/scene/index.ts
@@ -4,7 +4,7 @@ export {
getSelectedElements,
getTargetElements,
} from "@excalidraw/element";
-export { calculateScrollCenter } from "./scroll";
+export { getScrollToContentState } from "../viewport";
export {
hasBackground,
hasStrokeWidth,
diff --git a/packages/excalidraw/scene/scroll.ts b/packages/excalidraw/scene/scroll.ts
deleted file mode 100644
index f05a3c0a48..0000000000
--- a/packages/excalidraw/scene/scroll.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { getVisibleElements } from "@excalidraw/element";
-import {
- sceneCoordsToViewportCoords,
- viewportCoordsToSceneCoords,
-} from "@excalidraw/common";
-
-import { getClosestElementBounds } from "@excalidraw/element";
-
-import { getCommonBounds } from "@excalidraw/element";
-
-import type { ExcalidrawElement } from "@excalidraw/element/types";
-
-import type { AppState, Offsets, PointerCoords, Zoom } from "../types";
-
-const isOutsideViewPort = (appState: AppState, cords: Array) => {
- const [x1, y1, x2, y2] = cords;
- const { x: viewportX1, y: viewportY1 } = sceneCoordsToViewportCoords(
- { sceneX: x1, sceneY: y1 },
- appState,
- );
- const { x: viewportX2, y: viewportY2 } = sceneCoordsToViewportCoords(
- { sceneX: x2, sceneY: y2 },
- appState,
- );
- return (
- viewportX2 - viewportX1 > appState.width ||
- viewportY2 - viewportY1 > appState.height
- );
-};
-
-export const centerScrollOn = ({
- scenePoint,
- viewportDimensions,
- zoom,
- offsets,
-}: {
- scenePoint: PointerCoords;
- viewportDimensions: { height: number; width: number };
- zoom: Zoom;
- offsets?: Offsets;
-}) => {
- let scrollX =
- (viewportDimensions.width - (offsets?.right ?? 0)) / 2 / zoom.value -
- scenePoint.x;
-
- scrollX += (offsets?.left ?? 0) / 2 / zoom.value;
-
- let scrollY =
- (viewportDimensions.height - (offsets?.bottom ?? 0)) / 2 / zoom.value -
- scenePoint.y;
-
- scrollY += (offsets?.top ?? 0) / 2 / zoom.value;
-
- return {
- scrollX,
- scrollY,
- };
-};
-
-export const calculateScrollCenter = (
- elements: readonly ExcalidrawElement[],
- appState: AppState,
-): { scrollX: number; scrollY: number } => {
- elements = getVisibleElements(elements);
-
- if (!elements.length) {
- return {
- scrollX: 0,
- scrollY: 0,
- };
- }
- let [x1, y1, x2, y2] = getCommonBounds(elements);
-
- if (isOutsideViewPort(appState, [x1, y1, x2, y2])) {
- [x1, y1, x2, y2] = getClosestElementBounds(
- elements,
- viewportCoordsToSceneCoords(
- { clientX: appState.scrollX, clientY: appState.scrollY },
- appState,
- ),
- );
- }
-
- const centerX = (x1 + x2) / 2;
- const centerY = (y1 + y2) / 2;
-
- return centerScrollOn({
- scenePoint: { x: centerX, y: centerY },
- viewportDimensions: { width: appState.width, height: appState.height },
- zoom: appState.zoom,
- });
-};
diff --git a/packages/excalidraw/scroll.ts b/packages/excalidraw/scroll.ts
deleted file mode 100644
index 8ef5d218e7..0000000000
--- a/packages/excalidraw/scroll.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import { easeOut } 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 { calculateScrollCenter } from "./scene/scroll";
-
-import type { AppState, NormalizedZoomValue, Offsets } from "./types";
-
-export const SCROLL_TO_CONTENT_ANIMATION_KEY = "animateScrollToContent";
-
-/** default duration of the scroll/zoom animation, in milliseconds */
-const DEFAULT_ANIMATION_DURATION = 500;
-
-export type ScrollToContentOptions = (
- | {
- fitToContent?: boolean;
- fitToViewport?: never;
- viewportZoomFactor?: number;
- animate?: boolean;
- duration?: number;
- }
- | {
- fitToContent?: never;
- fitToViewport?: boolean;
- /** when fitToViewport=true, how much screen should the content cover,
- * between 0.1 (10%) and 1 (100%) */
- viewportZoomFactor?: number;
- animate?: boolean;
- duration?: number;
- }
-) & {
- minZoom?: number;
- maxZoom?: number;
- canvasOffsets?: Offsets;
-};
-
-type Viewport = Pick;
-
-/**
- * Scrolls (and optionally zooms) the viewport so that the given target is in
- * view, optionally animating the transition.
- */
-export const scrollToElements = (
- state: AppState,
- target: readonly ExcalidrawElement[],
- onFrame: (
- state: Pick<
- AppState,
- "scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
- >,
- ) => void,
- opts?: ScrollToContentOptions,
-) => {
- AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY);
-
- const viewport = getTargetViewport(state, target, opts);
-
- if (opts?.animate) {
- animateToViewport(
- state,
- viewport,
- opts.duration ?? DEFAULT_ANIMATION_DURATION,
- onFrame,
- );
- } else {
- // no animation: jump straight to the target. Re-enable zoom caching in
- // case we just cancelled an in-flight animation that had suppressed it.
- onFrame({ ...viewport, shouldCacheIgnoreZoom: false });
- }
-};
-
-/** Computes the viewport (scroll + zoom) that brings the target elements into
- * view, based on the requested fit behavior. */
-const getTargetViewport = (
- state: AppState,
- targetElements: readonly ExcalidrawElement[],
- opts?: ScrollToContentOptions,
-): Viewport => {
- if (opts?.fitToContent || opts?.fitToViewport) {
- const { appState } = zoomToFit({
- canvasOffsets: opts.canvasOffsets,
- targetElements,
- appState: state,
- fitToViewport: !!opts.fitToViewport,
- viewportZoomFactor: opts.viewportZoomFactor,
- minZoom: opts.minZoom,
- maxZoom: opts.maxZoom,
- });
-
- return {
- scrollX: appState.scrollX,
- scrollY: appState.scrollY,
- zoom: appState.zoom,
- };
- }
-
- // keep the current zoom, only recenter the viewport on the target
- const { scrollX, scrollY } = calculateScrollCenter(targetElements, state);
-
- return { scrollX, scrollY, zoom: state.zoom };
-};
-
-/**
- * Interpolates the viewport from `from` to `target` at the (already-eased)
- * blend amount `factor` (0 = `from`, 1 = `target`).
- *
- * Zoom is interpolated geometrically (so it feels uniform), and rather than
- * tweening scrollX/scrollY directly we tween the *focal point* — the scene
- * point under the viewport center — and derive scroll from it. Mixing a linear
- * scroll with a geometric zoom makes the focal point swoop sideways
- * mid-animation (most visible when zooming out); gliding the focal point keeps
- * it steady. `width/2/zoom - scroll` is the inverse of `centerScrollOn` without
- * offsets, so factor 0/1 land exactly on `from`/`target`.
- */
-export const interpolateViewport = ({
- from,
- target,
- factor,
-}: {
- from: Pick;
- target: Viewport;
- factor: number;
-}): Viewport => {
- const zoom = (from.zoom.value *
- Math.pow(
- target.zoom.value / from.zoom.value,
- factor,
- )) as NormalizedZoomValue;
-
- const fromCenterX = from.width / 2 / from.zoom.value - from.scrollX;
- const fromCenterY = from.height / 2 / from.zoom.value - from.scrollY;
- const toCenterX = from.width / 2 / target.zoom.value - target.scrollX;
- const toCenterY = from.height / 2 / target.zoom.value - target.scrollY;
-
- const centerX = fromCenterX + (toCenterX - fromCenterX) * factor;
- const centerY = fromCenterY + (toCenterY - fromCenterY) * factor;
-
- return {
- scrollX: from.width / 2 / zoom - centerX,
- scrollY: from.height / 2 / zoom - centerY,
- zoom: { value: zoom },
- };
-};
-
-/** Eases the viewport from its current position to `target` over `duration`,
- * driving the transition through the shared AnimationController so it doesn't
- * slow down other processes. */
-const animateToViewport = (
- from: Pick,
- target: Viewport,
- duration: number,
- onFrame: (
- state: Pick<
- AppState,
- "scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
- >,
- ) => void,
-) => {
- AnimationController.start<{ elapsed: number }>(
- SCROLL_TO_CONTENT_ANIMATION_KEY,
- ({ deltaTime, state }) => {
- const elapsed = (state?.elapsed ?? 0) + deltaTime;
- const progress = Math.min(elapsed / duration, 1);
- const factor = easeOut(clamp(progress, 0, 1));
-
- onFrame({
- ...interpolateViewport({ from, target, factor }),
- shouldCacheIgnoreZoom: progress < 1, // ignore zoom caching while animating
- });
-
- // returning a falsy value signals the AnimationController to remove the
- // animation; otherwise it would keep ticking (and calling onFrame) every
- // frame forever after reaching the target
- return progress < 1 ? { elapsed } : null;
- },
- );
-};
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/fitToContent.test.tsx b/packages/excalidraw/tests/fitToContent.test.tsx
index f6f1e69bb1..7b8092367c 100644
--- a/packages/excalidraw/tests/fitToContent.test.tsx
+++ b/packages/excalidraw/tests/fitToContent.test.tsx
@@ -2,7 +2,8 @@ import React from "react";
import { Excalidraw } from "../index";
import { AnimationController } from "../renderer/animation";
-import { SCROLL_TO_CONTENT_ANIMATION_KEY } from "../scroll";
+import { getNormalizedZoom } from "../scene";
+import { SCROLL_TO_CONTENT_ANIMATION_KEY } from "../viewport";
import { API } from "./helpers/api";
import { act, render } from "./test-utils";
@@ -61,7 +62,7 @@ const waitForAnimationToStop = (maxFrames = 200) => {
);
};
-describe("fitToContent", () => {
+describe("scale-down", () => {
it("should zoom to fit the selected element", async () => {
await render();
@@ -74,11 +75,16 @@ describe("fitToContent", () => {
x: 50,
y: 100,
});
+ API.setElements([rectElement]);
expect(h.state.zoom.value).toBe(1);
act(() => {
- h.app.scrollToContent(rectElement, { fitToContent: true });
+ h.app.setViewport({
+ target: rectElement,
+ fit: "scale-down",
+ animation: false,
+ });
});
// element is 10x taller than the viewport size,
@@ -102,6 +108,7 @@ describe("fitToContent", () => {
x: 80,
y: 80,
});
+ API.setElements([topLeft, bottomRight]);
h.state.width = 10;
h.state.height = 10;
@@ -109,8 +116,10 @@ describe("fitToContent", () => {
expect(h.state.zoom.value).toBe(1);
act(() => {
- h.app.scrollToContent([topLeft, bottomRight], {
- fitToContent: true,
+ h.app.setViewport({
+ target: [topLeft, bottomRight],
+ fit: "scale-down",
+ animation: false,
});
});
@@ -119,7 +128,7 @@ describe("fitToContent", () => {
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
});
- it("should default to fitToContent when scrolling to an element by id", async () => {
+ it("should zoom to fit when scrolling to an element by id", async () => {
await render();
h.state.width = 10;
@@ -137,9 +146,12 @@ describe("fitToContent", () => {
expect(h.state.zoom.value).toBe(1);
act(() => {
- // navigating by element id (a string target) should zoom-to-fit by
- // default, even though no `fitToContent` option was passed
- h.app.scrollToContent(rectElement.id, { animate: false });
+ // navigating by element id (a string target) with zoomToFit
+ h.app.setViewport({
+ target: rectElement.id,
+ fit: "scale-down",
+ animation: false,
+ });
});
// element is 10x taller than the viewport, so fit-to-content should
@@ -159,17 +171,21 @@ describe("fitToContent", () => {
x: 100,
y: 100,
});
+ API.setElements([rectElement]);
expect(h.state.zoom.value).toBe(1);
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
act(() => {
- h.app.scrollToContent(rectElement);
+ h.app.setViewport({
+ target: rectElement,
+ fit: "contain",
+ animation: false,
+ });
});
- // zoom level should stay the same
- expect(h.state.zoom.value).toBe(1);
+ expect(h.state.zoom.value).toBe(0.1);
// state should reflect some scrolling
expect(h.state.scrollX).not.toBe(0);
@@ -177,7 +193,43 @@ describe("fitToContent", () => {
});
});
-describe("fitToContent animated", () => {
+describe("none", () => {
+ it("should keep the current zoom and only center the target", async () => {
+ await render();
+
+ h.state.width = 100;
+ h.state.height = 100;
+
+ const rectElement = API.createElement({
+ width: 50,
+ height: 50,
+ x: 1000,
+ y: 1000,
+ });
+ API.setElements([rectElement]);
+
+ act(() => {
+ API.setAppState({ zoom: { value: getNormalizedZoom(0.5) } });
+ });
+
+ act(() => {
+ h.app.setViewport({
+ target: rectElement,
+ fit: "none",
+ animation: false,
+ });
+ });
+
+ // pan-only: the zoom must stay untouched...
+ expect(h.state.zoom.value).toBe(0.5);
+ // ...with the target's center (1025, 1025) centered in the viewport
+ // (scroll = viewportSize / 2 / zoom - sceneCenter)
+ expect(h.state.scrollX).toBeCloseTo(100 / 2 / 0.5 - 1025);
+ expect(h.state.scrollY).toBeCloseTo(100 / 2 / 0.5 - 1025);
+ });
+});
+
+describe("scale-down animated", () => {
beforeEach(() => {
// pace the animation via requestAnimationFrame instead of a tight
// setTimeout(0) loop, which would otherwise starve the test's own timers
@@ -203,11 +255,13 @@ describe("fitToContent animated", () => {
x: -100,
y: -100,
});
+ API.setElements([rectElement]);
act(() => {
- h.app.scrollToContent(rectElement, {
- animate: true,
- duration: LONG_ANIMATION_DURATION,
+ h.app.setViewport({
+ target: rectElement,
+ fit: "scale-down",
+ animation: { duration: LONG_ANIMATION_DURATION },
});
});
@@ -242,15 +296,16 @@ describe("fitToContent animated", () => {
x: 100,
y: 100,
});
+ API.setElements([rectElement]);
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
act(() => {
- h.app.scrollToContent(rectElement, {
- animate: true,
- fitToContent: true,
- duration: LONG_ANIMATION_DURATION,
+ h.app.setViewport({
+ target: rectElement,
+ fit: "scale-down",
+ animation: { duration: LONG_ANIMATION_DURATION },
});
});
@@ -280,10 +335,15 @@ describe("fitToContent animated", () => {
x: -100,
y: -100,
});
+ API.setElements([rectElement]);
act(() => {
// a short duration so the animation completes within a few frames
- h.app.scrollToContent(rectElement, { animate: true, duration: 10 });
+ h.app.setViewport({
+ target: rectElement,
+ fit: "scale-down",
+ animation: { duration: 10 },
+ });
});
await waitForAnimationToStop();
diff --git a/packages/excalidraw/tests/scroll.test.tsx b/packages/excalidraw/tests/scroll.test.tsx
index ef5eb3dbf7..28b48d495d 100644
--- a/packages/excalidraw/tests/scroll.test.tsx
+++ b/packages/excalidraw/tests/scroll.test.tsx
@@ -57,6 +57,59 @@ describe("appState", () => {
restoreOriginalGetBoundingClientRect();
});
+ it("initialState.viewport on init takes precedence over scrollToContent", async () => {
+ mockBoundingClientRect();
+
+ await render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(h.state.width).toBe(200);
+ expect(h.state.height).toBe(100);
+ expect(h.state.scrollX).toBe(-1000);
+ expect(h.state.scrollY).toBe(-500);
+ expect(h.state.scrollConstraints).toMatchObject({
+ x: 1000,
+ y: 500,
+ width: 200,
+ height: 100,
+ lockScroll: true,
+ lockZoom: true,
+ overscroll: 15,
+ });
+ });
+
+ restoreOriginalGetBoundingClientRect();
+ });
+
it("moving by page up/down/left/right", async () => {
mockBoundingClientRect();
await render(, {});
diff --git a/packages/excalidraw/tests/scrollConstraints.test.tsx b/packages/excalidraw/tests/scrollConstraints.test.tsx
new file mode 100644
index 0000000000..2ad4e952dc
--- /dev/null
+++ b/packages/excalidraw/tests/scrollConstraints.test.tsx
@@ -0,0 +1,917 @@
+import React from "react";
+import { vi } from "vitest";
+
+import { KEYS, MAX_ZOOM } from "@excalidraw/common";
+
+import { Excalidraw } from "../index";
+import {
+ actionResetZoom,
+ actionZoomIn,
+ actionZoomOut,
+ actionZoomToFit,
+ actionZoomToFitSelection,
+} from "../actions/actionCanvas";
+import { getNormalizedZoom } from "../scene";
+import { getStateForZoom } from "../scene/zoom";
+import {
+ animateToConstraints,
+ constrainScrollState,
+ DEFAULT_OVERSCROLL,
+ isViewportOverscrolled,
+ SCROLL_TO_CONTENT_ANIMATION_KEY,
+} from "../viewport";
+import { AnimationController } from "../renderer/animation";
+
+import { API } from "./helpers/api";
+import { Keyboard, Pointer } 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,
+});
+
+/** A lock box with sensible defaults; pass `lockScroll`/`lockZoom`/`zoom`/
+ * `overscroll`/`offsets` to override. */
+const makeLock = (
+ overrides: Partial &
+ Pick,
+): ScrollConstraints => ({
+ lockScroll: false,
+ lockZoom: false,
+ zoom: 1,
+ overscroll: 0,
+ ...overrides,
+});
+
+describe("constrainScrollState (pure)", () => {
+ it("returns scroll/zoom unchanged when there is no lock", () => {
+ 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) },
+ });
+ });
+
+ it("leaves scroll free when only zoom is locked", () => {
+ const lock = makeLock({
+ x: 0,
+ y: 0,
+ width: 1000,
+ height: 1000,
+ lockZoom: true,
+ zoom: 0.2,
+ });
+ const result = constrainScrollState(
+ makeState({ scrollX: 9999, scrollY: 9999, scrollConstraints: lock }),
+ );
+ expect(result.scrollX).toBe(9999);
+ expect(result.scrollY).toBe(9999);
+ });
+
+ it("leaves zoom free when only scroll is locked", () => {
+ const lock = makeLock({
+ x: 0,
+ y: 0,
+ width: 1000,
+ height: 1000,
+ lockScroll: true,
+ });
+ // a zoom that would have been forced up by the old box-cover fit zoom
+ // (0.2 for this box) stays as-is when only scroll is locked
+ const { zoom } = constrainScrollState(
+ makeState({
+ zoom: { value: getNormalizedZoom(0.15) },
+ scrollConstraints: lock,
+ }),
+ );
+ expect(zoom.value).toBeCloseTo(0.15);
+ });
+
+ describe("scroll lock", () => {
+ const lock = makeLock({
+ x: 0,
+ y: 0,
+ width: 1000,
+ height: 1000,
+ lockScroll: true,
+ });
+
+ 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: lock }),
+ );
+ 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: lock }),
+ );
+ expect(farEdge.scrollX).toBeCloseTo(VIEWPORT.width - lock.width); // -800
+ expect(farEdge.scrollY).toBeCloseTo(VIEWPORT.height - lock.height); // -900
+ });
+
+ it("leaves an in-bounds viewport untouched", () => {
+ const inBounds = makeState({
+ scrollX: -100,
+ scrollY: -100,
+ zoom: { value: getNormalizedZoom(1) },
+ scrollConstraints: lock,
+ });
+ expect(constrainScrollState(inBounds)).toMatchObject({
+ scrollX: -100,
+ scrollY: -100,
+ zoom: { value: getNormalizedZoom(1) },
+ });
+ });
+
+ it("centers the box when the viewport is larger than it", () => {
+ // at zoom 0.1 the visible width (200/0.1 = 2000) exceeds the box (1000),
+ // so there's no pan room → the box is centered on the axis
+ const result = constrainScrollState(
+ makeState({
+ scrollX: 999,
+ zoom: { value: getNormalizedZoom(0.1) },
+ scrollConstraints: lock,
+ }),
+ );
+ // centered: (min + max) / 2, min = w/zoom - (x+w), max = -x
+ const centeredX = (VIEWPORT.width / 0.1 - lock.width + 0) / 2;
+ expect(result.scrollX).toBeCloseTo(centeredX);
+ });
+ });
+});
+
+describe("zoom lock (pure)", () => {
+ const base = { x: 0, y: 0, width: 1000, height: 1000 } as const;
+
+ it("floors zoom-out at the locked zoom", () => {
+ const { zoom } = constrainScrollState(
+ makeState({
+ zoom: { value: getNormalizedZoom(0.1) },
+ scrollConstraints: makeLock({ ...base, lockZoom: true, zoom: 0.5 }),
+ }),
+ );
+ expect(zoom.value).toBeCloseTo(0.5);
+ });
+
+ it("always allows zooming in up to MAX_ZOOM", () => {
+ const { zoom } = constrainScrollState(
+ makeState({
+ zoom: { value: getNormalizedZoom(MAX_ZOOM) },
+ scrollConstraints: makeLock({ ...base, lockZoom: true, zoom: 0.5 }),
+ }),
+ );
+ expect(zoom.value).toBeCloseTo(MAX_ZOOM);
+ });
+
+ it("leaves an in-range zoom untouched", () => {
+ const { zoom } = constrainScrollState(
+ makeState({
+ zoom: { value: getNormalizedZoom(1.5) },
+ scrollConstraints: makeLock({ ...base, lockZoom: true, zoom: 0.5 }),
+ }),
+ );
+ expect(zoom.value).toBeCloseTo(1.5);
+ });
+});
+
+describe("offsets (pure)", () => {
+ const base = { x: 0, y: 0, width: 1000, height: 1000 } as const;
+
+ it("extends the scrollable area past each edge", () => {
+ const offsets = { top: 10, right: 20, bottom: 30, left: 40 };
+ const lock = makeLock({ ...base, lockScroll: true, offsets });
+
+ // pan past the top-left corner → clamp to the offsets corner
+ const topLeft = constrainScrollState(
+ makeState({ scrollX: 999, scrollY: 999, scrollConstraints: lock }),
+ );
+ expect(topLeft.scrollX).toBeCloseTo(40); // left offset
+ expect(topLeft.scrollY).toBeCloseTo(10); // top offset
+
+ // pan past the far edges → clamp to width/height - boxSize - far offset
+ const farEdge = constrainScrollState(
+ makeState({ scrollX: -5000, scrollY: -5000, scrollConstraints: lock }),
+ );
+ expect(farEdge.scrollX).toBeCloseTo(VIEWPORT.width - base.width - 20); // -820
+ expect(farEdge.scrollY).toBeCloseTo(VIEWPORT.height - base.height - 30); // -930
+ });
+
+ it("keeps the offsets a fixed screen distance regardless of zoom", () => {
+ // 40 screen px of top offset at zoom 2 → 20 scene px
+ const result = constrainScrollState(
+ makeState({
+ scrollY: 999,
+ zoom: { value: getNormalizedZoom(2) },
+ scrollConstraints: makeLock({
+ ...base,
+ lockScroll: true,
+ offsets: { top: 40 },
+ }),
+ }),
+ );
+ expect(result.scrollY).toBeCloseTo(20);
+ });
+
+ it("defaults to no extra room", () => {
+ const result = constrainScrollState(
+ makeState({
+ scrollX: 999,
+ scrollConstraints: makeLock({ ...base, lockScroll: true }),
+ }),
+ );
+ expect(result.scrollX).toBeCloseTo(0);
+ });
+
+ it("stacks with the rubberband overscroll", () => {
+ const overscroll = 30; // screen px
+ const result = constrainScrollState(
+ makeState({
+ scrollX: 999,
+ scrollConstraints: makeLock({
+ ...base,
+ lockScroll: true,
+ overscroll,
+ offsets: { left: 40 },
+ }),
+ }),
+ overscroll,
+ );
+ // left offset (40) + overscroll give (30) at zoom 1
+ expect(result.scrollX).toBeCloseTo(70);
+ });
+});
+
+describe("rubberband overscroll (pure)", () => {
+ const base = { x: 0, y: 0, width: 1000, height: 1000 } as const;
+
+ it("allows scroll overscroll up to `overscroll` screen pixels", () => {
+ const overscroll = 30; // screen px
+ const result = constrainScrollState(
+ makeState({
+ scrollX: 999,
+ scrollConstraints: makeLock({ ...base, lockScroll: true, overscroll }),
+ }),
+ overscroll,
+ );
+ expect(result.scrollX).toBeCloseTo(overscroll / 1); // 30 (zoom 1)
+ });
+
+ it("keeps the overscroll a fixed screen distance regardless of zoom", () => {
+ const overscroll = 30; // screen px
+ const result = constrainScrollState(
+ makeState({
+ scrollX: 999,
+ zoom: { value: getNormalizedZoom(2) },
+ scrollConstraints: makeLock({ ...base, lockScroll: true, overscroll }),
+ }),
+ overscroll,
+ );
+ // 30 screen px at zoom 2 -> 15 scene px of overscroll
+ expect(result.scrollX).toBeCloseTo(overscroll / 2); // 15
+ });
+
+ it("hard-clamps when overscroll is 0 (default)", () => {
+ const result = constrainScrollState(
+ makeState({
+ scrollX: 999,
+ scrollConstraints: makeLock({ ...base, lockScroll: true }),
+ }),
+ );
+ expect(result.scrollX).toBeCloseTo(0);
+ });
+
+ it("gives ±overscroll around center when the box can't cover the viewport", () => {
+ const overscroll = 30; // screen px
+ // at zoom 0.1 the visible width (200/0.1 = 2000) exceeds the box (1000),
+ // so the box rests centered (scrollX 500) — but overscroll must still
+ // allow give around that resting position, regardless of the free space
+ const state = makeState({
+ scrollX: 9999,
+ zoom: { value: getNormalizedZoom(0.1) },
+ scrollConstraints: makeLock({ ...base, lockScroll: true, overscroll }),
+ });
+
+ // 30 screen px at zoom 0.1 -> 300 scene px of give past center
+ expect(constrainScrollState(state, overscroll).scrollX).toBeCloseTo(
+ 500 + 300,
+ );
+ // the hard clamp still rests at dead center
+ expect(constrainScrollState(state).scrollX).toBeCloseTo(500);
+ });
+});
+
+describe("isViewportOverscrolled (pure)", () => {
+ const base = { x: 0, y: 0, width: 1000, height: 1000 } as const;
+
+ it("is false when there is no lock", () => {
+ expect(
+ isViewportOverscrolled(
+ makeState({ scrollX: 9999, scrollConstraints: null }),
+ ),
+ ).toBe(false);
+ });
+
+ it("is false when the viewport is within the hard bounds", () => {
+ expect(
+ isViewportOverscrolled(
+ makeState({
+ scrollX: -100,
+ scrollY: -100,
+ scrollConstraints: makeLock({ ...base, lockScroll: true }),
+ }),
+ ),
+ ).toBe(false);
+ });
+
+ it("is true when panned past an edge (scroll lock)", () => {
+ // scrollX 30 is past the hard max of 0
+ expect(
+ isViewportOverscrolled(
+ makeState({
+ scrollX: 30,
+ scrollConstraints: makeLock({ ...base, lockScroll: true }),
+ }),
+ ),
+ ).toBe(true);
+ });
+
+ it("is true when zoomed out below the locked zoom (zoom lock)", () => {
+ expect(
+ isViewportOverscrolled(
+ makeState({
+ zoom: { value: getNormalizedZoom(0.1) },
+ scrollConstraints: makeLock({ ...base, lockZoom: true, zoom: 0.2 }),
+ }),
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("animateToConstraints (rubberband snap-back)", () => {
+ afterEach(() => {
+ AnimationController.reset();
+ });
+
+ const lock = makeLock({
+ x: 0,
+ y: 0,
+ width: 1000,
+ height: 1000,
+ lockScroll: true,
+ });
+
+ it("starts an animation toward the box when overscrolled", () => {
+ const onFrame = vi.fn();
+ // scrollX 200 is outside the hard range [-800, 0]
+ animateToConstraints(
+ makeState({ scrollX: 200, scrollConstraints: lock }),
+ onFrame,
+ );
+ expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
+ true,
+ );
+ expect(onFrame).toHaveBeenCalled();
+ });
+
+ it("is a no-op when already within the box", () => {
+ const onFrame = vi.fn();
+ animateToConstraints(
+ makeState({ scrollX: -100, scrollConstraints: lock }),
+ onFrame,
+ );
+ expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
+ false,
+ );
+ expect(onFrame).not.toHaveBeenCalled();
+ });
+});
+
+describe("setViewport lock (integration)", () => {
+ beforeEach(() => {
+ mockBoundingClientRect();
+ });
+
+ afterEach(() => {
+ restoreOriginalGetBoundingClientRect();
+ AnimationController.reset();
+ });
+
+ it("installs a scroll lock and prevents keyboard panning out of the box", async () => {
+ await render();
+ await waitFor(() => expect(h.state.width).toBe(200));
+
+ React.act(() => {
+ h.app.setViewport({
+ target: [0, 0, 1000, 1000],
+ fit: "scale-down",
+ animation: false,
+ lock: { scroll: true, overscroll: false },
+ });
+ });
+
+ expect(h.state.scrollConstraints).not.toBe(null);
+
+ // 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 - 1000);
+
+ // clearing the lock lets it scroll freely again
+ React.act(() => {
+ h.app.setViewport(null);
+ });
+ expect(h.state.scrollConstraints).toBe(null);
+ const before = h.state.scrollY;
+ Keyboard.keyPress(KEYS.PAGE_DOWN);
+ expect(h.state.scrollY).toBeLessThan(before);
+ });
+
+ it("defers installing the lock until an animated scroll settles", async () => {
+ await render();
+ await waitFor(() => expect(h.state.width).toBe(200));
+
+ const rect = API.createElement({
+ type: "rectangle",
+ x: 2000,
+ y: 2000,
+ width: 100,
+ height: 100,
+ });
+ API.setElements([rect]);
+
+ React.act(() => {
+ h.app.setViewport({
+ target: rect,
+ fit: "scale-down",
+ animation: { duration: 300 },
+ lock: { scroll: true },
+ });
+ });
+
+ // the animation is in flight and the lock has NOT been installed yet —
+ // it's chained to run once the scroll settles
+ expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
+ true,
+ );
+ expect(h.state.scrollConstraints).toBe(null);
+ });
+
+ it("supports rect targets and defaults missing dimensions to the viewport", async () => {
+ await render();
+ await waitFor(() => expect(h.state.width).toBe(200));
+
+ React.act(() => {
+ h.app.setViewport({
+ target: { x: 10, y: 20, width: 50 },
+ fit: "scale-down",
+ animation: false,
+ lock: { scroll: true },
+ });
+ });
+
+ expect(h.state.scrollConstraints).toMatchObject({
+ x: 10,
+ y: 20,
+ width: 50,
+ height: h.state.height,
+ });
+
+ React.act(() => {
+ h.app.setViewport({
+ target: { x: 30, y: 40, height: 60 },
+ fit: "scale-down",
+ animation: false,
+ lock: { scroll: true },
+ });
+ });
+
+ expect(h.state.scrollConstraints).toMatchObject({
+ x: 30,
+ y: 40,
+ width: h.state.width,
+ height: 60,
+ });
+
+ React.act(() => {
+ h.app.setViewport({
+ target: { x: 70, y: 80 },
+ fit: "scale-down",
+ animation: false,
+ lock: { scroll: true },
+ });
+ });
+
+ expect(h.state.scrollConstraints).toMatchObject({
+ x: 70,
+ y: 80,
+ width: h.state.width,
+ height: h.state.height,
+ });
+ });
+
+ it("filters deleted, non-scene, and nullable element targets", async () => {
+ await render();
+ await waitFor(() => expect(h.state.width).toBe(200));
+
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const warnMessage =
+ "supplied element target(s) for setViewport contain deleted or non-existent elements which have been filtered out";
+
+ try {
+ const validRect = API.createElement({
+ type: "rectangle",
+ x: 100,
+ y: 200,
+ width: 300,
+ height: 400,
+ });
+ const deletedRect = API.createElement({
+ type: "rectangle",
+ isDeleted: true,
+ });
+ const missingRect = API.createElement({
+ type: "rectangle",
+ });
+ API.setElements([validRect, deletedRect]);
+
+ React.act(() => {
+ h.app.setViewport({
+ target: [
+ validRect,
+ deletedRect,
+ missingRect,
+ undefined,
+ null,
+ ] as unknown as readonly typeof validRect[],
+ fit: "scale-down",
+ animation: false,
+ lock: { scroll: true },
+ });
+ });
+
+ expect(warnSpy).toHaveBeenCalledWith(warnMessage);
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(h.state.scrollConstraints).toMatchObject({
+ x: validRect.x,
+ y: validRect.y,
+ width: validRect.width,
+ height: validRect.height,
+ });
+
+ const scrollConstraints = h.state.scrollConstraints;
+ warnSpy.mockClear();
+
+ React.act(() => {
+ h.app.setViewport({
+ target: [
+ deletedRect,
+ missingRect,
+ undefined,
+ null,
+ ] as unknown as readonly typeof validRect[],
+ fit: "scale-down",
+ animation: false,
+ lock: { scroll: true },
+ });
+ });
+
+ expect(warnSpy).toHaveBeenCalledWith(warnMessage);
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(h.state.scrollConstraints).toBe(scrollConstraints);
+ } finally {
+ warnSpy.mockRestore();
+ }
+ });
+
+ it("disables zoom-to-fit actions while locked, but keeps reset-zoom enabled", 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.setViewport({
+ target: [0, 0, 500, 500],
+ fit: "scale-down",
+ animation: false,
+ lock: { scroll: true },
+ });
+ });
+
+ // reset-zoom stays available (it re-clamps into the lock)
+ expect(h.app.actionManager.isActionEnabled(actionResetZoom)).toBe(true);
+ expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(false);
+ expect(h.app.actionManager.isActionEnabled(actionZoomToFitSelection)).toBe(
+ false,
+ );
+ });
+
+ it("resets zoom to the locked minimum zoom when zoom is locked", async () => {
+ await render();
+ await waitFor(() => expect(h.state.width).toBe(200));
+
+ // a target smaller than the viewport → zoomToTarget fills it at zoom > 1
+ const rect = API.createElement({
+ type: "rectangle",
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 50,
+ });
+ API.setElements([rect]);
+
+ React.act(() => {
+ h.app.setViewport({
+ target: rect,
+ fit: "contain",
+ animation: false,
+ lock: { zoom: true },
+ });
+ });
+
+ const lockedZoom = h.state.scrollConstraints?.zoom;
+ expect(lockedZoom).toBeGreaterThan(1);
+
+ // zoom in, then reset → should land back on the locked minimum, not 100%
+ React.act(() => {
+ h.app.actionManager.executeAction(actionZoomIn);
+ });
+ expect(h.state.zoom.value).toBeGreaterThan(lockedZoom!);
+
+ React.act(() => {
+ h.app.actionManager.executeAction(actionResetZoom);
+ });
+ expect(h.state.zoom.value).toBeCloseTo(lockedZoom!);
+ });
+
+ it("zoom-out cannot go below the locked minimum zoom", async () => {
+ await render();
+ await waitFor(() => expect(h.state.width).toBe(200));
+
+ const rect = API.createElement({
+ type: "rectangle",
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 50,
+ });
+ API.setElements([rect]);
+
+ React.act(() => {
+ h.app.setViewport({
+ target: rect,
+ fit: "contain",
+ animation: false,
+ lock: { zoom: true },
+ });
+ });
+
+ const lockedZoom = h.state.scrollConstraints?.zoom;
+ expect(lockedZoom).toBeGreaterThan(1);
+
+ // hammer zoom-out far past the locked floor; it must stay clamped
+ for (let i = 0; i < 30; i++) {
+ React.act(() => {
+ h.app.actionManager.executeAction(actionZoomOut);
+ });
+ }
+ expect(h.state.zoom.value).toBeCloseTo(lockedZoom!);
+ });
+});
+
+describe("rubberband overscroll (integration)", () => {
+ beforeEach(() => {
+ mockBoundingClientRect();
+ });
+
+ afterEach(() => {
+ restoreOriginalGetBoundingClientRect();
+ AnimationController.reset();
+ });
+
+ it("lets the user pan past the box edge (bounded by overscroll)", async () => {
+ await render();
+ await waitFor(() => expect(h.state.width).toBe(200));
+
+ React.act(() => {
+ h.app.setViewport({
+ target: [0, 0, 1000, 1000],
+ fit: "scale-down",
+ animation: false,
+ lock: { scroll: true, overscroll: 25 },
+ });
+ });
+
+ // pan up to (and past) the top edge (hard max scrollY = 0)
+ for (let i = 0; i < 20; i++) {
+ Keyboard.keyPress(KEYS.PAGE_UP);
+ }
+
+ // overscrolled past 0, but bounded by the give (25 screen px / zoom 1)
+ expect(h.state.scrollY).toBeGreaterThan(0);
+ expect(h.state.scrollY).toBeLessThanOrEqual(
+ 25 / h.state.zoom.value + 0.001,
+ );
+ });
+
+ it("hard-clamps interactive zoom so it never enters the overscroll zone", async () => {
+ await render();
+ await waitFor(() => expect(h.state.width).toBe(200));
+
+ React.act(() => {
+ h.app.setViewport({
+ target: [0, 0, 1000, 1000],
+ fit: "scale-down",
+ animation: false,
+ lock: { scroll: true, overscroll: 50 },
+ });
+ });
+
+ // fitted at zoom 0.1; the box rests centered horizontally (scrollX 500)
+ expect(h.state.zoom.value).toBeCloseTo(0.1);
+ expect(h.state.scrollX).toBeCloseTo(500);
+
+ // interactive zoom-in anchored at the top-left corner (the wheel-zoom
+ // path): keeping that focal point fixed would leave scrollX at 500,
+ // which at zoom 0.2 is 100 screen px past the hard bound (scrollX 0) —
+ // outside the 50px give. The zoom must be hard-clamped (equivalent to
+ // sliding the zoom origin back into bounds), not rubberbanded.
+ React.act(() => {
+ // eslint-disable-next-line dot-notation -- private method; bracket access is the TS escape hatch
+ h.app["translateCanvas"]((state: AppState) => ({
+ ...getStateForZoom(
+ { viewportX: 0, viewportY: 0, nextZoom: getNormalizedZoom(0.2) },
+ state,
+ ),
+ }));
+ });
+
+ expect(h.state.zoom.value).toBeCloseTo(0.2);
+ expect(h.state.scrollX).toBeCloseTo(0);
+ expect(isViewportOverscrolled(h.state)).toBe(false);
+
+ // panning, by contrast, still gets the rubberband give
+ Keyboard.keyPress(KEYS.PAGE_UP);
+ expect(h.state.scrollY).toBeGreaterThan(0);
+ expect(h.state.scrollY).toBeLessThanOrEqual(
+ 50 / h.state.zoom.value + 0.001,
+ );
+ });
+
+ it("allows simultaneous pinch-zoom and rubberband pan on touch", async () => {
+ await render();
+ await waitFor(() => expect(h.state.width).toBe(200));
+
+ React.act(() => {
+ h.app.setViewport({
+ target: [0, 0, 1000, 1000],
+ fit: "scale-down",
+ animation: false,
+ lock: { scroll: true, overscroll: 50 },
+ });
+ });
+
+ // fitted at zoom 0.1; the box exactly fills the viewport vertically,
+ // so the hard bound is scrollY = 0
+ const initialZoom = h.state.zoom.value;
+ expect(initialZoom).toBeCloseTo(0.1);
+ expect(h.state.scrollY).toBeCloseTo(0);
+
+ const finger1 = new Pointer("touch", 1);
+ const finger2 = new Pointer("touch", 2);
+
+ // spread the fingers (zoom in) while dragging both downward (pan past
+ // the top edge) — in a single two-finger gesture. Anchored near the top
+ // edge so the zoom's focal scroll doesn't counteract the pan.
+ finger1.downAt(50, 2);
+ finger2.downAt(60, 2);
+ finger1.move(-2, 5);
+ finger2.move(2, 5);
+
+ // zoomed in AND overscrolled past the top edge, bounded by the give
+ expect(h.state.zoom.value).toBeGreaterThan(initialZoom);
+ expect(h.state.scrollY).toBeGreaterThan(0);
+ expect(h.state.scrollY).toBeLessThanOrEqual(
+ 50 / h.state.zoom.value + 0.001,
+ );
+ expect(isViewportOverscrolled(h.state)).toBe(true);
+
+ // zooming must keep working while overscrolled (used to be pinned),
+ // without yanking the viewport back inside the box
+ const zoomedWhileOverscrolled = h.state.zoom.value;
+ finger1.move(-2, 0);
+ finger2.move(2, 0);
+ expect(h.state.zoom.value).toBeGreaterThan(zoomedWhileOverscrolled);
+ expect(h.state.scrollY).toBeGreaterThan(0);
+
+ finger1.up();
+ finger2.up();
+ });
+
+ it("withholds the rubberband snap-back until the touch gesture ends", async () => {
+ await render();
+ await waitFor(() => expect(h.state.width).toBe(200));
+
+ React.act(() => {
+ h.app.setViewport({
+ target: [0, 0, 1000, 1000],
+ fit: "scale-down",
+ animation: false,
+ lock: { scroll: true, overscroll: 50 },
+ });
+ });
+
+ const finger1 = new Pointer("touch", 1);
+ const finger2 = new Pointer("touch", 2);
+
+ // two-finger pan past the top edge, into the overscroll zone
+ finger1.downAt(50, 2);
+ finger2.downAt(60, 2);
+ finger1.move(0, 5);
+ finger2.move(0, 5);
+ expect(isViewportOverscrolled(h.state)).toBe(true);
+
+ // the debounced snap-back elapses while the fingers are still down —
+ // it must be withheld, not fight the held gesture
+ React.act(() => {
+ // eslint-disable-next-line dot-notation -- private; simulates the debounce delay elapsing
+ h.app["snapBackToScrollConstraintsDebounced"].flush();
+ });
+ expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
+ false,
+ );
+ expect(isViewportOverscrolled(h.state)).toBe(true);
+
+ // lifting one finger ends the gesture → the rubberband releases
+ finger1.up();
+ expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
+ true,
+ );
+
+ finger2.up();
+ });
+
+ it("defaults to DEFAULT_OVERSCROLL when omitted or `true`, 0 when `false`", async () => {
+ await render();
+ await waitFor(() => expect(h.state.width).toBe(200));
+
+ const install = (overscroll?: boolean | number) => {
+ React.act(() => {
+ h.app.setViewport({
+ target: [0, 0, 1000, 1000],
+ fit: "scale-down",
+ animation: false,
+ lock: { scroll: true, overscroll },
+ });
+ });
+ };
+
+ install(undefined);
+ expect(h.state.scrollConstraints?.overscroll).toBe(DEFAULT_OVERSCROLL);
+
+ install(true);
+ expect(h.state.scrollConstraints?.overscroll).toBe(DEFAULT_OVERSCROLL);
+
+ install(false);
+ expect(h.state.scrollConstraints?.overscroll).toBe(0);
+
+ install(25);
+ expect(h.state.scrollConstraints?.overscroll).toBe(25);
+ });
+});
diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts
index 0457591a75..5e91690dc2 100644
--- a/packages/excalidraw/types.ts
+++ b/packages/excalidraw/types.ts
@@ -59,6 +59,7 @@ import type Library from "./data/library";
import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping";
import type { ImportedDataState } from "./data/types";
+import type { SetViewportOptions } from "./viewport";
import type { Language } from "./i18n";
import type { isOverScrollBars } from "./scene/scrollbars";
@@ -273,6 +274,42 @@ export type ObservedElementsAppState = {
export type BoxSelectionMode = "contain" | "overlap";
+/**
+ * A box, in scene coordinates, that pan & zoom are constrained to.
+ *
+ * This is a private type. For public API, only use specific properties,
+ * needed.
+ */
+export type ScrollConstraints = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ /** when set, panning is constrained so the viewport stays within the box */
+ lockScroll: boolean;
+ /** when set, the viewport cannot zoom out below `zoom` */
+ lockZoom: boolean;
+ /**
+ * The zoom resolved after the `setViewport` navigation settled.
+ */
+ zoom: number;
+ /**
+ * Pixel amount the viewport may overscroll past its resting clamp before
+ * snapping back (rubberband). Screen pixels, zoom-independent. Resolved
+ * from `lock.overscroll` at the time the lock was installed (`true` →
+ * default give, `false` → 0).
+ */
+ overscroll: number;
+ /**
+ * Extra scrollable margin around the box (CSS-style), letting the viewport
+ * scroll past each box edge to reveal that much empty space. Values are
+ * viewport pixels and zoom-independent (a fixed on-screen distance).
+ * Resolved from the `offsets` passed to `setViewport` (see
+ * {@link ViewportOffsets}) at the time the lock was installed.
+ */
+ offsets?: Offsets;
+};
+
export interface AppState {
contextMenu: {
items: ContextMenuItems;
@@ -380,6 +417,7 @@ export interface AppState {
viewBackgroundColor: string;
scrollX: number;
scrollY: number;
+ scrollConstraints: ScrollConstraints | null;
cursorButton: "up" | "down";
scrolledOutside: boolean;
name: string | null;
@@ -558,6 +596,10 @@ export type ExcalidrawInitialDataState = Merge<
}
>;
+export type ExcalidrawInitialState = {
+ viewport?: Omit;
+};
+
export type OnUserFollowedPayload = {
userToFollow: UserToFollow;
action: "FOLLOW" | "UNFOLLOW";
@@ -584,6 +626,7 @@ export interface ExcalidrawProps {
initialData?:
| (() => MaybePromise)
| MaybePromise;
+ initialState?: ExcalidrawInitialState;
/**
* Invoked as soon as the Excalidraw API is available
* NOTE editor is not yet mounted, and state is not yet initialized
@@ -828,7 +871,7 @@ export type AppClassProperties = {
onInsertElements: App["onInsertElements"];
onExportImage: App["onExportImage"];
lastViewportPosition: App["lastViewportPosition"];
- scrollToContent: App["scrollToContent"];
+ setViewport: App["setViewport"];
addFiles: App["addFiles"];
addElementsFromPasteOrLibrary: App["addElementsFromPasteOrLibrary"];
togglePenMode: App["togglePenMode"];
@@ -843,7 +886,7 @@ export type AppClassProperties = {
getEffectiveGridSize: App["getEffectiveGridSize"];
setPlugins: App["setPlugins"];
plugins: App["plugins"];
- getEditorUIOffsets: App["getEditorUIOffsets"];
+ getViewportOffsets: App["getViewportOffsets"];
visibleElements: App["visibleElements"];
excalidrawContainerValue: App["excalidrawContainerValue"];
@@ -970,7 +1013,8 @@ export interface ExcalidrawImperativeAPI {
getAppState: () => InstanceType["state"];
getFiles: () => InstanceType["files"];
getName: InstanceType["getName"];
- scrollToContent: InstanceType["scrollToContent"];
+ setViewport: InstanceType["setViewport"];
+ getViewportOffsets: InstanceType["getViewportOffsets"];
registerAction: (action: Action) => void;
refresh: InstanceType["refresh"];
setToast: InstanceType["setToast"];
@@ -1085,3 +1129,106 @@ export type Offsets = Partial<{
bottom: number;
left: number;
}>;
+
+/**
+ * Value of the `data-viewport-ui` attribute, marking a DOM node as a UI
+ * surface that occludes the canvas. Such nodes are measured by
+ * `getViewportOffsets` to compute the default per-side viewport offsets:
+ *
+ * - `top` / `bottom` — offsets that side by the node's bottom/top edge
+ * - `side` — a panel hugging the left or right edge. Which side is not
+ * declared but resolved geometrically: if the node's horizontal center
+ * lies in the left half of the viewport it counts against the left
+ * offset (by its right edge), otherwise against the right offset (by
+ * `viewportWidth - left edge`). Measuring the rendered position instead
+ * of declaring a side means RTL layouts and host-configurable docking
+ * (e.g. sidebar side) are handled for free — but it assumes the surface
+ * actually hugs one edge; don't mark a centered/near-full-width node as
+ * `side` (its midpoint would classify it to one side and the offset
+ * would swallow most of the viewport).
+ *
+ * The attribute should only be present while the surface is actually
+ * rendered — omit it (don't just hide the node) when the surface shouldn't
+ * push the viewport around.
+ */
+export type ViewportUIDock = "top" | "bottom" | "side";
+
+/**
+ * Options for `getViewportOffsets` (and the `ui` key of
+ * {@link ViewportOffsets}), controlling how offsets are derived from the
+ * currently rendered editor UI.
+ *
+ * NOTE unlike the physical sides of {@link Offsets}, the horizontal values
+ * here are logical, i.e. flipped in RTL layouts (`left` refers to the
+ * reading-direction start side).
+ */
+export type ViewportOffsetsOptions = {
+ /** padding added to each measured side (default 24) */
+ padding?: number;
+ paddingTop?: number;
+ paddingRight?: number;
+ paddingBottom?: number;
+ paddingLeft?: number;
+ /** final value for the given side, replacing the measured UI size
+ * (padding is not added on top) */
+ top?: number;
+ bottom?: number;
+ left?: number;
+ right?: number;
+ /**
+ * Reserve space for the given conditionally-rendered surfaces even while
+ * they're hidden, so the resulting offsets don't shift when they
+ * (dis)appear. Uses the surface's last-measured footprint, falling back
+ * to an approximate default if it hasn't been rendered yet. Ignored on
+ * phones (where these surfaces never occlude the canvas).
+ */
+ reserve?: {
+ /** styles panel (rendered when a tool or selection is active) */
+ stylesPanel?: boolean;
+ /** sidebar (e.g. library) */
+ sidebar?: boolean;
+ };
+};
+
+/**
+ * Viewport offsets accepted by the `setViewport`-family APIs (`setViewport`,
+ * `props.initialState.viewport`), insetting the usable viewport area per
+ * side so the target isn't fitted/centered underneath overlaid UI.
+ *
+ * Two (combinable) ways to specify:
+ *
+ * - **Static sides** (`top`/`right`/`bottom`/`left`) — absolute pixel
+ * values, used as-is: physical (not flipped in RTL), zoom-independent,
+ * no padding added. Sides not specified default to `0` (unless `ui` is
+ * set, see below).
+ *
+ * - **`ui`** — derive the offsets from the editor UI (toolbar, styles
+ * panel, sidebar...) as rendered at the time the viewport is set,
+ * equivalent to calling `getViewportOffsets()`. Pass `true` for the
+ * defaults, or options ({@link ViewportOffsetsOptions}) to customize
+ * padding or reserve space for currently-hidden surfaces.
+ *
+ * When both are given, a static side always wins for that side — it
+ * replaces whatever `ui` would yield (including `ui`'s own side overrides,
+ * which — unlike the physical static sides — are RTL-relative). The
+ * remaining sides fall back to the `ui`-derived values.
+ *
+ * @example
+ * { top: 40 } // top 40px, other sides 0
+ * { ui: true } // measured UI + default padding
+ * { ui: { reserve: { stylesPanel: true } } } // + keep space for hidden panel
+ * { top: 40, ui: true } // top exactly 40px, rest from UI
+ */
+export type ViewportOffsets = Offsets & {
+ ui?: true | ViewportOffsetsOptions;
+};
+
+/**
+ * Value of the `data-viewport-ui-name` attribute, identifying a
+ * conditionally-rendered surface (marked with `data-viewport-ui`) so that
+ * `getViewportOffsets` can reserve space for it while it's hidden (see the
+ * `reserve` option). Whenever a named surface is rendered, its measured
+ * footprint is remembered; reserving uses that remembered footprint, or an
+ * approximate default if the surface hasn't been rendered yet.
+ */
+export type ViewportUIName = "sidebar" | "stylesPanel";
diff --git a/packages/excalidraw/viewport.ts b/packages/excalidraw/viewport.ts
new file mode 100644
index 0000000000..5e0f87720c
--- /dev/null
+++ b/packages/excalidraw/viewport.ts
@@ -0,0 +1,766 @@
+import {
+ arrayToMap,
+ easeOut,
+ isBounds,
+ MAX_ZOOM,
+ MIN_ZOOM,
+ viewportCoordsToSceneCoords,
+ ZOOM_STEP,
+} from "@excalidraw/common";
+import { clamp, pointDistance, pointFrom, roundToStep } from "@excalidraw/math";
+
+import {
+ CaptureUpdateAction,
+ getCommonBounds,
+ getElementBounds,
+ getElementsInGroup,
+ getVisibleElements,
+ isElementLink,
+ isExcalidrawElement,
+ parseElementLinkFromURL,
+} from "@excalidraw/element";
+
+import type { SceneBounds } from "@excalidraw/element";
+
+import type { Bounds } from "@excalidraw/common";
+import type {
+ ExcalidrawElement,
+ NonDeleted,
+ NonDeletedSceneElementsMap,
+} from "@excalidraw/element/types";
+
+import { AnimationController } from "./renderer/animation";
+import { getNormalizedZoom } from "./scene";
+
+import type {
+ AppState,
+ NormalizedZoomValue,
+ Offsets,
+ PointerCoords,
+ ScrollConstraints,
+ ViewportOffsets,
+ Zoom,
+} from "./types";
+
+export const SCROLL_TO_CONTENT_ANIMATION_KEY = "animateScrollToContent";
+
+const DEFAULT_SCROLL_ANIMATION_DURATION = 250;
+
+/** default rubberband overscroll give for scroll locks, in viewport px */
+export const DEFAULT_OVERSCROLL = 150;
+
+export type AnimationOptions = {
+ duration?: number;
+};
+
+export type SetViewportRect = {
+ x: number;
+ y: number;
+ width?: number;
+ height?: number;
+};
+
+export const isSetViewportRect = (
+ target: unknown,
+): target is SetViewportRect => {
+ if (!target || typeof target !== "object" || Array.isArray(target)) {
+ return false;
+ }
+
+ const rect = target as Partial;
+ return (
+ typeof rect.x === "number" &&
+ typeof rect.y === "number" &&
+ (rect.width == null || typeof rect.width === "number") &&
+ (rect.height == null || typeof rect.height === "number")
+ );
+};
+
+export type SetViewportOptions = {
+ /**
+ * what to show in the viewport: an explicit scene-coordinate box/rect,
+ * element(s), or an element id / element-link URL.
+ *
+ * IMPORTANT: if supplying ExcalidrawElement(s), only non-deleted elements
+ * that actually exist on the canvas are considered. If you want to navigate
+ * to elements that's not on canvas yet, supply their bounds by using
+ * `getCommonBounds` (see {@link getCommonBounds}).
+ */
+ target:
+ | Bounds
+ | SetViewportRect
+ | ExcalidrawElement
+ | readonly ExcalidrawElement[]
+ | string;
+
+ /**
+ * @see https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/object-fit
+ *
+ * - `scale-down` - zoom out so the target fits the viewport, never zooming past 100%
+ * - `contain` - zoom the target so it fills the viewport (may exceed 100%)
+ * - `none` - keep the current zoom, only center the target in the viewport
+ */
+ fit?: "scale-down" | "contain" | "none";
+
+ lock?: {
+ /** constraints panning to the target box */
+ scroll?: ScrollConstraints["lockScroll"];
+ /** makes the resolved zoom the minimum zoom */
+ zoom?: ScrollConstraints["lockZoom"];
+ /**
+ * Rubberband give past the lock, in viewport px (zoom-independent): how
+ * far the user can pan beyond the constraint before snapping back.
+ * `true` (default) uses {@link DEFAULT_OVERSCROLL}, `false` disables
+ * (rigid lock), a number sets the give explicitly.
+ */
+ overscroll?: boolean | number;
+ };
+
+ animation?: AnimationOptions | boolean;
+
+ /**
+ * CSS-style per-side viewport insets, in viewport pixels
+ * (zoom-independent) — static values, editor-UI-derived (`ui`), or a
+ * combination. See {@link ViewportOffsets}.
+ */
+ offsets?: ViewportOffsets;
+};
+
+type Viewport = Pick;
+
+const resolveAnimationDuration = (
+ animation: AnimationOptions | boolean | undefined,
+): number | null => {
+ if (animation === false) {
+ return null;
+ }
+ if (animation === true || animation == null) {
+ return DEFAULT_SCROLL_ANIMATION_DURATION;
+ }
+ return animation.duration ?? DEFAULT_SCROLL_ANIMATION_DURATION;
+};
+
+const resolveOverscroll = (
+ overscroll: boolean | number | undefined,
+): number => {
+ if (overscroll === false) {
+ return 0;
+ }
+ if (overscroll === true || overscroll == null) {
+ return DEFAULT_OVERSCROLL;
+ }
+ return Math.max(overscroll, 0);
+};
+
+/**
+ * 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]`, expanded by `offsetStart`/`offsetEnd` at
+ * the low/high edge. When the box can't cover the viewport on this axis (the
+ * viewport is larger than the box) the box rests centered instead.
+ * `overscroll` is pure rubberband give: it widens the allowed range by that
+ * much on both sides of the resting position, regardless of the geometry.
+ */
+const constrainScrollAxis = (
+ scroll: number,
+ boxStart: number,
+ boxSize: number,
+ visibleSize: number,
+ offsetStart: number,
+ offsetEnd: number,
+ overscroll: number,
+): number => {
+ const max = -boxStart + offsetStart;
+ const min = visibleSize - (boxStart + boxSize) - offsetEnd;
+ if (min > max) {
+ // box can't cover the viewport: rest at center, with rubberband give
+ const center = (min + max) / 2;
+ return clamp(scroll, center - overscroll, center + overscroll);
+ }
+ return clamp(scroll, min - overscroll, max + overscroll);
+};
+
+/**
+ * Clamps a proposed scroll/zoom against the active lock (`scrollConstraints`).
+ * Returns the input scroll/zoom unchanged when there is no lock.
+ * `overscroll` (screen px, zoom-independent) is rubberband give past the
+ * resting clamp — pass 0 for a hard clamp.
+ */
+export const constrainScrollState = (
+ state: Pick<
+ AppState,
+ "scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
+ >,
+ overscroll = 0,
+): Viewport => {
+ const { scrollConstraints, width, height } = state;
+
+ if (!scrollConstraints) {
+ return { scrollX: state.scrollX, scrollY: state.scrollY, zoom: state.zoom };
+ }
+
+ const minZoom = scrollConstraints.lockZoom
+ ? scrollConstraints.zoom
+ : MIN_ZOOM;
+ const zoomValue = getNormalizedZoom(
+ clamp(state.zoom.value, minZoom, MAX_ZOOM),
+ );
+
+ if (!scrollConstraints.lockScroll) {
+ return {
+ scrollX: state.scrollX,
+ scrollY: state.scrollY,
+ zoom: { value: zoomValue },
+ };
+ }
+
+ const give = Math.max(overscroll, 0) / zoomValue;
+ const offsets = scrollConstraints.offsets;
+ const offsetTop = (offsets?.top ?? 0) / zoomValue;
+ const offsetRight = (offsets?.right ?? 0) / zoomValue;
+ const offsetBottom = (offsets?.bottom ?? 0) / zoomValue;
+ const offsetLeft = (offsets?.left ?? 0) / zoomValue;
+
+ return {
+ scrollX: constrainScrollAxis(
+ state.scrollX,
+ scrollConstraints.x,
+ scrollConstraints.width,
+ width / zoomValue,
+ offsetLeft,
+ offsetRight,
+ give,
+ ),
+ scrollY: constrainScrollAxis(
+ state.scrollY,
+ scrollConstraints.y,
+ scrollConstraints.height,
+ height / zoomValue,
+ offsetTop,
+ offsetBottom,
+ give,
+ ),
+ zoom: { value: zoomValue },
+ };
+};
+
+export const isViewportOverscrolled = (
+ state: Pick<
+ AppState,
+ "scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
+ >,
+): boolean => {
+ if (!state.scrollConstraints) {
+ return false;
+ }
+
+ const target = constrainScrollState(state); // hard clamp (no overscroll give)
+
+ return (
+ target.scrollX !== state.scrollX ||
+ target.scrollY !== state.scrollY ||
+ target.zoom.value !== state.zoom.value
+ );
+};
+
+/**
+ * Rubberband snap-back: animates the viewport from its current (possibly
+ * overscrolled) position back inside the lock box via the shared
+ * AnimationController. No-op when already within the hard bounds (or when there
+ * is no lock).
+ */
+export const animateToConstraints = (
+ state: Pick<
+ AppState,
+ "scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
+ >,
+ onFrame: (
+ viewport: Pick<
+ AppState,
+ "scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
+ >,
+ ) => void,
+ duration = DEFAULT_SCROLL_ANIMATION_DURATION,
+) => {
+ const target = constrainScrollState(state); // hard clamp (no overscroll give)
+
+ if (
+ target.scrollX === state.scrollX &&
+ target.scrollY === state.scrollY &&
+ target.zoom.value === state.zoom.value
+ ) {
+ return;
+ }
+
+ animateToViewport(state, target, duration, onFrame);
+};
+
+/**
+ * Scrolls (and, per `fit`, zooms) the viewport so the given target box is in
+ * view, optionally animating the transition. `onComplete` runs once the
+ * viewport has settled on the target.
+ */
+export const setViewportToBounds = (
+ state: AppState,
+ bounds: Bounds,
+ // NOTE offsets must be resolved (see `App.resolveViewportOffsets`)
+ opts: Pick & { offsets?: Offsets },
+ onFrame: (
+ state: Pick<
+ AppState,
+ "scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
+ >,
+ ) => void,
+ onComplete?: () => void,
+) => {
+ AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY);
+
+ const viewport = getTargetViewport(state, bounds, opts.fit, opts.offsets);
+
+ const duration = resolveAnimationDuration(opts.animation);
+
+ if (duration === null) {
+ // no animation: jump straight to the target. Re-enable zoom caching in
+ // case we just cancelled an in-flight animation that had suppressed it.
+ onFrame({ ...viewport, shouldCacheIgnoreZoom: false });
+ onComplete?.();
+ } else {
+ animateToViewport(state, viewport, duration, onFrame, onComplete);
+ }
+};
+
+const zoomValueToFitBoundsOnViewport = (
+ bounds: SceneBounds,
+ viewportDimensions: { width: number; height: number },
+) => {
+ const [x1, y1, x2, y2] = bounds;
+ const commonBoundsWidth = x2 - x1;
+ const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
+ const commonBoundsHeight = y2 - y1;
+ const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
+ const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
+
+ return Math.min(smallestZoomValue, 1);
+};
+
+export const zoomToFitBounds = ({
+ bounds,
+ appState,
+ canvasOffsets,
+ fit = "scale-down",
+ minZoom = -Infinity,
+ maxZoom = Infinity,
+ steppedZoom = false,
+}: {
+ bounds: SceneBounds;
+ canvasOffsets?: Offsets;
+ appState: Readonly;
+ fit?: SetViewportOptions["fit"];
+ minZoom?: number;
+ maxZoom?: number;
+ steppedZoom?: boolean;
+}) => {
+ const [x1, y1, x2, y2] = bounds;
+ const centerX = (x1 + x2) / 2;
+ const centerY = (y1 + y2) / 2;
+
+ const canvasOffsetLeft = canvasOffsets?.left ?? 0;
+ const canvasOffsetTop = canvasOffsets?.top ?? 0;
+ const canvasOffsetRight = canvasOffsets?.right ?? 0;
+ const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
+
+ const effectiveCanvasWidth =
+ appState.width - canvasOffsetLeft - canvasOffsetRight;
+ const effectiveCanvasHeight =
+ appState.height - canvasOffsetTop - canvasOffsetBottom;
+
+ let adjustedZoomValue;
+
+ if (fit === "none") {
+ // pan-only: keep the current zoom, just center the target
+ adjustedZoomValue = appState.zoom.value;
+ } else if (fit === "contain") {
+ const commonBoundsWidth = x2 - x1;
+ const commonBoundsHeight = y2 - y1;
+
+ adjustedZoomValue = Math.min(
+ effectiveCanvasWidth / commonBoundsWidth,
+ effectiveCanvasHeight / commonBoundsHeight,
+ );
+ } else {
+ adjustedZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
+ width: effectiveCanvasWidth,
+ height: effectiveCanvasHeight,
+ });
+ }
+
+ const targetZoomValue =
+ steppedZoom && fit !== "none"
+ ? roundToStep(adjustedZoomValue, ZOOM_STEP, "floor")
+ : adjustedZoomValue;
+
+ const newZoomValue = getNormalizedZoom(
+ clamp(targetZoomValue, minZoom, maxZoom),
+ );
+
+ const centerScroll = centerScrollOn({
+ scenePoint: { x: centerX, y: centerY },
+ viewportDimensions: {
+ width: appState.width,
+ height: appState.height,
+ },
+ offsets: canvasOffsets,
+ zoom: { value: newZoomValue },
+ });
+
+ return {
+ appState: {
+ ...appState,
+ scrollX: centerScroll.scrollX,
+ scrollY: centerScroll.scrollY,
+ zoom: { value: newZoomValue },
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+};
+
+/** Computes the viewport (scroll + zoom) that brings the target box into view,
+ * based on the requested fit behavior. */
+export const getTargetViewport = (
+ state: AppState,
+ bounds: Bounds,
+ fit: SetViewportOptions["fit"] = "scale-down",
+ offsets?: Offsets,
+): Viewport => {
+ const { appState } = zoomToFitBounds({
+ bounds,
+ appState: state,
+ fit,
+ canvasOffsets: offsets,
+ steppedZoom: false,
+ });
+
+ return {
+ scrollX: appState.scrollX,
+ scrollY: appState.scrollY,
+ zoom: appState.zoom,
+ };
+};
+
+const getElementsFromId = (
+ id: string,
+ elementsMap: NonDeletedSceneElementsMap,
+) => {
+ const element = elementsMap.get(id);
+ if (element) {
+ return [element];
+ }
+
+ return getElementsInGroup(elementsMap, id);
+};
+
+export type ResolvedViewportTarget = {
+ /** null when the target couldn't be resolved (unknown id/link, or all
+ * supplied elements deleted) */
+ bounds: Bounds | null;
+ /** how the target was specified, so callers can react to unresolved
+ * targets themselves (e.g. toast on a broken element link) */
+ type: "element" | "area" | "link";
+};
+
+/** Resolves a `setViewport` target to a scene-coordinate box. */
+export const resolveViewportTarget = (
+ target: SetViewportOptions["target"],
+ elementsMap: NonDeletedSceneElementsMap,
+ appState: Pick,
+): ResolvedViewportTarget => {
+ if (typeof target === "string") {
+ const isLink = isElementLink(target);
+ const type = isLink ? ("link" as const) : ("element" as const);
+ const id = isLink ? parseElementLinkFromURL(target) : target;
+ const resolved = id ? getElementsFromId(id, elementsMap) : [];
+
+ if (!resolved.length) {
+ return { bounds: null, type };
+ }
+
+ return { bounds: getCommonBounds(resolved, elementsMap), type };
+ }
+
+ if (isBounds(target)) {
+ return { bounds: target, type: "area" };
+ }
+
+ if (isSetViewportRect(target) && !isExcalidrawElement(target)) {
+ const width = target.width ?? appState.width;
+ const height = target.height ?? appState.height;
+ return {
+ bounds: [target.x, target.y, target.x + width, target.y + height],
+ type: "area",
+ };
+ }
+
+ // widening to null values in case the host app doesn't have
+ // noUncheckedIndexedAccess enabled
+ const targetElements: (ExcalidrawElement | undefined | null)[] =
+ Array.isArray(target) ? target : [target];
+ const elements = targetElements.reduce[]>(
+ (acc, element) => {
+ if (element && !element.isDeleted) {
+ const sceneElement = elementsMap.get(element.id);
+ if (sceneElement) {
+ acc.push(sceneElement);
+ }
+ }
+ return acc;
+ },
+ [],
+ );
+
+ const hasNoElements = !elements.length;
+ if (elements.length !== targetElements.length || hasNoElements) {
+ console.warn(
+ "supplied element target(s) for setViewport contain deleted or non-existent elements which have been filtered out",
+ );
+ }
+
+ if (hasNoElements) {
+ return { bounds: null, type: "element" };
+ }
+
+ return { bounds: getCommonBounds(elements, elementsMap), type: "element" };
+};
+
+/** Computes the viewport patch for landing on `bounds`: the target
+ * scroll/zoom, plus the scroll lock to install — or `scrollConstraints: null`
+ * to clear a previous lock when none is requested. */
+export const getConstrainedTargetViewport = (
+ appState: AppState,
+ bounds: Bounds,
+ // NOTE offsets must be resolved (see `App.resolveViewportOffsets`)
+ {
+ fit,
+ offsets,
+ lock,
+ }: Pick & { offsets?: Offsets },
+): Viewport & { scrollConstraints: ScrollConstraints | null } => {
+ const viewport = getTargetViewport(appState, bounds, fit, offsets);
+
+ if (!lock?.scroll && !lock?.zoom) {
+ return { ...viewport, scrollConstraints: null };
+ }
+
+ const [x1, y1, x2, y2] = bounds;
+ const scrollConstraints: ScrollConstraints = {
+ x: x1,
+ y: y1,
+ width: x2 - x1,
+ height: y2 - y1,
+ lockScroll: !!lock.scroll,
+ lockZoom: !!lock.zoom,
+ zoom: viewport.zoom.value,
+ overscroll: resolveOverscroll(lock.overscroll),
+ offsets,
+ };
+
+ return {
+ ...constrainScrollState({ ...appState, ...viewport, scrollConstraints }),
+ scrollConstraints,
+ };
+};
+
+/**
+ * Interpolates the viewport from `from` to `target` at the (already-eased)
+ * blend amount `factor` (0 = `from`, 1 = `target`).
+ *
+ * Zoom is interpolated geometrically (so it feels uniform), but the pan can't
+ * simply lerp alongside it: pairing a geometric zoom with a linear scroll (or
+ * a linearly-tweened focal point) makes scene points swoop along curved,
+ * non-monotone screen paths once the zoom ratio exceeds ~e (the destination
+ * visibly drifts away before converging). Instead we interpolate the view
+ * transform affinely — a scene point maps to screen as
+ * `(scenePt + scroll) * zoom`, and requiring every point to travel a straight
+ * screen line forces `zoom` to be a convex blend `(1-m)*z0 + m*z1` with
+ * `scroll * zoom` lerped by that same weight. Deriving `m` from the geometric
+ * zoom keeps its pacing while making all screen trajectories straight and
+ * monotone.
+ */
+export const interpolateViewport = ({
+ from,
+ target,
+ factor,
+}: {
+ from: Viewport;
+ target: Viewport;
+ factor: number;
+}): Viewport => {
+ if (factor >= 1) {
+ // land bit-exactly on the target (`z0 * (z1/z0)^1` can be off by an ulp)
+ return { ...target };
+ }
+
+ const zoom = (from.zoom.value *
+ Math.pow(
+ target.zoom.value / from.zoom.value,
+ factor,
+ )) as NormalizedZoomValue;
+
+ // pan blend weight derived from the zoom blend (0/0 for pure pans, hence
+ // the `factor` fallback; near-equal zooms are fine — the ratio limits to
+ // `factor` smoothly)
+ const m =
+ target.zoom.value === from.zoom.value
+ ? factor
+ : (zoom - from.zoom.value) / (target.zoom.value - from.zoom.value);
+
+ return {
+ scrollX:
+ ((1 - m) * from.scrollX * from.zoom.value +
+ m * target.scrollX * target.zoom.value) /
+ zoom,
+ scrollY:
+ ((1 - m) * from.scrollY * from.zoom.value +
+ m * target.scrollY * target.zoom.value) /
+ zoom,
+ zoom: { value: zoom },
+ };
+};
+
+/** Eases the viewport from its current position to `target` over `duration`,
+ * driving the transition through the shared AnimationController so it doesn't
+ * slow down other processes. */
+const animateToViewport = (
+ from: Viewport,
+ target: Viewport,
+ duration: number,
+ onFrame: (
+ state: Pick<
+ AppState,
+ "scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
+ >,
+ ) => void,
+ onComplete?: () => void,
+) => {
+ AnimationController.start<{ elapsed: number }>(
+ SCROLL_TO_CONTENT_ANIMATION_KEY,
+ ({ deltaTime, state }) => {
+ const elapsed = (state?.elapsed ?? 0) + deltaTime;
+ const progress = Math.min(elapsed / duration, 1);
+ const factor = easeOut(clamp(progress, 0, 1));
+
+ onFrame({
+ ...interpolateViewport({ from, target, factor }),
+ shouldCacheIgnoreZoom: progress < 1, // ignore zoom caching while animating
+ });
+
+ if (progress < 1) {
+ return { elapsed };
+ }
+
+ onComplete?.();
+
+ return null;
+ },
+ );
+};
+
+const doBoundsExceedViewport = (appState: AppState, bounds: Bounds) => {
+ const [x1, y1, x2, y2] = bounds;
+ return (
+ (x2 - x1) * appState.zoom.value > appState.width ||
+ (y2 - y1) * appState.zoom.value > appState.height
+ );
+};
+
+export const getClosestElementBounds = (
+ elements: readonly ExcalidrawElement[],
+ from: { x: number; y: number },
+): Bounds => {
+ if (!elements.length) {
+ return [0, 0, 0, 0];
+ }
+
+ let minDistance = Infinity;
+ let closestElement = elements[0];
+ const elementsMap = arrayToMap(elements);
+ elements.forEach((element) => {
+ const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
+ const distance = pointDistance(
+ pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
+ pointFrom(from.x, from.y),
+ );
+
+ if (distance < minDistance) {
+ minDistance = distance;
+ closestElement = element;
+ }
+ });
+
+ return getElementBounds(closestElement, elementsMap);
+};
+
+export const centerScrollOn = ({
+ scenePoint,
+ viewportDimensions,
+ zoom,
+ offsets,
+}: {
+ scenePoint: PointerCoords;
+ viewportDimensions: { height: number; width: number };
+ zoom: Zoom;
+ offsets?: Offsets;
+}) => {
+ let scrollX =
+ (viewportDimensions.width - (offsets?.right ?? 0)) / 2 / zoom.value -
+ scenePoint.x;
+
+ scrollX += (offsets?.left ?? 0) / 2 / zoom.value;
+
+ let scrollY =
+ (viewportDimensions.height - (offsets?.bottom ?? 0)) / 2 / zoom.value -
+ scenePoint.y;
+
+ scrollY += (offsets?.top ?? 0) / 2 / zoom.value;
+
+ return {
+ scrollX,
+ scrollY,
+ };
+};
+
+export const getScrollToContentState = (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+): { scrollX: number; scrollY: number } => {
+ elements = getVisibleElements(elements);
+
+ if (!elements.length) {
+ return {
+ scrollX: 0,
+ scrollY: 0,
+ };
+ }
+ let [x1, y1, x2, y2] = getCommonBounds(elements);
+
+ if (doBoundsExceedViewport(appState, [x1, y1, x2, y2])) {
+ [x1, y1, x2, y2] = getClosestElementBounds(
+ elements,
+ viewportCoordsToSceneCoords(
+ {
+ clientX: appState.offsetLeft + appState.width / 2,
+ clientY: appState.offsetTop + appState.height / 2,
+ },
+ appState,
+ ),
+ );
+ }
+
+ const centerX = (x1 + x2) / 2;
+ const centerY = (y1 + y2) / 2;
+
+ return centerScrollOn({
+ scenePoint: { x: centerX, y: centerY },
+ viewportDimensions: { width: appState.width, height: appState.height },
+ zoom: appState.zoom,
+ });
+};
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,