import { debounce, easeOut, isBounds, type Bounds, type StylesPanelMode, } from "@excalidraw/common"; import { clamp } from "@excalidraw/math"; import { getCommonBounds, getElementsInGroup, isElementLink, isExcalidrawElement, parseElementLinkFromURL, } from "@excalidraw/element"; import type { ExcalidrawElement, NonDeleted, NonDeletedSceneElementsMap, } from "@excalidraw/element/types"; import { getLanguage, t } from "../i18n"; import { AnimationController } from "../renderer/animation"; import { constrainScrollState, DEFAULT_OVERSCROLL, type SetViewportOptions, type SetViewportRect, zoomToFitBounds, } from "../viewport"; import type { AppProps, AppState, NormalizedZoomValue, Offsets, ScrollConstraints, ViewportOffsets, ViewportOffsetsOptions, ViewportUIDock, ViewportUIName, } from "../types"; import type App from "./App"; export const SCROLL_TO_CONTENT_ANIMATION_KEY = "animateScrollToContent"; export const SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY = "animateScrollConstraintsSnapBack"; const DEFAULT_SCROLL_ANIMATION_DURATION = 500; /** rubberband snap-back animation duration, in ms — kept snappier than the * default scroll animation so releasing an overscroll feels responsive */ const SNAP_BACK_ANIMATION_DURATION = 250; /** how long after the last pan/zoom we animate the rubberband back into the * scroll constraints box */ const SCROLL_CONSTRAINTS_SNAP_BACK_DELAY = 200; /** single source of truth for the `--right-sidebar-width` CSS variable */ export const RIGHT_SIDEBAR_WIDTH = 302; /** * Approximate styles panel footprints (panel width + editor edge inset), * used by `getOffsets` 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 }; type Viewport = Pick; 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") ); }; const getElementsFromId = ( id: string, elementsMap: NonDeletedSceneElementsMap, ) => { const element = elementsMap.get(id); if (element) { return [element]; } return getElementsInGroup(elementsMap, id); }; 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. */ 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" }; }; 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); }; /** Computes the viewport (scroll + zoom) that brings the target box into view, * based on the requested fit behavior. */ 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, }; }; /** 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. */ const getConstrainedTargetViewport = ( appState: AppState, bounds: Bounds, // NOTE offsets must be resolved (see `AppViewport.resolveOffsets`) { 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. */ 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 }, }; }; type AppViewportDependencies = { getContainer: () => HTMLDivElement | null; getStylesPanelMode: () => StylesPanelMode; isGestureActive: () => boolean; }; const resolveAnimationDuration = ( animation: SetViewportOptions["animation"], ): number | null => { if (animation === false) { return null; } if (animation === true || animation == null) { return DEFAULT_SCROLL_ANIMATION_DURATION; } return animation.duration ?? DEFAULT_SCROLL_ANIMATION_DURATION; }; /** 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)); if (progress < 1) { onFrame({ ...interpolateViewport({ from, target, factor }), shouldCacheIgnoreZoom: true, }); return { elapsed }; } onComplete(); return null; }, ); }; /** * 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 snapBackToConstraints = ( state: Pick< AppState, "scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints" >, onFrame: ( updater: ( state: Pick< AppState, | "scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints" >, ) => Viewport | null, ) => void, duration = SNAP_BACK_ANIMATION_DURATION, ) => { const target = constrainScrollState(state); if ( target.scrollX === state.scrollX && target.scrollY === state.scrollY && target.zoom.value === state.zoom.value ) { return; } // A programmatic navigation owns the viewport until it settles. A stale // rubberband debounce must not supersede it. if (AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)) { return; } // Keep the displacement zoom-independent. Each animation frame resolves // the resting viewport from the latest state, allowing zoom to update the // underlying viewport while the same on-screen rubberband distance decays. const overscrollX = (state.scrollX - target.scrollX) * state.zoom.value; const overscrollY = (state.scrollY - target.scrollY) * state.zoom.value; AnimationController.start<{ elapsed: number }>( SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY, ({ deltaTime, state: animationState }) => { const elapsed = (animationState?.elapsed ?? 0) + deltaTime; const progress = Math.min(elapsed / duration, 1); const remaining = 1 - easeOut(clamp(progress, 0, 1)); onFrame((currentState) => { if (!currentState.scrollConstraints) { return null; } const restingViewport = constrainScrollState(currentState); const zoom = restingViewport.zoom.value; return { scrollX: restingViewport.scrollX + (overscrollX * remaining) / zoom, scrollY: restingViewport.scrollY + (overscrollY * remaining) / zoom, zoom: restingViewport.zoom, }; }); return progress < 1 ? { elapsed } : null; }, ); }; /** * Owns viewport state transitions and App-bound viewport concerns. Pure * viewport geometry remains in `viewport.ts` so actions and public helpers can * calculate state without an App instance. */ export class AppViewport { public lastPosition = { x: 0, y: 0 }; private activeTransition: { target: Viewport & Pick; } | null = null; private uiLastMeasured = new Map< ViewportUIName, { side: "left" | "right"; offset: number } >(); constructor( private app: App, private dependencies: AppViewportDependencies, ) {} get isAnimating() { return ( AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY) || AnimationController.running(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY) ); } /** Whether a programmatic transition into a locked viewport currently owns * user pan/zoom input. */ get isLockedTransitionPending() { return !!this.activeTransition?.target.scrollConstraints; } invalidateUIOffset = (name: ViewportUIName) => { this.uiLastMeasured.delete(name); }; /** * Resolves user-supplied viewport offsets into concrete per-side pixel * values. Static sides take precedence over UI-derived sides. */ resolveOffsets = ( offsets: ViewportOffsets | undefined, ): Offsets | undefined => { if (!offsets) { return offsets; } const { ui, ...staticOffsets } = offsets; if (!ui) { return staticOffsets; } const uiOffsets = this.getOffsets(ui === true ? undefined : ui); return { top: staticOffsets.top ?? uiOffsets.top, right: staticOffsets.right ?? uiOffsets.right, bottom: staticOffsets.bottom ?? uiOffsets.bottom, left: staticOffsets.left ?? uiOffsets.left, }; }; /** * Measures the currently rendered editor UI and returns the usable viewport * offsets, including optional padding and reserved hidden surfaces. */ getOffsets = (opts?: ViewportOffsetsOptions): Offsets => { const excalidrawContainer = this.dependencies.getContainer(); const excalidrawContainerRect = excalidrawContainer?.getBoundingClientRect(); const isRTL = getLanguage().rtl; const measuredOffsets = { top: 0, right: 0, bottom: 0, left: 0 }; const renderedSurfaces = new Set(); if (excalidrawContainer && excalidrawContainerRect) { for (const node of excalidrawContainer.querySelectorAll( "[data-viewport-ui]", )) { const domRect = node.getBoundingClientRect(); 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, }; 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.app.state.height - rect.top, ); break; case "side": { const [side, offset] = rect.left + rect.width / 2 < this.app.state.width / 2 ? (["left", rect.right] as const) : (["right", this.app.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); if (offset > 0) { this.uiLastMeasured.set(name, { side, offset }); } } break; } } } } if (opts?.reserve && this.app.editorInterface.formFactor !== "phone") { const reserveSurface = ( name: ViewportUIName, fallback: { side: "left" | "right"; offset: number }, ) => { if (renderedSurfaces.has(name)) { return; } const { side, offset } = this.uiLastMeasured.get(name) ?? fallback; measuredOffsets[side] = Math.max(measuredOffsets[side], offset); }; if (opts.reserve.stylesPanel) { reserveSurface("stylesPanel", { side: isRTL ? "right" : "left", offset: this.dependencies.getStylesPanelMode() === "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, }; }; /** Resolves the host-supplied initial viewport against restored scene data. */ resolveInitialViewport = ( opts: Omit, elementsMap: NonDeletedSceneElementsMap, appState: AppState, ) => { const { bounds } = resolveViewportTarget( opts.target, elementsMap, appState, ); if (!bounds) { return null; } return getConstrainedTargetViewport(appState, bounds, { ...opts, // Initial state is resolved post-mount so UI-derived offsets measure // the actually-rendered editor UI. offsets: this.resolveOffsets(opts.offsets), }); }; /** Navigates to a target and optionally installs scroll/zoom constraints. */ setViewport = (opts: SetViewportOptions | null) => { if (opts === null) { this.cancelTransition(); AnimationController.cancel(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY); this.snapBackDebounced.cancel(); this.app.setState({ scrollConstraints: null, shouldCacheIgnoreZoom: false, }); return; } const { target, fit, lock, animation } = opts; const offsets = this.resolveOffsets(opts.offsets); const { bounds, type } = resolveViewportTarget( target, this.app.scene.getNonDeletedElementsMap(), this.app.state, ); if (!bounds) { if (type === "link") { this.app.setState({ toast: { message: t("elementLink.notFound"), duration: 3000, closable: true, }, }); } return; } const viewportUpdate = getConstrainedTargetViewport( this.app.state, bounds, { fit, offsets, lock }, ); const duration = resolveAnimationDuration(animation); const from = { scrollX: this.app.state.scrollX, scrollY: this.app.state.scrollY, zoom: this.app.state.zoom, }; // A new programmatic navigation supersedes the previous one and starts // from whatever viewport its last rendered frame reached. this.cancelTransition(); AnimationController.cancel(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY); this.snapBackDebounced.cancel(); if (duration === null) { this.app.setState({ ...viewportUpdate, shouldCacheIgnoreZoom: false, }); return; } const transition = { target: viewportUpdate, }; this.activeTransition = transition; // The old lock has been superseded. Keep the new lock pending outside // AppState so it cannot constrain animation frames back toward either // endpoint, and install it together with the final viewport. if (this.app.state.scrollConstraints) { this.app.setState({ scrollConstraints: null }); } animateToViewport( from, viewportUpdate, duration, (state) => { if (this.activeTransition === transition) { this.app.setState(state); } }, () => { if (this.activeTransition !== transition) { return; } this.activeTransition = null; this.app.setState({ ...transition.target, shouldCacheIgnoreZoom: false, }); }, ); }; /** Use when changing scrollX/scrollY/zoom based on user interaction. */ translate = ( state: | AppState | Pick | null | (( prevState: Readonly, props: Readonly, ) => AppState | Pick | null), opts?: { zoomPreConstrained?: boolean; preserveScrollConstraintsSnapBack?: boolean; }, ) => { if (this.isLockedTransitionPending) { return false; } // a user gesture interrupting an animated `setViewport` takes over from // frames that draw from zoom-scaled bitmaps, and the completion handler // that would switch back to crisp ones no longer runs. Otherwise the // flag is left alone: wheel and touch zoom set it and clear it on a // debounce once the gesture is over, and a pan frame landing between two // zoom ticks must not force every element to be re-rasterized at the // in-progress zoom if (this.activeTransition) { this.app.setState({ shouldCacheIgnoreZoom: false }); } this.cancelTransition(); if (!opts?.preserveScrollConstraintsSnapBack) { AnimationController.cancel(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY); } this.app.requestUnfollow(); this.app.setState((prevState, props) => { const update = typeof state === "function" ? state(prevState, props) : state; if (!update) { return null; } const nextState = { ...prevState, ...update }; if (!nextState.scrollConstraints) { return update; } const zoomed = !opts?.zoomPreConstrained && nextState.zoom.value !== prevState.zoom.value; const overscroll = zoomed ? 0 : nextState.scrollConstraints.overscroll; if (overscroll > 0) { this.snapBackDebounced(); } return { ...nextState, ...constrainScrollState(nextState, overscroll) }; }); return true; }; /** Clamps the viewport into the active scroll constraints. */ constrain = (overscroll = 0) => { this.app.setState((prevState) => prevState.scrollConstraints ? constrainScrollState(prevState, overscroll) : null, ); }; /** Releases a held rubberband overscroll. */ releaseOverscroll = () => { if (!this.app.state.scrollConstraints) { return; } this.snapBackDebounced.cancel(); this.snapBack(); }; private snapBack = () => { if (this.app.unmounted || this.dependencies.isGestureActive()) { return; } snapBackToConstraints(this.app.state, (updater) => this.app.setState((state) => updater(state)), ); }; private snapBackDebounced = debounce( this.snapBack, SCROLL_CONSTRAINTS_SNAP_BACK_DELAY, ); private cancelTransition = () => { this.activeTransition = null; AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY); }; destroy = () => { this.cancelTransition(); AnimationController.cancel(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY); this.snapBackDebounced.cancel(); }; }