feat(packages/excalidraw): viewport locking (#11554)

Co-authored-by: barnabasmolnar <barnabas@excalidraw.com>
Co-authored-by: Arnost Pleskot <arnostpleskot@gmail.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Márk Tolmács
2026-07-03 22:57:47 +02:00
committed by GitHub
co-authored by barnabasmolnar Arnost Pleskot dwelle
parent cce5001aaa
commit 51ca8abde4
28 changed files with 2771 additions and 710 deletions
@@ -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
</button>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
fitToContent: true,
});
excalidrawAPI.scrollToContent(elements[0]);
}}
>
Scroll to first element, no fitToContent, no fitToViewport
</button>
<div className="export export-canvas">
<img src={canvasUrl} alt="" />
</div>
+5 -1
View File
@@ -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,
});
}
}}
>
+1 -2
View File
@@ -652,8 +652,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
appState: zoomToFitBounds({
appState,
bounds: sceneBounds,
fitToViewport: true,
viewportZoomFactor: 1,
fit: "contain",
}).appState,
});
-28
View File
@@ -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;
+49 -6
View File
@@ -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 `<Excalidraw initialState={{ viewport }} />` 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 `<Excalidraw onThemeChange={(theme) => ...} />` 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 `<ExcalidrawAPIProvider/>`, `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 `<ExcalidrawAPIProvider/>`, `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
<ExcalidrawAPIProvider>
@@ -62,12 +101,12 @@ Please add the latest change on the top under the correct section.
</ExcalidrawAPIProvider>;
function Logger() {
// initially null before the ExcalidrawAPIProvider initializes ater
// initially null before the ExcalidrawAPIProvider initializes after
// <Excalidraw/> renders
// When <Excalidraw/> 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
+77 -190
View File
@@ -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<Partial<AppState>>({
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<AppState>;
/** 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<AppState>;
/** 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 &&
+2
View File
@@ -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 },
+468 -129
View File
@@ -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<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(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<AppProps, AppState> {
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<HTMLDivElement>();
public scene: Scene;
@@ -759,7 +795,8 @@ class App extends React.Component<AppProps, AppState> {
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<AppProps, AppState> {
["--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<AppProps, AppState> {
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<AppProps, AppState> {
});
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<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
);
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<AppProps, AppState> {
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<AppProps, AppState> {
*/
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<ExcalidrawElement>[];
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<ExcalidrawElement>];
} 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<AppProps, AppState> {
},
});
}
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<AppProps, AppState> {
};
/** use when changing scrollX/scrollY/zoom based on user interaction */
private translateCanvas: React.Component<any, AppState>["setState"] = (
state,
private translateCanvas = <K extends keyof AppState>(
state:
| AppState
| Pick<AppState, K>
| null
| ((
prevState: Readonly<AppState>,
props: Readonly<AppProps>,
) => AppState | Pick<AppState, K> | 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<AppProps, AppState> {
},
);
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<ViewportUIName>();
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<HTMLElement>(
"[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<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
: 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<AppProps, AppState> {
},
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<AppProps, AppState> {
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<AppProps, AppState> {
// 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<AppProps, AppState> {
cb && cb();
},
);
// a smaller viewport may push the min zoom up / shrink the pan range
this.constrainViewportToScrollConstraints();
}
};
+21 -1
View File
@@ -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<HTMLDivElement, IslandProps>(
({ children, padding, className, style }, ref) => (
(
{
children,
padding,
className,
style,
"data-viewport-ui": viewportUI,
"data-viewport-ui-name": viewportUIName,
},
ref,
) => (
<div
className={clsx("Island", className)}
style={{ "--padding": padding, ...style }}
data-viewport-ui={viewportUI}
data-viewport-ui-name={viewportUIName}
ref={ref}
>
{children}
+7 -2
View File
@@ -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 = ({
<Island
className={clsx("compact-shape-actions-island")}
padding={0}
data-viewport-ui="side"
data-viewport-ui-name="stylesPanel"
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
@@ -269,6 +271,8 @@ const LayerUI = ({
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
data-viewport-ui="side"
data-viewport-ui-name="stylesPanel"
>
<SelectedShapeActions
appState={appState}
@@ -349,6 +353,7 @@ const LayerUI = ({
"zen-mode": appState.zenModeEnabled,
"App-toolbar--compact": isCompactStylesPanel,
})}
data-viewport-ui="top"
>
<HintViewer
appState={appState}
@@ -642,7 +647,7 @@ const LayerUI = ({
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState),
...getScrollToContentState(elements, appState),
}));
}}
>
@@ -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"
>
<MobileShapeActions
appState={appState}
@@ -161,7 +162,7 @@ export const MobileMenu = ({
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState),
...getScrollToContentState(elements, appState),
}));
}}
>
+16 -23
View File
@@ -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<AppClassProperties["scrollToContent"]>[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 },
});
}
}
@@ -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}
>
<SidebarPropsContext.Provider value={headerPropsRef.current}>
@@ -160,7 +160,7 @@ export const insertToEditor = ({
elements: newElements,
files,
position: "center",
fitToContent: true,
fit: "scale-down",
});
app.setOpenDialog(null);
+2 -2
View File
@@ -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,
+4 -1
View File
@@ -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,
+1 -1
View File
@@ -4,7 +4,7 @@ export {
getSelectedElements,
getTargetElements,
} from "@excalidraw/element";
export { calculateScrollCenter } from "./scroll";
export { getScrollToContentState } from "../viewport";
export {
hasBackground,
hasStrokeWidth,
-92
View File
@@ -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<number>) => {
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,
});
};
-180
View File
@@ -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<AppState, "scrollX" | "scrollY" | "zoom">;
/**
* 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<AppState, "scrollX" | "scrollY" | "zoom" | "width" | "height">;
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<AppState, "scrollX" | "scrollY" | "zoom" | "width" | "height">,
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;
},
);
};
@@ -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,
@@ -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,
@@ -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,
+81 -21
View File
@@ -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(<Excalidraw />);
@@ -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(<Excalidraw />);
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(<Excalidraw />);
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();
+53
View File
@@ -57,6 +57,59 @@ describe("appState", () => {
restoreOriginalGetBoundingClientRect();
});
it("initialState.viewport on init takes precedence over scrollToContent", async () => {
mockBoundingClientRect();
await render(
<Excalidraw
initialData={{
elements: [
API.createElement({
type: "rectangle",
id: "A",
width: 100,
height: 60,
}),
API.createElement({
type: "rectangle",
id: "B",
x: 1000,
y: 500,
width: 200,
height: 100,
}),
],
scrollToContent: true,
}}
initialState={{
viewport: {
target: "B",
fit: "scale-down",
lock: { scroll: true, zoom: true, overscroll: 15 },
},
}}
/>,
);
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(<Excalidraw handleKeyboardGlobally={true} />, {});
@@ -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<AppState> & {
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<ScrollConstraints> &
Pick<ScrollConstraints, "x" | "y" | "width" | "height">,
): 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(<Excalidraw handleKeyboardGlobally={true} />);
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(<Excalidraw />);
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(<Excalidraw />);
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(<Excalidraw />);
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(<Excalidraw />);
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(<Excalidraw />);
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(<Excalidraw />);
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(<Excalidraw handleKeyboardGlobally={true} />);
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(<Excalidraw handleKeyboardGlobally={true} />);
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(<Excalidraw handleKeyboardGlobally={true} />);
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(<Excalidraw handleKeyboardGlobally={true} />);
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(<Excalidraw />);
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);
});
});
+150 -3
View File
@@ -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<SetViewportOptions, "animation">;
};
export type OnUserFollowedPayload = {
userToFollow: UserToFollow;
action: "FOLLOW" | "UNFOLLOW";
@@ -584,6 +626,7 @@ export interface ExcalidrawProps {
initialData?:
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
| MaybePromise<ExcalidrawInitialDataState | null>;
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<typeof App>["state"];
getFiles: () => InstanceType<typeof App>["files"];
getName: InstanceType<typeof App>["getName"];
scrollToContent: InstanceType<typeof App>["scrollToContent"];
setViewport: InstanceType<typeof App>["setViewport"];
getViewportOffsets: InstanceType<typeof App>["getViewportOffsets"];
registerAction: (action: Action) => void;
refresh: InstanceType<typeof App>["refresh"];
setToast: InstanceType<typeof App>["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";
+766
View File
@@ -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<SetViewportRect>;
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<AppState, "scrollX" | "scrollY" | "zoom">;
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<SetViewportOptions, "fit" | "animation"> & { 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<AppState>;
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<AppState, "width" | "height">,
): 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<NonDeleted<ExcalidrawElement>[]>(
(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<SetViewportOptions, "fit" | "lock"> & { 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,
});
};
@@ -86,6 +86,7 @@ exports[`exportToSvg > with default arguments 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,