feat: AnimationController for scrollToContent
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
@@ -204,135 +204,6 @@ export const easeOut = (k: number) => {
|
|||||||
return 1 - Math.pow(1 - k, 4);
|
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
|
// https://github.com/lodash/lodash/blob/es/chunk.js
|
||||||
export const chunk = <T extends any>(
|
export const chunk = <T extends any>(
|
||||||
array: readonly T[],
|
array: readonly T[],
|
||||||
|
|||||||
@@ -77,11 +77,9 @@ import {
|
|||||||
updateObject,
|
updateObject,
|
||||||
updateActiveTool,
|
updateActiveTool,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
easeToValuesRAF,
|
|
||||||
muteFSAbortError,
|
muteFSAbortError,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
easeOut,
|
|
||||||
updateStable,
|
updateStable,
|
||||||
addEventListener,
|
addEventListener,
|
||||||
normalizeEOL,
|
normalizeEOL,
|
||||||
@@ -203,7 +201,6 @@ import {
|
|||||||
cropElement,
|
cropElement,
|
||||||
wrapText,
|
wrapText,
|
||||||
isElementLink,
|
isElementLink,
|
||||||
parseElementLinkFromURL,
|
|
||||||
isMeasureTextSupported,
|
isMeasureTextSupported,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
measureText,
|
measureText,
|
||||||
@@ -264,6 +261,7 @@ import {
|
|||||||
getActiveTextElement,
|
getActiveTextElement,
|
||||||
isEligibleFrameChildType,
|
isEligibleFrameChildType,
|
||||||
getBindingStrategyForDraggingBindingElementEndpoints,
|
getBindingStrategyForDraggingBindingElementEndpoints,
|
||||||
|
parseElementLinkFromURL,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||||
@@ -331,7 +329,7 @@ import {
|
|||||||
actionToggleCropEditor,
|
actionToggleCropEditor,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||||
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
|
import { actionToggleHandTool } from "../actions/actionCanvas";
|
||||||
import { actionPaste } from "../actions/actionClipboard";
|
import { actionPaste } from "../actions/actionClipboard";
|
||||||
import { actionCopyElementLink } from "../actions/actionElementLink";
|
import { actionCopyElementLink } from "../actions/actionElementLink";
|
||||||
import { actionUnlockAllElements } from "../actions/actionElementLock";
|
import { actionUnlockAllElements } from "../actions/actionElementLock";
|
||||||
@@ -413,6 +411,11 @@ import {
|
|||||||
isGridModeEnabled,
|
isGridModeEnabled,
|
||||||
} from "../snapping";
|
} from "../snapping";
|
||||||
import { Renderer } from "../scene/Renderer";
|
import { Renderer } from "../scene/Renderer";
|
||||||
|
import {
|
||||||
|
type ScrollToContentOptions,
|
||||||
|
SCROLL_TO_CONTENT_ANIMATION_KEY,
|
||||||
|
scrollToElements,
|
||||||
|
} from "../scroll";
|
||||||
import {
|
import {
|
||||||
setEraserCursor,
|
setEraserCursor,
|
||||||
setCursor,
|
setCursor,
|
||||||
@@ -425,16 +428,12 @@ import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
|||||||
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
|
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
|
||||||
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
||||||
import { isOverScrollBars } from "../scene/scrollbars";
|
import { isOverScrollBars } from "../scene/scrollbars";
|
||||||
|
|
||||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||||
|
|
||||||
import { LassoTrail } from "../lasso";
|
import { LassoTrail } from "../lasso";
|
||||||
|
|
||||||
import { EraserTrail } from "../eraser";
|
import { EraserTrail } from "../eraser";
|
||||||
|
|
||||||
import { getShortcutKey } from "../shortcut";
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { tryParseSpreadsheet } from "../charts";
|
import { tryParseSpreadsheet } from "../charts";
|
||||||
|
import { AnimationController } from "../renderer/animation";
|
||||||
|
|
||||||
import ConvertElementTypePopup, {
|
import ConvertElementTypePopup, {
|
||||||
getConversionTypeFromElements,
|
getConversionTypeFromElements,
|
||||||
@@ -4339,148 +4338,42 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private cancelInProgressAnimation: (() => void) | null = null;
|
|
||||||
|
|
||||||
scrollToContent = (
|
scrollToContent = (
|
||||||
/**
|
target?:
|
||||||
* target to scroll to
|
|
||||||
*
|
|
||||||
* - string - id of element or group, or url containing elementLink
|
|
||||||
* - ExcalidrawElement | ExcalidrawElement[] - element(s) objects
|
|
||||||
*/
|
|
||||||
target:
|
|
||||||
| string
|
| string
|
||||||
| ExcalidrawElement
|
| ExcalidrawElement
|
||||||
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
| readonly NonDeletedExcalidrawElement[],
|
||||||
opts?: (
|
opts?: 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;
|
|
||||||
},
|
|
||||||
) => {
|
) => {
|
||||||
|
let elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||||
if (typeof target === "string") {
|
if (typeof target === "string") {
|
||||||
let id: string | null;
|
const id = isElementLink(target)
|
||||||
if (isElementLink(target)) {
|
? parseElementLinkFromURL(target)
|
||||||
id = parseElementLinkFromURL(target);
|
: target;
|
||||||
} else {
|
elements = id ? this.scene.getElementsFromId(id) : [];
|
||||||
id = target;
|
} else if (Array.isArray(target)) {
|
||||||
}
|
elements = target;
|
||||||
if (id) {
|
} else if (target) {
|
||||||
const elements = this.scene.getElementsFromId(id);
|
elements = [target as NonDeleted<ExcalidrawElement>];
|
||||||
|
} else {
|
||||||
|
elements = this.scene.getNonDeletedElements();
|
||||||
|
}
|
||||||
|
|
||||||
if (elements?.length) {
|
if (!elements.length) {
|
||||||
this.scrollToContent(elements, {
|
if (typeof target === "string" && isElementLink(target)) {
|
||||||
fitToContent: opts?.fitToContent ?? true,
|
|
||||||
animate: opts?.animate ?? true,
|
|
||||||
});
|
|
||||||
} else if (isElementLink(target)) {
|
|
||||||
this.setState({
|
|
||||||
toast: {
|
|
||||||
message: t("elementLink.notFound"),
|
|
||||||
duration: 3000,
|
|
||||||
closable: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
this.setState({
|
||||||
|
toast: {
|
||||||
|
message: t("elementLink.notFound"),
|
||||||
|
duration: 3000,
|
||||||
|
closable: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cancelInProgressAnimation?.();
|
scrollToElements(this.state, elements, this.setState.bind(this), opts);
|
||||||
|
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.cancelInProgressAnimation = () => {
|
|
||||||
cancel();
|
|
||||||
this.cancelInProgressAnimation = null;
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.setState({ scrollX, scrollY, zoom });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private maybeUnfollowRemoteUser = () => {
|
private maybeUnfollowRemoteUser = () => {
|
||||||
@@ -4493,7 +4386,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
private translateCanvas: React.Component<any, AppState>["setState"] = (
|
private translateCanvas: React.Component<any, AppState>["setState"] = (
|
||||||
state,
|
state,
|
||||||
) => {
|
) => {
|
||||||
this.cancelInProgressAnimation?.();
|
AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY);
|
||||||
|
this.setState({ shouldCacheIgnoreZoom: false });
|
||||||
this.maybeUnfollowRemoteUser();
|
this.maybeUnfollowRemoteUser();
|
||||||
this.setState(state);
|
this.setState(state);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -123,4 +123,9 @@ export class AnimationController {
|
|||||||
AnimationController.animations.delete(key);
|
AnimationController.animations.delete(key);
|
||||||
AnimationController.cancelScheduledFrameIfIdle();
|
AnimationController.cancelScheduledFrameIfIdle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static reset() {
|
||||||
|
AnimationController.animations.clear();
|
||||||
|
AnimationController.cancelScheduledFrame();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { easeOut } from "@excalidraw/common";
|
||||||
|
|
||||||
|
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">) => 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 {
|
||||||
|
onFrame(viewport);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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">) => 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);
|
||||||
|
|
||||||
|
if (progress >= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const factor = easeOut(progress);
|
||||||
|
|
||||||
|
onFrame({
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { elapsed };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,20 +1,36 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { vi } from "vitest";
|
|
||||||
|
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
|
import { AnimationController } from "../renderer/animation";
|
||||||
|
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { act, render } from "./test-utils";
|
import { act, render } from "./test-utils";
|
||||||
|
|
||||||
const { h } = window;
|
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(
|
return act(
|
||||||
() =>
|
() =>
|
||||||
new Promise((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
requestAnimationFrame(() => {
|
let remaining = frames;
|
||||||
requestAnimationFrame(resolve);
|
const step = () => {
|
||||||
});
|
if (--remaining <= 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(step);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -109,11 +125,16 @@ describe("fitToContent", () => {
|
|||||||
|
|
||||||
describe("fitToContent animated", () => {
|
describe("fitToContent animated", () => {
|
||||||
beforeEach(() => {
|
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(() => {
|
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 () => {
|
it("should ease scroll the viewport to the selected element", async () => {
|
||||||
@@ -130,17 +151,18 @@ describe("fitToContent animated", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
h.app.scrollToContent(rectElement, { animate: true });
|
h.app.scrollToContent(rectElement, {
|
||||||
|
animate: true,
|
||||||
|
duration: LONG_ANIMATION_DURATION,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(window.requestAnimationFrame).toHaveBeenCalled();
|
// the animation hasn't progressed yet, so we're still at the origin
|
||||||
|
|
||||||
// Since this is an animation, we expect values to change through time.
|
|
||||||
// We'll verify that the scroll values change at 50ms and 100ms
|
|
||||||
expect(h.state.scrollX).toBe(0);
|
expect(h.state.scrollX).toBe(0);
|
||||||
expect(h.state.scrollY).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 prevScrollX = h.state.scrollX;
|
||||||
const prevScrollY = h.state.scrollY;
|
const prevScrollY = h.state.scrollY;
|
||||||
@@ -148,7 +170,7 @@ describe("fitToContent animated", () => {
|
|||||||
expect(h.state.scrollX).not.toBe(0);
|
expect(h.state.scrollX).not.toBe(0);
|
||||||
expect(h.state.scrollY).not.toBe(0);
|
expect(h.state.scrollY).not.toBe(0);
|
||||||
|
|
||||||
await waitForNextAnimationFrame();
|
await waitForAnimationProgress();
|
||||||
|
|
||||||
expect(h.state.scrollX).not.toBe(prevScrollX);
|
expect(h.state.scrollX).not.toBe(prevScrollX);
|
||||||
expect(h.state.scrollY).not.toBe(prevScrollY);
|
expect(h.state.scrollY).not.toBe(prevScrollY);
|
||||||
@@ -171,12 +193,14 @@ describe("fitToContent animated", () => {
|
|||||||
expect(h.state.scrollY).toBe(0);
|
expect(h.state.scrollY).toBe(0);
|
||||||
|
|
||||||
act(() => {
|
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 waitForAnimationProgress();
|
||||||
|
|
||||||
await waitForNextAnimationFrame();
|
|
||||||
|
|
||||||
const prevScrollX = h.state.scrollX;
|
const prevScrollX = h.state.scrollX;
|
||||||
const prevScrollY = h.state.scrollY;
|
const prevScrollY = h.state.scrollY;
|
||||||
@@ -184,7 +208,7 @@ describe("fitToContent animated", () => {
|
|||||||
expect(h.state.scrollX).not.toBe(0);
|
expect(h.state.scrollX).not.toBe(0);
|
||||||
expect(h.state.scrollY).not.toBe(0);
|
expect(h.state.scrollY).not.toBe(0);
|
||||||
|
|
||||||
await waitForNextAnimationFrame();
|
await waitForAnimationProgress();
|
||||||
|
|
||||||
expect(h.state.scrollX).not.toBe(prevScrollX);
|
expect(h.state.scrollX).not.toBe(prevScrollX);
|
||||||
expect(h.state.scrollY).not.toBe(prevScrollY);
|
expect(h.state.scrollY).not.toBe(prevScrollY);
|
||||||
|
|||||||
Reference in New Issue
Block a user