feat(editor): AnimationController for scrollToContent (#11553)

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Márk Tolmács
2026-06-26 09:35:32 +02:00
committed by GitHub
parent 20f694d110
commit e4c70cb6c6
5 changed files with 341 additions and 289 deletions
-129
View File
@@ -204,135 +204,6 @@ export const easeOut = (k: number) => {
return 1 - Math.pow(1 - k, 4);
};
const easeOutInterpolate = (from: number, to: number, progress: number) => {
return (to - from) * easeOut(progress) + from;
};
/**
* Animates values from `fromValues` to `toValues` using the requestAnimationFrame API.
* Executes the `onStep` callback on each step with the interpolated values.
* Returns a function that can be called to cancel the animation.
*
* @example
* // Example usage:
* const fromValues = { x: 0, y: 0 };
* const toValues = { x: 100, y: 200 };
* const onStep = ({x, y}) => {
* setState(x, y)
* };
* const onCancel = () => {
* console.log("Animation canceled");
* };
*
* const cancelAnimation = easeToValuesRAF({
* fromValues,
* toValues,
* onStep,
* onCancel,
* });
*
* // To cancel the animation:
* cancelAnimation();
*/
export const easeToValuesRAF = <
T extends Record<keyof T, number>,
K extends keyof T,
>({
fromValues,
toValues,
onStep,
duration = 250,
interpolateValue,
onStart,
onEnd,
onCancel,
}: {
fromValues: T;
toValues: T;
/**
* Interpolate a single value.
* Return undefined to be handled by the default interpolator.
*/
interpolateValue?: (
fromValue: number,
toValue: number,
/** no easing applied */
progress: number,
key: K,
) => number | undefined;
onStep: (values: T) => void;
duration?: number;
onStart?: () => void;
onEnd?: () => void;
onCancel?: () => void;
}) => {
let canceled = false;
let frameId = 0;
let startTime: number;
function step(timestamp: number) {
if (canceled) {
return;
}
if (startTime === undefined) {
startTime = timestamp;
onStart?.();
}
const elapsed = Math.min(timestamp - startTime, duration);
const factor = easeOut(elapsed / duration);
const newValues = {} as T;
Object.keys(fromValues).forEach((key) => {
const _key = key as keyof T;
const result = ((toValues[_key] - fromValues[_key]) * factor +
fromValues[_key]) as T[keyof T];
newValues[_key] = result;
});
onStep(newValues);
if (elapsed < duration) {
const progress = elapsed / duration;
const newValues = {} as T;
Object.keys(fromValues).forEach((key) => {
const _key = key as K;
const startValue = fromValues[_key];
const endValue = toValues[_key];
let result;
result = interpolateValue
? interpolateValue(startValue, endValue, progress, _key)
: easeOutInterpolate(startValue, endValue, progress);
if (result == null) {
result = easeOutInterpolate(startValue, endValue, progress);
}
newValues[_key] = result as T[K];
});
onStep(newValues);
frameId = window.requestAnimationFrame(step);
} else {
onStep(toValues);
onEnd?.();
}
}
frameId = window.requestAnimationFrame(step);
return () => {
onCancel?.();
canceled = true;
window.cancelAnimationFrame(frameId);
};
};
// https://github.com/lodash/lodash/blob/es/chunk.js
export const chunk = <T extends any>(
array: readonly T[],
+52 -140
View File
@@ -77,11 +77,9 @@ import {
updateObject,
updateActiveTool,
isTransparent,
easeToValuesRAF,
muteFSAbortError,
isTestEnv,
isDevEnv,
easeOut,
updateStable,
addEventListener,
normalizeEOL,
@@ -203,7 +201,6 @@ import {
cropElement,
wrapText,
isElementLink,
parseElementLinkFromURL,
isMeasureTextSupported,
normalizeText,
measureText,
@@ -264,6 +261,7 @@ import {
getActiveTextElement,
isEligibleFrameChildType,
getBindingStrategyForDraggingBindingElementEndpoints,
parseElementLinkFromURL,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -331,7 +329,7 @@ import {
actionToggleCropEditor,
} from "../actions";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { actionToggleHandTool } from "../actions/actionCanvas";
import { actionPaste } from "../actions/actionClipboard";
import { actionCopyElementLink } from "../actions/actionElementLink";
import { actionUnlockAllElements } from "../actions/actionElementLock";
@@ -413,6 +411,11 @@ import {
isGridModeEnabled,
} from "../snapping";
import { Renderer } from "../scene/Renderer";
import {
type ScrollToContentOptions,
SCROLL_TO_CONTENT_ANIMATION_KEY,
scrollToElements,
} from "../scroll";
import {
setEraserCursor,
setCursor,
@@ -425,16 +428,12 @@ import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
import { isOverScrollBars } from "../scene/scrollbars";
import { isMaybeMermaidDefinition } from "../mermaid";
import { LassoTrail } from "../lasso";
import { EraserTrail } from "../eraser";
import { getShortcutKey } from "../shortcut";
import { tryParseSpreadsheet } from "../charts";
import { AnimationController } from "../renderer/animation";
import ConvertElementTypePopup, {
getConversionTypeFromElements,
@@ -4341,148 +4340,60 @@ class App extends React.Component<AppProps, AppState> {
});
};
private cancelInProgressAnimation: (() => void) | null = null;
scrollToContent = (
/**
* target to scroll to
*
* - string - id of element or group, or url containing elementLink
* - ExcalidrawElement | ExcalidrawElement[] - element(s) objects
*/
target:
target?:
| string
| ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: (
| {
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;
},
| readonly NonDeletedExcalidrawElement[],
opts?: ScrollToContentOptions,
) => {
let elements: readonly NonDeleted<ExcalidrawElement>[];
if (typeof target === "string") {
let id: string | null;
if (isElementLink(target)) {
id = parseElementLinkFromURL(target);
} else {
id = target;
}
if (id) {
const elements = this.scene.getElementsFromId(id);
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<ExcalidrawElement>];
} else {
elements = this.scene.getNonDeletedElements();
}
if (elements?.length) {
this.scrollToContent(elements, {
fitToContent: opts?.fitToContent ?? true,
animate: opts?.animate ?? true,
});
} else if (isElementLink(target)) {
this.setState({
toast: {
message: t("elementLink.notFound"),
duration: 3000,
closable: true,
},
});
}
if (!elements.length) {
if (typeof target === "string" && isElementLink(target)) {
this.setState({
toast: {
message: t("elementLink.notFound"),
duration: 3000,
closable: true,
},
});
}
return;
}
this.cancelInProgressAnimation?.();
// convert provided target into ExcalidrawElement[] if necessary
const targetElements = Array.isArray(target) ? target : [target];
let zoom = this.state.zoom;
let scrollX = this.state.scrollX;
let scrollY = this.state.scrollY;
if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({
canvasOffsets: opts.canvasOffsets,
targetElements,
appState: this.state,
fitToViewport: !!opts?.fitToViewport,
viewportZoomFactor: opts?.viewportZoomFactor,
minZoom: opts?.minZoom,
maxZoom: opts?.maxZoom,
});
zoom = appState.zoom;
scrollX = appState.scrollX;
scrollY = appState.scrollY;
} else {
// compute only the viewport location, without any zoom adjustment
const scroll = calculateScrollCenter(targetElements, this.state);
scrollX = scroll.scrollX;
scrollY = scroll.scrollY;
}
// when animating, we use RequestAnimationFrame to prevent the animation
// from slowing down other processes
if (opts?.animate) {
const origScrollX = this.state.scrollX;
const origScrollY = this.state.scrollY;
const origZoom = this.state.zoom.value;
const cancel = easeToValuesRAF({
fromValues: {
scrollX: origScrollX,
scrollY: origScrollY,
zoom: origZoom,
},
toValues: { scrollX, scrollY, zoom: zoom.value },
interpolateValue: (from, to, progress, key) => {
// for zoom, use different easing
if (key === "zoom") {
return from * Math.pow(to / from, easeOut(progress));
// 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,
}
// handle using default
return undefined;
},
onStep: ({ scrollX, scrollY, zoom }) => {
this.setState({
scrollX,
scrollY,
zoom: { value: zoom },
});
},
onStart: () => {
this.setState({ shouldCacheIgnoreZoom: true });
},
onEnd: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
onCancel: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
duration: opts?.duration ?? 500,
});
: opts;
this.cancelInProgressAnimation = () => {
cancel();
this.cancelInProgressAnimation = null;
};
} else {
this.setState({ scrollX, scrollY, zoom });
}
scrollToElements(
this.state,
elements,
this.setState.bind(this),
resolvedOpts,
);
};
private maybeUnfollowRemoteUser = () => {
@@ -4495,7 +4406,8 @@ class App extends React.Component<AppProps, AppState> {
private translateCanvas: React.Component<any, AppState>["setState"] = (
state,
) => {
this.cancelInProgressAnimation?.();
AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY);
this.setState({ shouldCacheIgnoreZoom: false });
this.maybeUnfollowRemoteUser();
this.setState(state);
};
@@ -123,4 +123,9 @@ export class AnimationController {
AnimationController.animations.delete(key);
AnimationController.cancelScheduledFrameIfIdle();
}
static reset() {
AnimationController.animations.clear();
AnimationController.cancelScheduledFrame();
}
}
+147
View File
@@ -0,0 +1,147 @@
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<AppState, "scrollX" | "scrollY" | "zoom">;
/**
* 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 };
};
/** 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<AppState, "scrollX" | "scrollY" | "zoom">,
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({
shouldCacheIgnoreZoom: progress < 1, // ignore zoom caching while animating
scrollX: from.scrollX + (target.scrollX - from.scrollX) * factor,
scrollY: from.scrollY + (target.scrollY - from.scrollY) * factor,
// zoom interpolates geometrically so the transition feels natural
zoom: {
value: (from.zoom.value *
Math.pow(
target.zoom.value / from.zoom.value,
factor,
)) as NormalizedZoomValue,
},
});
// 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;
},
);
};
+137 -20
View File
@@ -1,20 +1,62 @@
import React from "react";
import { vi } from "vitest";
import { Excalidraw } from "../index";
import { AnimationController } from "../renderer/animation";
import { SCROLL_TO_CONTENT_ANIMATION_KEY } from "../scroll";
import { API } from "./helpers/api";
import { act, render } from "./test-utils";
const { h } = window;
const waitForNextAnimationFrame = () => {
/**
* The scroll/zoom animation is driven by `AnimationController`. With render
* throttling enabled (see the `beforeEach` below) it schedules frames via
* `requestAnimationFrame`, advancing the easing based on elapsed wall-clock
* time. We use a very long animation `duration` (see `LONG_ANIMATION_DURATION`)
* so it can never complete while we sample it, and let a few frames pass
* between samples so the easing makes observable (but partial) progress.
*/
const LONG_ANIMATION_DURATION = 1_000_000;
const waitForAnimationProgress = (frames = 4) => {
return act(
() =>
new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
new Promise<void>((resolve) => {
let remaining = frames;
const step = () => {
if (--remaining <= 0) {
resolve();
} else {
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
}),
);
};
/**
* Polls until the scroll/zoom animation has removed itself from the
* `AnimationController` (i.e. it ran to completion), or until `maxFrames`
* elapses as a safety net so a regression can't hang the suite.
*/
const waitForAnimationToStop = (maxFrames = 200) => {
return act(
() =>
new Promise<void>((resolve) => {
let remaining = maxFrames;
const check = () => {
if (
!AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY) ||
--remaining <= 0
) {
resolve();
} else {
requestAnimationFrame(check);
}
};
requestAnimationFrame(check);
}),
);
};
@@ -77,6 +119,34 @@ describe("fitToContent", () => {
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
});
it("should default to fitToContent when scrolling to an element by id", async () => {
await render(<Excalidraw />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 50,
height: 100,
x: 50,
y: 100,
});
API.setElements([rectElement]);
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 });
});
// element is 10x taller than the viewport, so fit-to-content should
// drop the zoom to <= 1/10
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
});
it("should scroll the viewport to the selected element", async () => {
await render(<Excalidraw />);
@@ -109,11 +179,16 @@ describe("fitToContent", () => {
describe("fitToContent animated", () => {
beforeEach(() => {
vi.spyOn(window, "requestAnimationFrame");
// pace the animation via requestAnimationFrame instead of a tight
// setTimeout(0) loop, which would otherwise starve the test's own timers
window.EXCALIDRAW_THROTTLE_RENDER = true;
});
afterEach(() => {
vi.restoreAllMocks();
window.EXCALIDRAW_THROTTLE_RENDER = undefined;
// stop any in-flight scroll/zoom animation so it doesn't keep ticking on
// the unmounted component and leak into the next test via the singleton
AnimationController.reset();
});
it("should ease scroll the viewport to the selected element", async () => {
@@ -130,17 +205,18 @@ describe("fitToContent animated", () => {
});
act(() => {
h.app.scrollToContent(rectElement, { animate: true });
h.app.scrollToContent(rectElement, {
animate: true,
duration: LONG_ANIMATION_DURATION,
});
});
expect(window.requestAnimationFrame).toHaveBeenCalled();
// Since this is an animation, we expect values to change through time.
// We'll verify that the scroll values change at 50ms and 100ms
// the animation hasn't progressed yet, so we're still at the origin
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
await waitForNextAnimationFrame();
// Since this is an animation, we expect values to change through time.
await waitForAnimationProgress();
const prevScrollX = h.state.scrollX;
const prevScrollY = h.state.scrollY;
@@ -148,7 +224,7 @@ describe("fitToContent animated", () => {
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
await waitForNextAnimationFrame();
await waitForAnimationProgress();
expect(h.state.scrollX).not.toBe(prevScrollX);
expect(h.state.scrollY).not.toBe(prevScrollY);
@@ -171,12 +247,14 @@ describe("fitToContent animated", () => {
expect(h.state.scrollY).toBe(0);
act(() => {
h.app.scrollToContent(rectElement, { animate: true, fitToContent: true });
h.app.scrollToContent(rectElement, {
animate: true,
fitToContent: true,
duration: LONG_ANIMATION_DURATION,
});
});
expect(window.requestAnimationFrame).toHaveBeenCalled();
await waitForNextAnimationFrame();
await waitForAnimationProgress();
const prevScrollX = h.state.scrollX;
const prevScrollY = h.state.scrollY;
@@ -184,9 +262,48 @@ describe("fitToContent animated", () => {
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
await waitForNextAnimationFrame();
await waitForAnimationProgress();
expect(h.state.scrollX).not.toBe(prevScrollX);
expect(h.state.scrollY).not.toBe(prevScrollY);
});
it("should stop ticking and settle on the target once complete", async () => {
await render(<Excalidraw />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 100,
height: 100,
x: -100,
y: -100,
});
act(() => {
// a short duration so the animation completes within a few frames
h.app.scrollToContent(rectElement, { animate: true, duration: 10 });
});
await waitForAnimationToStop();
// the animation must remove itself from the controller rather than keep
// ticking forever after reaching the target
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
false,
);
// it should have settled on the target viewport (moved off the origin)
const settledScrollX = h.state.scrollX;
const settledScrollY = h.state.scrollY;
expect(settledScrollX).not.toBe(0);
expect(settledScrollY).not.toBe(0);
expect(h.state.shouldCacheIgnoreZoom).toBe(false);
// further frames must not move the viewport (no perpetual re-rendering)
await waitForAnimationProgress();
expect(h.state.scrollX).toBe(settledScrollX);
expect(h.state.scrollY).toBe(settledScrollY);
});
});