Compare commits

..

1 Commits

Author SHA1 Message Date
Dany Valverde Caldas 4ce70b815e fix(editor): ensure canvas is cleared when background color is invalid to prevent ghosting (#11458)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-26 22:59:17 +02:00
16 changed files with 166 additions and 1143 deletions
+1 -4
View File
@@ -1014,10 +1014,7 @@ const ExcalidrawWrapper = () => {
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter
excalidrawAPI={excalidrawAPI}
onChange={() => excalidrawAPI?.refresh()}
/>
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
<TTDDialogTrigger />
+2 -121
View File
@@ -1,131 +1,13 @@
import { Footer } from "@excalidraw/excalidraw/index";
import {
getCommonBounds,
getSelectedElements,
getVisibleSceneBounds,
} from "@excalidraw/element";
import React, { useCallback, useState } from "react";
import type {
ExcalidrawImperativeAPI,
ScrollConstraints,
} from "@excalidraw/excalidraw/types";
import React from "react";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { EncryptedIcon } from "./EncryptedIcon";
const ScrollConstraintsDebugFooter = ({
excalidrawAPI,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
}) => {
const [activeLock, setActiveLock] = useState<ScrollConstraints | null>(null);
const [tolerance, setTolerance] = useState(0);
const setLock = useCallback(
(nextLock: ScrollConstraints) => {
// pass an empty target so the constraints are applied without scrolling
excalidrawAPI?.scrollToContent([], { scrollConstraints: nextLock });
setActiveLock(nextLock);
},
[excalidrawAPI],
);
const toggleScrollLock = useCallback(() => {
if (!excalidrawAPI) {
return;
}
if (activeLock) {
excalidrawAPI.scrollToContent([], { scrollConstraints: null });
setActiveLock(null);
return;
}
const selectedElements = getSelectedElements(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
const [x1, y1, x2, y2] = selectedElements.length
? getCommonBounds(
selectedElements,
excalidrawAPI.getSceneElementsMapIncludingDeleted(),
)
: getVisibleSceneBounds(excalidrawAPI.getAppState());
setLock({
x: x1,
y: y1,
width: x2 - x1,
height: y2 - y1,
tolerance,
});
}, [activeLock, excalidrawAPI, setLock, tolerance]);
const updateTolerance = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const nextTolerance = Number(event.target.value) || 0;
setTolerance(nextTolerance);
if (activeLock) {
setLock({ ...activeLock, tolerance: nextTolerance });
}
},
[activeLock, setLock],
);
return (
<div
style={{
display: "flex",
gap: ".35rem",
alignItems: "center",
padding: "0 .35rem",
fontSize: 12,
}}
>
<button
className="ToolIcon_type_button"
type="button"
onClick={toggleScrollLock}
>
{activeLock ? "disable scroll lock" : "lock scroll"}
</button>
<label
style={{
display: "flex",
gap: ".25rem",
alignItems: "center",
}}
>
tolerance
<input
type="number"
min={0}
step={1}
value={tolerance}
onChange={updateTolerance}
style={{ width: 56 }}
/>
</label>
</div>
);
};
export const AppFooter = React.memo(
({
excalidrawAPI,
onChange,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
onChange: () => void;
}) => {
({ onChange }: { onChange: () => void }) => {
return (
<Footer>
<div
@@ -135,7 +17,6 @@ export const AppFooter = React.memo(
alignItems: "center",
}}
>
<ScrollConstraintsDebugFooter excalidrawAPI={excalidrawAPI} />
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
</div>
+28 -42
View File
@@ -45,7 +45,6 @@ import { t } from "../i18n";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { constrainScrollState } from "../scroll";
import { getShortcutKey } from "../shortcut";
import { register } from "./register";
@@ -139,20 +138,19 @@ 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: { ...nextState, ...constrainScrollState(nextState) },
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,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
@@ -181,20 +179,19 @@ 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: { ...nextState, ...constrainScrollState(nextState) },
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,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
@@ -222,8 +219,6 @@ export const actionResetZoom = register({
icon: ZoomResetIcon,
viewMode: true,
trackEvent: { category: "canvas" },
// resetting to 100% may violate active scroll constraints
predicate: (elements, appState) => !appState.scrollConstraints,
perform: (_elements, appState, _, app) => {
return {
appState: {
@@ -248,8 +243,6 @@ export const actionResetZoom = register({
className="reset-zoom-button zoom-button"
title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")}
// keep the zoom level visible, but non-resettable while constrained
disabled={!!appState.scrollConstraints}
onClick={() => {
updateData(null);
}}
@@ -258,8 +251,7 @@ export const actionResetZoom = register({
</ToolButton>
</Tooltip>
),
keyTest: (event, appState) =>
!appState.scrollConstraints &&
keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
});
@@ -404,7 +396,6 @@ 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({
@@ -419,8 +410,7 @@ export const actionZoomToFitSelectionInViewport = register({
},
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
// TBD on how proceed
keyTest: (event, appState) =>
!appState.scrollConstraints &&
keyTest: (event) =>
event.code === CODES.TWO &&
event.shiftKey &&
!event.altKey &&
@@ -432,7 +422,6 @@ 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({
@@ -446,8 +435,7 @@ export const actionZoomToFitSelection = register({
});
},
// NOTE this action should use shift-2 per figma, alas
keyTest: (event, appState) =>
!appState.scrollConstraints &&
keyTest: (event) =>
event.code === CODES.THREE &&
event.shiftKey &&
!event.altKey &&
@@ -460,7 +448,6 @@ export const actionZoomToFit = register({
icon: zoomAreaIcon,
viewMode: true,
trackEvent: { category: "canvas" },
predicate: (elements, appState) => !appState.scrollConstraints,
perform: (elements, appState, _, app) =>
zoomToFit({
targetElements: elements,
@@ -471,8 +458,7 @@ export const actionZoomToFit = register({
fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}),
keyTest: (event, appState) =>
!appState.scrollConstraints &&
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
!event.altKey &&
-2
View File
@@ -91,7 +91,6 @@ export const getDefaultAppState = (): Omit<
scrolledOutside: false,
scrollX: 0,
scrollY: 0,
scrollConstraints: null,
selectedElementIds: {},
hoveredElementIds: {},
selectedGroupIds: {},
@@ -227,7 +226,6 @@ 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 },
+2 -68
View File
@@ -415,9 +415,6 @@ import {
type ScrollToContentOptions,
SCROLL_TO_CONTENT_ANIMATION_KEY,
scrollToElements,
constrainScrollState,
animateToConstraints,
isViewportOverscrolled,
} from "../scroll";
import {
setEraserCursor,
@@ -611,10 +608,6 @@ 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;
@@ -4368,16 +4361,6 @@ class App extends React.Component<AppProps, AppState> {
elements = this.scene.getNonDeletedElements();
}
const scrollConstraints = opts?.scrollConstraints ?? null;
if (scrollConstraints || this.state.scrollConstraints) {
flushSync(() => {
this.setState((prevState) => ({
scrollConstraints,
...constrainScrollState({ ...prevState, scrollConstraints }),
}));
});
}
if (!elements.length) {
if (typeof target === "string" && isElementLink(target)) {
this.setState({
@@ -4427,39 +4410,8 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ shouldCacheIgnoreZoom: false });
this.maybeUnfollowRemoteUser();
this.setState(state);
// with rubberband tolerance, allow a soft overscroll while interacting and
// animate back to the box once the interaction settles; otherwise hard-clamp
const tolerance = this.state.scrollConstraints?.tolerance ?? 0;
this.constrainViewportToScrollConstraints(tolerance);
if (tolerance > 0) {
this.snapBackToScrollConstraintsDebounced();
}
};
/** clamps scroll/zoom back into `appState.scrollConstraints` (no-op when
* unconstrained). Runs as a queued update, so it sees the preceding change.
* `tolerance` (screen px) relaxes the bounds for rubberbanding. */
private constrainViewportToScrollConstraints = (tolerance = 0) => {
this.setState((prevState) =>
prevState.scrollConstraints
? constrainScrollState(prevState, tolerance)
: null,
);
};
/** animates an overscrolled viewport back inside the constraint box */
private snapBackToScrollConstraints = () => {
if (!this.unmounted) {
animateToConstraints(this.state, (viewport) => this.setState(viewport));
}
};
private snapBackToScrollConstraintsDebounced = debounce(
this.snapBackToScrollConstraints,
SCROLL_CONSTRAINTS_SNAP_BACK_DELAY,
);
setToast = (toast: AppState["toast"]) => {
this.setState({ toast });
};
@@ -5638,15 +5590,9 @@ 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.translateCanvas((state) => ({
this.setState((state) => ({
...getStateForZoom(
{
viewportX: this.lastViewportPosition.x,
@@ -6894,11 +6840,7 @@ class App extends React.Component<AppProps, AppState> {
? 1
: distance / gesture.initialDistance;
// while rubberband-overscrolled past the scroll constraints, pin the zoom
// (still allowing the pan below) until the viewport has snapped back
const nextZoom = isViewportOverscrolled(this.state)
? this.state.zoom.value
: scaleFactor
const nextZoom = scaleFactor
? getNormalizedZoom(initialScale * scaleFactor)
: this.state.zoom.value;
@@ -12792,12 +12734,6 @@ 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);
@@ -12951,8 +12887,6 @@ class App extends React.Component<AppProps, AppState> {
cb && cb();
},
);
// a smaller viewport may push the min zoom up / shrink the pan range
this.constrainViewportToScrollConstraints();
}
};
@@ -0,0 +1,90 @@
import { COLOR_WHITE } from "@excalidraw/common";
import { bootstrapCanvas } from "./helpers";
const setup = () => {
const canvas = document.createElement("canvas");
canvas.width = 200;
canvas.height = 100;
const context = canvas.getContext("2d")!;
const clearRect = vi.spyOn(context, "clearRect");
const fillRect = vi.spyOn(context, "fillRect");
return { canvas, context, clearRect, fillRect };
};
const run = (viewBackgroundColor: unknown) => {
const { canvas, context, clearRect, fillRect } = setup();
bootstrapCanvas({
canvas,
scale: 1,
normalizedWidth: 200,
normalizedHeight: 100,
viewBackgroundColor: viewBackgroundColor as string,
});
return { context, clearRect, fillRect };
};
describe("bootstrapCanvas background painting", () => {
it("skips clearRect for an opaque hex color (fill fully repaints)", () => {
const { clearRect, fillRect } = run("#ffffff");
expect(clearRect).not.toHaveBeenCalled();
expect(fillRect).toHaveBeenCalledTimes(1);
});
it("skips clearRect for a 3-digit opaque hex color", () => {
const { clearRect, fillRect } = run("#fff");
expect(clearRect).not.toHaveBeenCalled();
expect(fillRect).toHaveBeenCalledTimes(1);
});
it("clears for a hex color with alpha (#RGBA / #RRGGBBAA)", () => {
expect(run("#ffff").clearRect).toHaveBeenCalledTimes(1);
expect(run("#ffffff80").clearRect).toHaveBeenCalledTimes(1);
});
it("clears and skips fill for the transparent keyword", () => {
const { clearRect, fillRect } = run("transparent");
expect(clearRect).toHaveBeenCalledTimes(1);
expect(fillRect).not.toHaveBeenCalled();
});
it("clears for rgba()/hsla() colors and still fills", () => {
const rgba = run("rgba(255, 0, 0, 0.5)");
expect(rgba.clearRect).toHaveBeenCalledTimes(1);
expect(rgba.fillRect).toHaveBeenCalledTimes(1);
});
// the ghosting bug (#10931): a corrupted value must never leave the prior
// frame on screen — we always clear when we can't prove the color is opaque
it("clears for a corrupted color value to prevent ghosting", () => {
expect(run("0000").clearRect).toHaveBeenCalledTimes(1);
expect(run("asdfgh").clearRect).toHaveBeenCalledTimes(1);
});
it("falls back to white when the color is rejected by the canvas", () => {
const { canvas, context } = setup();
// simulate a stale fillStyle left over from a previous frame's drawing
context.fillStyle = "#ff0000";
let fillStyleAtFillTime = "";
vi.spyOn(context, "fillRect").mockImplementation(() => {
fillStyleAtFillTime = context.fillStyle as string;
});
bootstrapCanvas({
canvas,
scale: 1,
normalizedWidth: 200,
normalizedHeight: 100,
viewBackgroundColor: "not-a-color",
});
// not the stale red — the seeded default
expect(fillStyleAtFillTime).toBe(COLOR_WHITE);
});
it("clears for a non-string background", () => {
const { clearRect, fillRect } = run(undefined);
expect(clearRect).toHaveBeenCalledTimes(1);
expect(fillRect).not.toHaveBeenCalled();
});
});
+24 -14
View File
@@ -1,4 +1,4 @@
import { THEME, applyDarkModeFilter } from "@excalidraw/common";
import { COLOR_WHITE, THEME, applyDarkModeFilter } from "@excalidraw/common";
import type { StaticCanvasRenderConfig } from "../scene/types";
import type { AppState, StaticCanvasAppState } from "../types";
@@ -53,21 +53,31 @@ export const bootstrapCanvas = ({
// Paint background
if (typeof viewBackgroundColor === "string") {
const hasTransparence =
viewBackgroundColor === "transparent" ||
viewBackgroundColor.length === 5 || // #RGBA
viewBackgroundColor.length === 9 || // #RRGGBBA
/(hsla|rgba)\(/.test(viewBackgroundColor);
if (hasTransparence) {
// An opaque fill repaints every pixel, so clearRect would be redundant.
// For anything else — transparency, or a value we can't be certain about
// (e.g. corrupted persisted state like "0000") — clear first so the
// previous frame can't bleed through.
//
// We skip opaque #RRGGBB and #RGB hex colors as a quick optimization.
const isOpaque = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(viewBackgroundColor);
if (!isOpaque) {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
context.save();
context.fillStyle = applyDarkModeFilter(
viewBackgroundColor,
theme === THEME.DARK,
);
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore();
if (viewBackgroundColor !== "transparent") {
context.save();
// The canvas silently ignores an invalid fillStyle, which would leave a
// stale color from a previous draw. Seed a sane default so corrupted
// values fall back to white instead of painting garbage.
context.fillStyle = COLOR_WHITE;
context.fillStyle = applyDarkModeFilter(
viewBackgroundColor,
theme === THEME.DARK,
);
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore();
}
} else {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
+8 -1
View File
@@ -1,5 +1,6 @@
import {
applyDarkModeFilter,
COLOR_WHITE,
FRAME_STYLE,
THEME,
throttleRAF,
@@ -204,7 +205,13 @@ const renderLinkIcon = (
window.devicePixelRatio * appState.zoom.value,
window.devicePixelRatio * appState.zoom.value,
);
linkCanvasCacheContext.fillStyle = appState.viewBackgroundColor || "#fff";
// Seed a sane default so a corrupted color (silently rejected by the
// canvas) falls back to white instead of a stale fillStyle.
linkCanvasCacheContext.fillStyle = COLOR_WHITE;
linkCanvasCacheContext.fillStyle =
appState.viewBackgroundColor || COLOR_WHITE;
linkCanvasCacheContext.fillRect(0, 0, width, height);
if (canvasKey === "elementLink") {
+10 -196
View File
@@ -1,19 +1,13 @@
import { easeOut, MAX_ZOOM, MIN_ZOOM } from "@excalidraw/common";
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 { getNormalizedZoom } from "./scene";
import { calculateScrollCenter } from "./scene/scroll";
import type {
AppState,
NormalizedZoomValue,
Offsets,
ScrollConstraints,
} from "./types";
import type { AppState, NormalizedZoomValue, Offsets } from "./types";
export const SCROLL_TO_CONTENT_ANIMATION_KEY = "animateScrollToContent";
@@ -41,189 +35,10 @@ export type ScrollToContentOptions = (
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
scrollConstraints?: ScrollConstraints | null;
};
type Viewport = Pick<AppState, "scrollX" | "scrollY" | "zoom">;
/**
* The smallest zoom at which the constraint box still fully covers the viewport.
* Below this, the viewport would extend past the box on its binding dimension,
* so it becomes the enforced minimum zoom. When the box is smaller than the
* viewport this is > 1, forcing a zoom-in (best-effort fit).
*/
export const getMinZoomForConstraints = (
constraints: ScrollConstraints,
viewport: { width: number; height: number },
): number =>
clamp(
Math.max(
viewport.width / constraints.width,
viewport.height / constraints.height,
),
MIN_ZOOM,
MAX_ZOOM,
);
/**
* Clamps a single scroll axis so the visible scene span stays inside the box.
* The visible span is `[-scroll, -scroll + visibleSize]`; we keep it within
* `[boxStart, boxStart + boxSize]`, expanded by `startExpand` at the low edge
* and `endExpand` at the high edge (rubberband overscroll plus any padding).
* When the box can't cover the viewport on this axis (only at the MAX_ZOOM cap
* for a tiny box) we center the box instead.
*/
const constrainScrollAxis = (
scroll: number,
boxStart: number,
boxSize: number,
visibleSize: number,
startExpand: number,
endExpand: number,
): number => {
const max = -boxStart + startExpand;
const min = visibleSize - (boxStart + boxSize) - endExpand;
return min > max ? (min + max) / 2 : clamp(scroll, min, max);
};
/**
* Clamps a proposed scroll/zoom so that, when `scrollConstraints` is set, the
* viewport cannot pan or zoom out of the box. Returns the input scroll/zoom
* unchanged when there are no constraints. Because the whole viewport is kept
* inside the box, any zoom anchor (which lives within the viewport) is also
* guaranteed to stay inside the box.
*
* `tolerance` is the rubberband overscroll allowance, in screen pixels: the
* viewport may pan past the box edges (and zoom out below the fit zoom) until
* each edge has crossed the box by up to `tolerance` pixels on screen,
* independent of the current zoom. Pass `0` (default) for a hard clamp.
*
* An explicit `minZoom`/`maxZoom` on the constraints takes precedence over the
* zoom the box would otherwise enforce: `minZoom` replaces the fit zoom (so the
* viewport may zoom out past the box) and `maxZoom` replaces the global
* `MAX_ZOOM` cap.
*
* `padding` ([top, right, bottom, left], screen px) permanently extends the
* scrollable area past each box edge by that many on-screen pixels, so the
* viewport can reveal that much empty space beyond the box. Unlike `tolerance`
* it does not snap back.
*/
export const constrainScrollState = (
state: Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
>,
tolerance = 0,
): Viewport => {
const { scrollConstraints, width, height } = state;
if (!scrollConstraints) {
return { scrollX: state.scrollX, scrollY: state.scrollY, zoom: state.zoom };
}
tolerance = Math.max(tolerance, 0);
// zoom bounds: an explicit min/maxZoom on the constraints takes precedence
// over the box-enforced fit zoom (lower) and the global MAX_ZOOM (upper).
// Without a `minZoom`, relax the fit zoom by `tolerance` so the user can
// briefly zoom out past it (rubberbanding), letting each viewport edge cross
// the box by up to `tolerance` screen px.
const minZoom =
scrollConstraints.minZoom ??
getMinZoomForConstraints(scrollConstraints, {
width: width - 2 * tolerance,
height: height - 2 * tolerance,
});
const maxZoom = scrollConstraints.maxZoom ?? MAX_ZOOM;
const zoomValue = getNormalizedZoom(
clamp(state.zoom.value, minZoom, maxZoom),
);
// `tolerance`/`padding` are screen px; express them in scene coords at the
// current zoom so the on-screen amount stays the same regardless of zoom.
const overscroll = tolerance / zoomValue;
const [padTop, padRight, padBottom, padLeft] = (
scrollConstraints.padding ?? [0, 0, 0, 0]
).map((px) => px / zoomValue);
return {
scrollX: constrainScrollAxis(
state.scrollX,
scrollConstraints.x,
scrollConstraints.width,
width / zoomValue,
overscroll + padLeft,
overscroll + padRight,
),
scrollY: constrainScrollAxis(
state.scrollY,
scrollConstraints.y,
scrollConstraints.height,
height / zoomValue,
overscroll + padTop,
overscroll + padBottom,
),
zoom: { value: zoomValue },
};
};
/**
* Whether the viewport currently sits outside the hard constraint bounds, i.e.
* the user is rubberband-overscrolled (panned past an edge or zoomed out below
* the fit zoom). Always `false` when there are no constraints. Used to suppress
* zooming until the viewport has settled back inside the box.
*/
export const isViewportOverscrolled = (
state: Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
>,
): boolean => {
if (!state.scrollConstraints) {
return false;
}
const target = constrainScrollState(state); // hard clamp (tolerance 0)
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 constraint box via the shared
* AnimationController. No-op when already within the hard bounds (or when there
* are no constraints).
*/
export const animateToConstraints = (
state: Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
>,
onFrame: (
viewport: Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
>,
) => void,
duration = DEFAULT_ANIMATION_DURATION,
) => {
const target = constrainScrollState(state); // hard clamp (tolerance 0)
if (
target.scrollX === state.scrollX &&
target.scrollY === state.scrollY &&
target.zoom.value === state.zoom.value
) {
return;
}
animateToViewport(state, target, duration, onFrame);
};
/**
* Scrolls (and optionally zooms) the viewport so that the given target is in
* view, optionally animating the transition.
@@ -264,8 +79,6 @@ const getTargetViewport = (
targetElements: readonly ExcalidrawElement[],
opts?: ScrollToContentOptions,
): Viewport => {
let viewport: Viewport;
if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({
canvasOffsets: opts.canvasOffsets,
@@ -277,19 +90,17 @@ const getTargetViewport = (
maxZoom: opts.maxZoom,
});
viewport = {
return {
scrollX: appState.scrollX,
scrollY: appState.scrollY,
zoom: appState.zoom,
};
} else {
// keep the current zoom, only recenter the viewport on the target
const { scrollX, scrollY } = calculateScrollCenter(targetElements, state);
viewport = { scrollX, scrollY, zoom: state.zoom };
}
// keep the target inside any active scroll constraints (no-op otherwise)
return constrainScrollState({ ...state, ...viewport });
// keep the current zoom, only recenter the viewport on the target
const { scrollX, scrollY } = calculateScrollCenter(targetElements, state);
return { scrollX, scrollY, zoom: state.zoom };
};
/**
@@ -360,6 +171,9 @@ const animateToViewport = (
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,7 +962,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1160,7 +1159,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": true,
@@ -1376,7 +1374,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1709,7 +1706,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2042,7 +2038,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": true,
@@ -2258,7 +2253,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2501,7 +2495,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2803,7 +2796,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3175,7 +3167,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3697,7 +3688,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4022,7 +4012,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4349,7 +4338,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5636,7 +5624,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -6855,7 +6842,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7815,7 +7801,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8814,7 +8799,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": true,
@@ -9810,7 +9794,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -86,7 +86,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"id4": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -722,7 +721,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"id4": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -1287,7 +1285,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -1649,7 +1646,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -2013,7 +2009,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -2275,7 +2270,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -2767,7 +2761,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -3072,7 +3065,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -3393,7 +3385,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -3689,7 +3680,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -3977,7 +3967,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -4214,7 +4203,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -4473,7 +4461,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -4746,7 +4733,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -4977,7 +4963,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -5208,7 +5193,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -5457,7 +5441,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -5715,7 +5698,6 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -5974,7 +5956,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id1": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -6305,7 +6286,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id8": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -6734,7 +6714,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id1": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -7111,7 +7090,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -7422,7 +7400,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -7717,7 +7694,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -7949,7 +7925,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -8303,7 +8278,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -8660,7 +8634,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -9065,7 +9038,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -9354,7 +9326,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -9620,7 +9591,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -9887,7 +9857,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -10124,7 +10093,6 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -10420,7 +10388,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -10740,7 +10707,6 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -10981,7 +10947,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -11905,7 +11870,6 @@ exports[`history > multiplayer undo/redo > should update history entries after r
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -12167,7 +12131,6 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -12404,7 +12367,6 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -12643,7 +12605,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -13051,7 +13012,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -13260,7 +13220,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -13472,7 +13431,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -13772,7 +13730,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -14075,7 +14032,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": -50,
"scrollY": -50,
"searchMatches": null,
@@ -14319,7 +14275,6 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -14558,7 +14513,6 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -14799,7 +14753,6 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -15046,7 +14999,6 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -15382,7 +15334,6 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -15551,7 +15502,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -15840,7 +15790,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -16105,7 +16054,6 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -16259,7 +16207,6 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -16544,7 +16491,6 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -16707,7 +16653,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -17458,7 +17403,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -18107,7 +18051,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -18754,7 +18697,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -19508,7 +19450,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -20279,7 +20220,6 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -20761,7 +20701,6 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"id1": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -21274,7 +21213,6 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -21735,7 +21673,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -89,7 +89,6 @@ 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,
@@ -517,7 +516,6 @@ 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,
@@ -931,7 +929,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1499,7 +1496,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1710,7 +1706,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2096,7 +2091,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2341,7 +2335,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2525,7 +2518,6 @@ exports[`regression tests > can drag element that covers another element, while
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2850,7 +2842,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3109,7 +3100,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3352,7 +3342,6 @@ 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,
@@ -3590,7 +3579,6 @@ 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,
@@ -3850,7 +3838,6 @@ 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,
@@ -4164,7 +4151,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4605,7 +4591,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4890,7 +4875,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5167,7 +5151,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5377,7 +5360,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5577,7 +5559,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5975,7 +5956,6 @@ exports[`regression tests > drags selected elements from point inside common bou
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -6271,7 +6251,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7069,7 +7048,6 @@ 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,
@@ -7404,7 +7382,6 @@ 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,
@@ -7685,7 +7662,6 @@ 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,
@@ -7922,7 +7898,6 @@ 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,
@@ -8162,7 +8137,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8344,7 +8318,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8526,7 +8499,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8708,7 +8680,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8943,7 +8914,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9176,7 +9146,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9374,7 +9343,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9609,7 +9577,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9791,7 +9758,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -10024,7 +9990,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -10206,7 +10171,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -10404,7 +10368,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -10590,7 +10553,6 @@ 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,
@@ -11121,7 +11083,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -11401,7 +11362,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": "-6.25000",
"scrollY": 0,
"scrolledOutside": false,
@@ -11528,7 +11488,6 @@ exports[`regression tests > shift click on selected element should deselect it o
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -11731,7 +11690,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -12053,7 +12011,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -12485,7 +12442,6 @@ 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,
@@ -13125,7 +13081,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 60,
"scrollY": 60,
"scrolledOutside": false,
@@ -13252,7 +13207,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -13886,7 +13840,6 @@ exports[`regression tests > switches from group of selected elements to another
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -14226,7 +14179,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -14490,7 +14442,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 20,
"scrollY": "-18.53553",
"scrolledOutside": false,
@@ -14615,7 +14566,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -14982,7 +14932,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -15110,7 +15059,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1,528 +0,0 @@
import React from "react";
import { vi } from "vitest";
import { KEYS, MAX_ZOOM } from "@excalidraw/common";
import { Excalidraw } from "../index";
import {
actionResetZoom,
actionZoomToFit,
actionZoomToFitSelection,
} from "../actions/actionCanvas";
import { getNormalizedZoom } from "../scene";
import {
animateToConstraints,
constrainScrollState,
getMinZoomForConstraints,
isViewportOverscrolled,
SCROLL_TO_CONTENT_ANIMATION_KEY,
} from "../scroll";
import { AnimationController } from "../renderer/animation";
import { API } from "./helpers/api";
import { Keyboard } from "./helpers/ui";
import {
mockBoundingClientRect,
render,
restoreOriginalGetBoundingClientRect,
waitFor,
} from "./test-utils";
import type { AppState, ScrollConstraints } from "../types";
const { h } = window;
// `mockBoundingClientRect()` makes the viewport 200 x 100
const VIEWPORT = { width: 200, height: 100 };
const makeState = (
overrides: Partial<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,
});
describe("constrainScrollState (pure)", () => {
it("returns scroll/zoom unchanged when there are no constraints", () => {
const state = makeState({
scrollX: 123,
scrollY: -45,
zoom: { value: getNormalizedZoom(0.5) },
scrollConstraints: null,
});
expect(constrainScrollState(state)).toEqual({
scrollX: 123,
scrollY: -45,
zoom: { value: getNormalizedZoom(0.5) },
});
});
describe("box larger than the viewport", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("clamps scroll so the viewport can't leave the box", () => {
// pan past the box's top-left corner → clamp to the corner (scroll 0)
const corner = constrainScrollState(
makeState({ scrollX: 100, scrollY: 100, scrollConstraints: box }),
);
expect(corner.scrollX).toBeCloseTo(0);
expect(corner.scrollY).toBeCloseTo(0);
// pan past the box's far edges → clamp to width/height - boxSize
const farEdge = constrainScrollState(
makeState({ scrollX: -5000, scrollY: -5000, scrollConstraints: box }),
);
expect(farEdge.scrollX).toBeCloseTo(VIEWPORT.width - box.width); // -800
expect(farEdge.scrollY).toBeCloseTo(VIEWPORT.height - box.height); // -900
});
it("enforces a minimum zoom so the box keeps covering the viewport", () => {
const minZoom = getMinZoomForConstraints(box, VIEWPORT);
expect(minZoom).toBeCloseTo(0.2); // max(200/1000, 100/1000)
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: box,
}),
);
expect(zoom.value).toBeCloseTo(minZoom);
});
it("leaves an in-bounds viewport untouched", () => {
const inBounds = makeState({
scrollX: -100,
scrollY: -100,
zoom: { value: getNormalizedZoom(1) },
scrollConstraints: box,
});
expect(constrainScrollState(inBounds)).toMatchObject({
scrollX: -100,
scrollY: -100,
zoom: { value: getNormalizedZoom(1) },
});
});
});
describe("box smaller than the viewport (best-effort fit)", () => {
it("forces a zoom-in and removes pan room on the binding axis", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 50, height: 50 };
// minZoom = max(200/50, 100/50) = 4
expect(getMinZoomForConstraints(box, VIEWPORT)).toBeCloseTo(4);
const result = constrainScrollState(
makeState({
scrollX: 999,
scrollY: 999,
zoom: { value: getNormalizedZoom(1) },
scrollConstraints: box,
}),
);
expect(result.zoom.value).toBeCloseTo(4);
// width binds: visible width (200/4=50) == box width → no x pan room
expect(result.scrollX).toBeCloseTo(0);
// height has slack (100/4=25 < 50) → clamped to the top edge here
expect(result.scrollY).toBeCloseTo(0);
});
it("centers the box when it can't cover the viewport even at MAX_ZOOM", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1, height: 1 };
expect(getMinZoomForConstraints(box, VIEWPORT)).toBe(MAX_ZOOM);
const result = constrainScrollState(
makeState({ scrollX: 1000, scrollY: 1000, scrollConstraints: box }),
);
expect(result.zoom.value).toBe(MAX_ZOOM);
// centered: (min + max) / 2, with min = w/zoom - (x+w), max = -x
const centeredX = (VIEWPORT.width / MAX_ZOOM - 1 + 0) / 2;
expect(result.scrollX).toBeCloseTo(centeredX);
});
});
});
describe("scrollToContent scrollConstraints (integration)", () => {
beforeEach(() => {
mockBoundingClientRect();
});
afterEach(() => {
restoreOriginalGetBoundingClientRect();
});
it("snaps the current viewport inside the box when constraints are set", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
// start well outside the future box
React.act(() => {
h.app.scrollToContent([], {
scrollConstraints: { x: 0, y: 0, width: 1000, height: 1000 },
});
});
// viewport must now be within [-800, 0] x [-900, 0]
expect(h.state.scrollX).toBeLessThanOrEqual(0);
expect(h.state.scrollX).toBeGreaterThanOrEqual(-800);
expect(h.state.scrollY).toBeLessThanOrEqual(0);
expect(h.state.scrollY).toBeGreaterThanOrEqual(-900);
expect(h.state.zoom.value).toBeGreaterThanOrEqual(0.2);
});
it("prevents keyboard panning out of the box and restores freedom on clear", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.scrollToContent([], {
scrollConstraints: { x: 0, y: 0, width: 300, height: 200 },
});
});
// hammer page-down (pans down) far past the box; scroll must stay clamped
for (let i = 0; i < 20; i++) {
Keyboard.keyPress(KEYS.PAGE_DOWN);
}
expect(h.state.scrollY).toBeGreaterThanOrEqual(h.state.height - 200);
// clearing constraints lets it scroll freely again
React.act(() => {
h.app.scrollToContent([], { scrollConstraints: null });
});
const before = h.state.scrollY;
Keyboard.keyPress(KEYS.PAGE_DOWN);
expect(h.state.scrollY).toBeLessThan(before);
});
it("clamps the scroll target into the box when scrolling to content", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
// an element well outside the future constraint box
const rect = API.createElement({
type: "rectangle",
x: 2000,
y: 2000,
width: 100,
height: 100,
});
API.setElements([rect]);
React.act(() => {
h.app.scrollToContent(rect, {
scrollConstraints: { x: 0, y: 0, width: 1000, height: 1000 },
animate: false,
});
});
// recentering on the element would scroll way past the box; it must be
// clamped to the far edges instead (scrollX -800, scrollY -900 at zoom 1)
expect(h.state.scrollX).toBeCloseTo(-800);
expect(h.state.scrollY).toBeCloseTo(-900);
});
it("disables reset-zoom and zoom-to-fit actions while constrained", async () => {
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.scrollToContent([], {
scrollConstraints: { x: 0, y: 0, width: 500, height: 500 },
});
});
expect(h.app.actionManager.isActionEnabled(actionResetZoom)).toBe(false);
expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(false);
expect(h.app.actionManager.isActionEnabled(actionZoomToFitSelection)).toBe(
false,
);
});
});
describe("explicit minZoom / maxZoom (pure)", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("lets minZoom override the box-enforced fit zoom (zoom out past the box)", () => {
// without minZoom the box forces a fit zoom of 0.2; minZoom 0.1 takes over
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: { ...box, minZoom: 0.1 },
}),
);
expect(zoom.value).toBeCloseTo(0.1);
});
it("still clamps zoom out at minZoom", () => {
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: { ...box, minZoom: 0.5 },
}),
);
expect(zoom.value).toBeCloseTo(0.5);
});
it("lets maxZoom cap zoom-in below the global MAX_ZOOM", () => {
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(MAX_ZOOM) },
scrollConstraints: { ...box, maxZoom: 3 },
}),
);
expect(zoom.value).toBeCloseTo(3);
});
it("leaves an in-range zoom untouched", () => {
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(1.5) },
scrollConstraints: { ...box, minZoom: 0.5, maxZoom: 3 },
}),
);
expect(zoom.value).toBeCloseTo(1.5);
});
});
describe("padding (pure)", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("extends the scrollable area past each edge by `[top, right, bottom, left]`", () => {
const padding: [number, number, number, number] = [10, 20, 30, 40];
// pan past the top-left corner → clamp to the padded corner
const topLeft = constrainScrollState(
makeState({
scrollX: 999,
scrollY: 999,
scrollConstraints: { ...box, padding },
}),
);
expect(topLeft.scrollX).toBeCloseTo(40); // left padding
expect(topLeft.scrollY).toBeCloseTo(10); // top padding
// pan past the far edges → clamp to width/height - boxSize - far padding
const farEdge = constrainScrollState(
makeState({
scrollX: -5000,
scrollY: -5000,
scrollConstraints: { ...box, padding },
}),
);
expect(farEdge.scrollX).toBeCloseTo(VIEWPORT.width - box.width - 20); // -820 (right)
expect(farEdge.scrollY).toBeCloseTo(VIEWPORT.height - box.height - 30); // -930 (bottom)
});
it("keeps the padding a fixed screen distance regardless of zoom", () => {
// 40 screen px of top padding at zoom 2 → 20 scene px
const result = constrainScrollState(
makeState({
scrollY: 999,
zoom: { value: getNormalizedZoom(2) },
scrollConstraints: { ...box, padding: [40, 0, 0, 0] },
}),
);
expect(result.scrollY).toBeCloseTo(20);
});
it("defaults to no extra room", () => {
const result = constrainScrollState(
makeState({ scrollX: 999, scrollConstraints: box }),
);
expect(result.scrollX).toBeCloseTo(0);
});
it("stacks with the rubberband tolerance", () => {
const tolerance = 30; // screen px
const result = constrainScrollState(
makeState({
scrollX: 999,
scrollConstraints: { ...box, tolerance, padding: [0, 0, 0, 40] },
}),
tolerance,
);
// left padding (40) + tolerance overscroll (30) at zoom 1
expect(result.scrollX).toBeCloseTo(70);
});
});
describe("rubberband tolerance (pure)", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("allows scroll overscroll up to `tolerance` screen pixels", () => {
const tolerance = 30; // screen px
// hard max for scrollX is -box.x = 0; soft adds tolerance / zoom scene px
const result = constrainScrollState(
makeState({ scrollX: 999, scrollConstraints: { ...box, tolerance } }),
tolerance,
);
expect(result.scrollX).toBeCloseTo(tolerance / 1); // 30 (zoom 1)
});
it("keeps the overscroll a fixed screen distance regardless of zoom", () => {
const tolerance = 30; // screen px
const result = constrainScrollState(
makeState({
scrollX: 999,
zoom: { value: getNormalizedZoom(2) },
scrollConstraints: { ...box, tolerance },
}),
tolerance,
);
// 30 screen px at zoom 2 -> 15 scene px of overscroll
expect(result.scrollX).toBeCloseTo(tolerance / 2); // 15
});
it("relaxes the minimum zoom by the `tolerance` screen pixels", () => {
const tolerance = 25; // screen px
// relaxed min zoom lets the box shrink within the viewport by `tolerance`
// px on each side: max((200-50)/1000, (100-50)/1000) = 0.15
const expected = getMinZoomForConstraints(box, {
width: VIEWPORT.width - 2 * tolerance,
height: VIEWPORT.height - 2 * tolerance,
});
const result = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.01) },
scrollConstraints: { ...box, tolerance },
}),
tolerance,
);
expect(result.zoom.value).toBeCloseTo(expected); // 0.15
});
it("hard-clamps when tolerance is 0 (default)", () => {
const result = constrainScrollState(
makeState({ scrollX: 999, scrollConstraints: box }),
);
expect(result.scrollX).toBeCloseTo(0);
});
});
describe("isViewportOverscrolled (pure)", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("is false when there are no constraints", () => {
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: box }),
),
).toBe(false);
});
it("is true when panned past an edge (rubberband overscroll)", () => {
// scrollX 30 is past the hard max of 0
expect(
isViewportOverscrolled(
makeState({ scrollX: 30, scrollConstraints: box }),
),
).toBe(true);
});
it("is true when zoomed out below the fit zoom", () => {
// fit zoom for this box is 0.2; 0.1 is below it
expect(
isViewportOverscrolled(
makeState({
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: box,
}),
),
).toBe(true);
});
});
describe("animateToConstraints (rubberband snap-back)", () => {
afterEach(() => {
AnimationController.reset();
});
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
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: box }),
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: box }),
onFrame,
);
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
false,
);
expect(onFrame).not.toHaveBeenCalled();
});
});
describe("rubberband tolerance (integration)", () => {
beforeEach(() => {
mockBoundingClientRect();
});
afterEach(() => {
restoreOriginalGetBoundingClientRect();
AnimationController.reset();
});
it("lets the user pan past the box edge (bounded by tolerance)", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.scrollToContent([], {
scrollConstraints: {
x: 0,
y: 0,
width: 1000,
height: 1000,
tolerance: 25,
},
});
});
// snapped to the top edge (hard max scrollY = 0)
expect(h.state.scrollY).toBe(0);
// page-up pans up, pushing scrollY past the hard edge into the overscroll
Keyboard.keyPress(KEYS.PAGE_UP);
// overscrolled past 0, but bounded by tolerance (25 screen px / zoom 1)
expect(h.state.scrollY).toBeGreaterThan(0);
expect(h.state.scrollY).toBeLessThanOrEqual(
25 / h.state.zoom.value + 0.001,
);
});
});
-33
View File
@@ -273,36 +273,6 @@ export type ObservedElementsAppState = {
export type BoxSelectionMode = "contain" | "overlap";
/** A box, in scene coordinates, that pan & zoom are constrained to. */
export type ScrollConstraints = {
x: number;
y: number;
width: number;
height: number;
/**
* Pixel amount to allow to overscroll.
*/
tolerance?: number;
/**
* Lower zoom bound. When set, it takes precedence over the zoom the box would
* otherwise enforce (the fit zoom that keeps the box covering the viewport),
* letting the viewport zoom out past the box.
*/
minZoom?: number;
/**
* Upper zoom bound. When set, it takes precedence over the global `MAX_ZOOM`,
* capping how far the viewport can zoom in.
*/
maxZoom?: number;
/**
* Extra scrollable margin around the box, as `[top, right, bottom, left]`
* (cardinal order), letting the viewport scroll past each box edge to reveal
* that much empty space beyond it. Values are screen pixels, so the margin
* stays the same on-screen regardless of zoom. Defaults to `[0, 0, 0, 0]`.
*/
padding?: [top: number, right: number, bottom: number, left: number];
};
export interface AppState {
contextMenu: {
items: ContextMenuItems;
@@ -410,9 +380,6 @@ export interface AppState {
viewBackgroundColor: string;
scrollX: number;
scrollY: number;
/** when set, pan & zoom are constrained so the viewport stays within this
* scene-coordinate box (see `scrollToContent`'s `scrollConstraints` option) */
scrollConstraints: ScrollConstraints | null;
cursorButton: "up" | "down";
scrolledOutside: boolean;
name: string | null;
+1 -1
View File
@@ -635,7 +635,7 @@ export const textWysiwyg = ({
event.preventDefault();
app.actionManager.executeAction(actionZoomOut);
updateWysiwygStyle();
} else if (!event.shiftKey && actionResetZoom.keyTest(event, app.state)) {
} else if (!event.shiftKey && actionResetZoom.keyTest(event)) {
event.preventDefault();
app.actionManager.executeAction(actionResetZoom);
updateWysiwygStyle();
@@ -86,7 +86,6 @@ exports[`exportToSvg > with default arguments 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,