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