462 lines
13 KiB
TypeScript
462 lines
13 KiB
TypeScript
// this file contains viewport-related utilities that are stateless or
|
|
// reused throughout the codebase not just directly by App.tsx or AppViewport
|
|
//
|
|
// for editor instance specific, see App.viewport.ts
|
|
|
|
import {
|
|
arrayToMap,
|
|
MAX_ZOOM,
|
|
MIN_ZOOM,
|
|
viewportCoordsToSceneCoords,
|
|
ZOOM_STEP,
|
|
} from "@excalidraw/common";
|
|
import { clamp, pointDistance, pointFrom, roundToStep } from "@excalidraw/math";
|
|
|
|
import {
|
|
CaptureUpdateAction,
|
|
getCommonBounds,
|
|
getElementBounds,
|
|
getVisibleElements,
|
|
} from "@excalidraw/element";
|
|
|
|
import type { SceneBounds } from "@excalidraw/element";
|
|
|
|
import type { Bounds } from "@excalidraw/common";
|
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
|
|
|
import { getNormalizedZoom } from "./scene";
|
|
|
|
import type {
|
|
AppState,
|
|
NormalizedZoomValue,
|
|
Offsets,
|
|
PointerCoords,
|
|
ScrollConstraints,
|
|
ViewportOffsets,
|
|
Zoom,
|
|
} from "./types";
|
|
|
|
/** default rubberband overscroll give for scroll locks, in viewport px */
|
|
export const DEFAULT_OVERSCROLL = 150;
|
|
|
|
type ZoomOptions = {
|
|
viewportX: number;
|
|
viewportY: number;
|
|
nextZoom: NormalizedZoomValue;
|
|
};
|
|
|
|
export type AnimationOptions = {
|
|
duration?: number;
|
|
};
|
|
|
|
export type SetViewportRect = {
|
|
x: number;
|
|
y: number;
|
|
width?: number;
|
|
height?: number;
|
|
};
|
|
|
|
export type SetViewportOptions = {
|
|
/**
|
|
* what to show in the viewport: an explicit scene-coordinate box/rect,
|
|
* element(s), or an element id / element-link URL.
|
|
*
|
|
* IMPORTANT: if supplying ExcalidrawElement(s), only non-deleted elements
|
|
* that actually exist on the canvas are considered. If you want to navigate
|
|
* to elements that's not on canvas yet, supply their bounds by using
|
|
* `getCommonBounds` (see {@link getCommonBounds}).
|
|
*/
|
|
target:
|
|
| Bounds
|
|
| SetViewportRect
|
|
| ExcalidrawElement
|
|
| readonly ExcalidrawElement[]
|
|
| string;
|
|
|
|
/**
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/object-fit
|
|
*
|
|
* - `scale-down` - zoom out so the target fits the viewport, never zooming past 100%
|
|
* - `contain` - zoom the target so it fills the viewport (may exceed 100%)
|
|
* - `none` - keep the current zoom, only center the target in the viewport
|
|
*/
|
|
fit?: "scale-down" | "contain" | "none";
|
|
|
|
lock?: {
|
|
/** constraints panning to the target box */
|
|
scroll?: ScrollConstraints["lockScroll"];
|
|
/** makes the resolved zoom the minimum zoom */
|
|
zoom?: ScrollConstraints["lockZoom"];
|
|
/**
|
|
* Rubberband give past the lock, in viewport px (zoom-independent): how
|
|
* far the user can pan beyond the constraint before snapping back.
|
|
* `true` (default) uses {@link DEFAULT_OVERSCROLL}, `false` disables
|
|
* (rigid lock), a number sets the give explicitly.
|
|
*/
|
|
overscroll?: boolean | number;
|
|
};
|
|
|
|
animation?: AnimationOptions | boolean;
|
|
|
|
/**
|
|
* CSS-style per-side viewport insets, in viewport pixels
|
|
* (zoom-independent) — static values, editor-UI-derived (`ui`), or a
|
|
* combination. See {@link ViewportOffsets}.
|
|
*/
|
|
offsets?: ViewportOffsets;
|
|
};
|
|
|
|
type Viewport = Pick<AppState, "scrollX" | "scrollY" | "zoom">;
|
|
|
|
/**
|
|
* 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 },
|
|
};
|
|
};
|
|
|
|
/** private helper. Use {@link getViewportForZoomWithScrollConstraints} instead */
|
|
const getViewportForZoom = (
|
|
{ viewportX, viewportY, nextZoom }: ZoomOptions,
|
|
appState: AppState,
|
|
): Viewport => {
|
|
const appLayerX = viewportX - appState.offsetLeft;
|
|
const appLayerY = viewportY - appState.offsetTop;
|
|
|
|
const currentZoom = appState.zoom.value;
|
|
|
|
// get original scroll position without zoom
|
|
const baseScrollX = appState.scrollX + (appLayerX - appLayerX / currentZoom);
|
|
const baseScrollY = appState.scrollY + (appLayerY - appLayerY / currentZoom);
|
|
|
|
// get scroll offsets for target zoom level
|
|
const zoomOffsetScrollX = -(appLayerX - appLayerX / nextZoom);
|
|
const zoomOffsetScrollY = -(appLayerY - appLayerY / nextZoom);
|
|
|
|
return {
|
|
scrollX: baseScrollX + zoomOffsetScrollX,
|
|
scrollY: baseScrollY + zoomOffsetScrollY,
|
|
zoom: {
|
|
value: nextZoom,
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Resolves a focal-point zoom against the active scroll constraints while
|
|
* preserving the current rubberband displacement in screen pixels. This lets
|
|
* zoom and scroll-constraint snap-back compose without either visually
|
|
* cancelling the other.
|
|
*/
|
|
export const getViewportForZoomWithScrollConstraints = (
|
|
opts: ZoomOptions,
|
|
state: AppState,
|
|
): Viewport => {
|
|
const restingViewport = constrainScrollState(state);
|
|
const overscrollX =
|
|
(state.scrollX - restingViewport.scrollX) * state.zoom.value;
|
|
const overscrollY =
|
|
(state.scrollY - restingViewport.scrollY) * state.zoom.value;
|
|
|
|
const zoomedViewport = constrainScrollState({
|
|
...state,
|
|
...getViewportForZoom(opts, state),
|
|
});
|
|
|
|
if (!state.scrollConstraints?.lockScroll || (!overscrollX && !overscrollY)) {
|
|
return zoomedViewport;
|
|
}
|
|
|
|
const zoom = zoomedViewport.zoom.value;
|
|
return constrainScrollState(
|
|
{
|
|
...state,
|
|
...zoomedViewport,
|
|
scrollX: zoomedViewport.scrollX + overscrollX / zoom,
|
|
scrollY: zoomedViewport.scrollY + overscrollY / zoom,
|
|
},
|
|
state.scrollConstraints.overscroll,
|
|
);
|
|
};
|
|
|
|
const zoomValueToFitBoundsOnViewport = (
|
|
bounds: SceneBounds,
|
|
viewportDimensions: { width: number; height: number },
|
|
) => {
|
|
const [x1, y1, x2, y2] = bounds;
|
|
const commonBoundsWidth = x2 - x1;
|
|
const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
|
|
const commonBoundsHeight = y2 - y1;
|
|
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
|
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
|
|
|
return Math.min(smallestZoomValue, 1);
|
|
};
|
|
|
|
export const zoomToFitBounds = ({
|
|
bounds,
|
|
appState,
|
|
canvasOffsets,
|
|
fit = "scale-down",
|
|
minZoom = -Infinity,
|
|
maxZoom = Infinity,
|
|
steppedZoom = false,
|
|
}: {
|
|
bounds: SceneBounds;
|
|
canvasOffsets?: Offsets;
|
|
appState: Readonly<AppState>;
|
|
fit?: SetViewportOptions["fit"];
|
|
minZoom?: number;
|
|
maxZoom?: number;
|
|
steppedZoom?: boolean;
|
|
}) => {
|
|
const [x1, y1, x2, y2] = bounds;
|
|
const centerX = (x1 + x2) / 2;
|
|
const centerY = (y1 + y2) / 2;
|
|
|
|
const canvasOffsetLeft = canvasOffsets?.left ?? 0;
|
|
const canvasOffsetTop = canvasOffsets?.top ?? 0;
|
|
const canvasOffsetRight = canvasOffsets?.right ?? 0;
|
|
const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
|
|
|
|
const effectiveCanvasWidth =
|
|
appState.width - canvasOffsetLeft - canvasOffsetRight;
|
|
const effectiveCanvasHeight =
|
|
appState.height - canvasOffsetTop - canvasOffsetBottom;
|
|
|
|
let adjustedZoomValue;
|
|
|
|
if (fit === "none") {
|
|
// pan-only: keep the current zoom, just center the target
|
|
adjustedZoomValue = appState.zoom.value;
|
|
} else if (fit === "contain") {
|
|
const commonBoundsWidth = x2 - x1;
|
|
const commonBoundsHeight = y2 - y1;
|
|
|
|
adjustedZoomValue = Math.min(
|
|
effectiveCanvasWidth / commonBoundsWidth,
|
|
effectiveCanvasHeight / commonBoundsHeight,
|
|
);
|
|
} else {
|
|
adjustedZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
|
|
width: effectiveCanvasWidth,
|
|
height: effectiveCanvasHeight,
|
|
});
|
|
}
|
|
|
|
const targetZoomValue =
|
|
steppedZoom && fit !== "none"
|
|
? roundToStep(adjustedZoomValue, ZOOM_STEP, "floor")
|
|
: adjustedZoomValue;
|
|
|
|
const newZoomValue = getNormalizedZoom(
|
|
clamp(targetZoomValue, minZoom, maxZoom),
|
|
);
|
|
|
|
const centerScroll = centerScrollOn({
|
|
scenePoint: { x: centerX, y: centerY },
|
|
viewportDimensions: {
|
|
width: appState.width,
|
|
height: appState.height,
|
|
},
|
|
offsets: canvasOffsets,
|
|
zoom: { value: newZoomValue },
|
|
});
|
|
|
|
return {
|
|
appState: {
|
|
...appState,
|
|
scrollX: centerScroll.scrollX,
|
|
scrollY: centerScroll.scrollY,
|
|
zoom: { value: newZoomValue },
|
|
},
|
|
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
|
};
|
|
};
|
|
|
|
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,
|
|
});
|
|
};
|