snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Has been cancelled
Some checks failed
Tests / test (push) Has been cancelled
This commit is contained in:
828
packages/excalidraw/components/App.viewport.ts
Normal file
828
packages/excalidraw/components/App.viewport.ts
Normal file
@@ -0,0 +1,828 @@
|
||||
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<AppState, "scrollX" | "scrollY" | "zoom">;
|
||||
|
||||
const isSetViewportRect = (target: unknown): target is SetViewportRect => {
|
||||
if (!target || typeof target !== "object" || Array.isArray(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = target as Partial<SetViewportRect>;
|
||||
return (
|
||||
typeof rect.x === "number" &&
|
||||
typeof rect.y === "number" &&
|
||||
(rect.width == null || typeof rect.width === "number") &&
|
||||
(rect.height == null || typeof rect.height === "number")
|
||||
);
|
||||
};
|
||||
|
||||
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<AppState, "width" | "height">,
|
||||
): ResolvedViewportTarget => {
|
||||
if (typeof target === "string") {
|
||||
const isLink = isElementLink(target);
|
||||
const type = isLink ? ("link" as const) : ("element" as const);
|
||||
const id = isLink ? parseElementLinkFromURL(target) : target;
|
||||
const resolved = id ? getElementsFromId(id, elementsMap) : [];
|
||||
|
||||
if (!resolved.length) {
|
||||
return { bounds: null, type };
|
||||
}
|
||||
|
||||
return { bounds: getCommonBounds(resolved, elementsMap), type };
|
||||
}
|
||||
|
||||
if (isBounds(target)) {
|
||||
return { bounds: target, type: "area" };
|
||||
}
|
||||
|
||||
if (isSetViewportRect(target) && !isExcalidrawElement(target)) {
|
||||
const width = target.width ?? appState.width;
|
||||
const height = target.height ?? appState.height;
|
||||
return {
|
||||
bounds: [target.x, target.y, target.x + width, target.y + height],
|
||||
type: "area",
|
||||
};
|
||||
}
|
||||
|
||||
// widening to null values in case the host app doesn't have
|
||||
// noUncheckedIndexedAccess enabled
|
||||
const targetElements: (ExcalidrawElement | undefined | null)[] =
|
||||
Array.isArray(target) ? target : [target];
|
||||
const elements = targetElements.reduce<NonDeleted<ExcalidrawElement>[]>(
|
||||
(acc, element) => {
|
||||
if (element && !element.isDeleted) {
|
||||
const sceneElement = elementsMap.get(element.id);
|
||||
if (sceneElement) {
|
||||
acc.push(sceneElement);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const hasNoElements = !elements.length;
|
||||
if (elements.length !== targetElements.length || hasNoElements) {
|
||||
console.warn(
|
||||
"supplied element target(s) for setViewport contain deleted or non-existent elements which have been filtered out",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNoElements) {
|
||||
return { bounds: null, type: "element" };
|
||||
}
|
||||
|
||||
return { bounds: getCommonBounds(elements, elementsMap), type: "element" };
|
||||
};
|
||||
|
||||
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<SetViewportOptions, "fit" | "lock"> & { offsets?: Offsets },
|
||||
): Viewport & { scrollConstraints: ScrollConstraints | null } => {
|
||||
const viewport = getTargetViewport(appState, bounds, fit, offsets);
|
||||
|
||||
if (!lock?.scroll && !lock?.zoom) {
|
||||
return { ...viewport, scrollConstraints: null };
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
const scrollConstraints: ScrollConstraints = {
|
||||
x: x1,
|
||||
y: y1,
|
||||
width: x2 - x1,
|
||||
height: y2 - y1,
|
||||
lockScroll: !!lock.scroll,
|
||||
lockZoom: !!lock.zoom,
|
||||
zoom: viewport.zoom.value,
|
||||
overscroll: resolveOverscroll(lock.overscroll),
|
||||
offsets,
|
||||
};
|
||||
|
||||
return {
|
||||
...constrainScrollState({ ...appState, ...viewport, scrollConstraints }),
|
||||
scrollConstraints,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Interpolates the viewport from `from` to `target` at the (already-eased)
|
||||
* blend amount `factor` (0 = `from`, 1 = `target`).
|
||||
*
|
||||
* Zoom is interpolated geometrically (so it feels uniform), but the pan can't
|
||||
* simply lerp alongside it: pairing a geometric zoom with a linear scroll (or
|
||||
* a linearly-tweened focal point) makes scene points swoop along curved,
|
||||
* non-monotone screen paths once the zoom ratio exceeds ~e (the destination
|
||||
* visibly drifts away before converging). Instead we interpolate the view
|
||||
* transform affinely — a scene point maps to screen as
|
||||
* `(scenePt + scroll) * zoom`, and requiring every point to travel a straight
|
||||
* screen line forces `zoom` to be a convex blend `(1-m)*z0 + m*z1` with
|
||||
* `scroll * zoom` lerped by that same weight. Deriving `m` from the geometric
|
||||
* zoom keeps its pacing while making all screen trajectories straight and
|
||||
* monotone.
|
||||
*/
|
||||
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<AppState, "scrollConstraints">;
|
||||
} | 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<ViewportUIName>();
|
||||
|
||||
if (excalidrawContainer && excalidrawContainerRect) {
|
||||
for (const node of excalidrawContainer.querySelectorAll<HTMLElement>(
|
||||
"[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<SetViewportOptions, "animation">,
|
||||
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 = <K extends keyof AppState>(
|
||||
state:
|
||||
| AppState
|
||||
| Pick<AppState, K>
|
||||
| null
|
||||
| ((
|
||||
prevState: Readonly<AppState>,
|
||||
props: Readonly<AppProps>,
|
||||
) => AppState | Pick<AppState, K> | 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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user