Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ce70b815e | |||
| c070c8ffa6 | |||
| e4c70cb6c6 | |||
| 20f694d110 |
@@ -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,
|
||||||
@@ -4341,148 +4340,60 @@ 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,
|
this.setState({
|
||||||
animate: opts?.animate ?? true,
|
toast: {
|
||||||
});
|
message: t("elementLink.notFound"),
|
||||||
} else if (isElementLink(target)) {
|
duration: 3000,
|
||||||
this.setState({
|
closable: true,
|
||||||
toast: {
|
},
|
||||||
message: t("elementLink.notFound"),
|
});
|
||||||
duration: 3000,
|
|
||||||
closable: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cancelInProgressAnimation?.();
|
// Navigating to an element by id or element-link defaults to zooming the
|
||||||
|
// element into view, animated — matching the historical element-link
|
||||||
// convert provided target into ExcalidrawElement[] if necessary
|
// behavior — unless the caller opts out.
|
||||||
const targetElements = Array.isArray(target) ? target : [target];
|
const resolvedOpts =
|
||||||
|
typeof target === "string"
|
||||||
let zoom = this.state.zoom;
|
? {
|
||||||
let scrollX = this.state.scrollX;
|
...opts,
|
||||||
let scrollY = this.state.scrollY;
|
fitToViewport: undefined,
|
||||||
|
fitToContent: opts?.fitToContent ?? true,
|
||||||
if (opts?.fitToContent || opts?.fitToViewport) {
|
animate: opts?.animate ?? true,
|
||||||
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
|
: opts;
|
||||||
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 = () => {
|
scrollToElements(
|
||||||
cancel();
|
this.state,
|
||||||
this.cancelInProgressAnimation = null;
|
elements,
|
||||||
};
|
this.setState.bind(this),
|
||||||
} else {
|
resolvedOpts,
|
||||||
this.setState({ scrollX, scrollY, zoom });
|
);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private maybeUnfollowRemoteUser = () => {
|
private maybeUnfollowRemoteUser = () => {
|
||||||
@@ -4495,7 +4406,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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,7 +101,38 @@ type RestoredAppState = Omit<
|
|||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const MAX_ARROW_PX = 75_000;
|
const MAX_LINEAR_PX = 75_000;
|
||||||
|
|
||||||
|
// Last resort fix for extremely large linear elements (lines / arrows), which
|
||||||
|
// would otherwise freeze the editor while rendering — e.g. a dotted or dashed
|
||||||
|
// stroke spanning a huge distance generates an enormous dash array.
|
||||||
|
// https://github.com/excalidraw/excalidraw/issues/11497
|
||||||
|
const handleOversizedLinearElements = <T extends ExcalidrawLinearElement>(
|
||||||
|
element: T,
|
||||||
|
): T => {
|
||||||
|
if (element.width <= MAX_LINEAR_PX && element.height <= MAX_LINEAR_PX) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label =
|
||||||
|
element.type === "arrow"
|
||||||
|
? `${isElbowArrow(element) ? "elbow" : "simple"} arrow`
|
||||||
|
: element.type;
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
`Removing extremely large ${label} ${element.id} (width: ${element.width}, height: ${element.height}, x: ${element.x}, y: ${element.y})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(100, 100)],
|
||||||
|
isDeleted: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const restoreLinearElementPoints = (
|
const restoreLinearElementPoints = (
|
||||||
points: unknown,
|
points: unknown,
|
||||||
@@ -560,7 +591,7 @@ export const restoreElement = (
|
|||||||
} as ExcalidrawLinearElement));
|
} as ExcalidrawLinearElement));
|
||||||
}
|
}
|
||||||
|
|
||||||
return restoreElementWithProperties(element, {
|
const restoredLine = restoreElementWithProperties(element, {
|
||||||
type: "line",
|
type: "line",
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
endBinding: null,
|
endBinding: null,
|
||||||
@@ -578,6 +609,8 @@ export const restoreElement = (
|
|||||||
: {}),
|
: {}),
|
||||||
...getSizeFromPoints(points),
|
...getSizeFromPoints(points),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return handleOversizedLinearElements(restoredLine);
|
||||||
case "arrow": {
|
case "arrow": {
|
||||||
const startArrowhead = normalizeArrowhead(element.startArrowhead);
|
const startArrowhead = normalizeArrowhead(element.startArrowhead);
|
||||||
const endArrowhead =
|
const endArrowhead =
|
||||||
@@ -644,37 +677,7 @@ export const restoreElement = (
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Last resort fix for extremely large arrows
|
return handleOversizedLinearElements(normalizedRestoredElement);
|
||||||
if (
|
|
||||||
normalizedRestoredElement.width > MAX_ARROW_PX ||
|
|
||||||
normalizedRestoredElement.height > MAX_ARROW_PX
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
`Removing extremely large arrow ${
|
|
||||||
normalizedRestoredElement.id
|
|
||||||
} (type: ${
|
|
||||||
isElbowArrow(normalizedRestoredElement) ? "elbow" : "simple"
|
|
||||||
}, width: ${normalizedRestoredElement.width}, height: ${
|
|
||||||
normalizedRestoredElement.height
|
|
||||||
}, x: ${normalizedRestoredElement.x}, y: ${
|
|
||||||
normalizedRestoredElement.y
|
|
||||||
})`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...normalizedRestoredElement,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
points: [
|
|
||||||
pointFrom<LocalPoint>(0, 0),
|
|
||||||
pointFrom<LocalPoint>(100, 100),
|
|
||||||
],
|
|
||||||
isDeleted: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedRestoredElement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// generic elements
|
// generic elements
|
||||||
|
|||||||
@@ -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,90 @@
|
|||||||
|
import { COLOR_WHITE } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { bootstrapCanvas } from "./helpers";
|
||||||
|
|
||||||
|
const setup = () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 200;
|
||||||
|
canvas.height = 100;
|
||||||
|
const context = canvas.getContext("2d")!;
|
||||||
|
const clearRect = vi.spyOn(context, "clearRect");
|
||||||
|
const fillRect = vi.spyOn(context, "fillRect");
|
||||||
|
return { canvas, context, clearRect, fillRect };
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = (viewBackgroundColor: unknown) => {
|
||||||
|
const { canvas, context, clearRect, fillRect } = setup();
|
||||||
|
bootstrapCanvas({
|
||||||
|
canvas,
|
||||||
|
scale: 1,
|
||||||
|
normalizedWidth: 200,
|
||||||
|
normalizedHeight: 100,
|
||||||
|
viewBackgroundColor: viewBackgroundColor as string,
|
||||||
|
});
|
||||||
|
return { context, clearRect, fillRect };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("bootstrapCanvas background painting", () => {
|
||||||
|
it("skips clearRect for an opaque hex color (fill fully repaints)", () => {
|
||||||
|
const { clearRect, fillRect } = run("#ffffff");
|
||||||
|
expect(clearRect).not.toHaveBeenCalled();
|
||||||
|
expect(fillRect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips clearRect for a 3-digit opaque hex color", () => {
|
||||||
|
const { clearRect, fillRect } = run("#fff");
|
||||||
|
expect(clearRect).not.toHaveBeenCalled();
|
||||||
|
expect(fillRect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears for a hex color with alpha (#RGBA / #RRGGBBAA)", () => {
|
||||||
|
expect(run("#ffff").clearRect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(run("#ffffff80").clearRect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears and skips fill for the transparent keyword", () => {
|
||||||
|
const { clearRect, fillRect } = run("transparent");
|
||||||
|
expect(clearRect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fillRect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears for rgba()/hsla() colors and still fills", () => {
|
||||||
|
const rgba = run("rgba(255, 0, 0, 0.5)");
|
||||||
|
expect(rgba.clearRect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(rgba.fillRect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// the ghosting bug (#10931): a corrupted value must never leave the prior
|
||||||
|
// frame on screen — we always clear when we can't prove the color is opaque
|
||||||
|
it("clears for a corrupted color value to prevent ghosting", () => {
|
||||||
|
expect(run("0000").clearRect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(run("asdfgh").clearRect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to white when the color is rejected by the canvas", () => {
|
||||||
|
const { canvas, context } = setup();
|
||||||
|
// simulate a stale fillStyle left over from a previous frame's drawing
|
||||||
|
context.fillStyle = "#ff0000";
|
||||||
|
let fillStyleAtFillTime = "";
|
||||||
|
vi.spyOn(context, "fillRect").mockImplementation(() => {
|
||||||
|
fillStyleAtFillTime = context.fillStyle as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
bootstrapCanvas({
|
||||||
|
canvas,
|
||||||
|
scale: 1,
|
||||||
|
normalizedWidth: 200,
|
||||||
|
normalizedHeight: 100,
|
||||||
|
viewBackgroundColor: "not-a-color",
|
||||||
|
});
|
||||||
|
|
||||||
|
// not the stale red — the seeded default
|
||||||
|
expect(fillStyleAtFillTime).toBe(COLOR_WHITE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears for a non-string background", () => {
|
||||||
|
const { clearRect, fillRect } = run(undefined);
|
||||||
|
expect(clearRect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fillRect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { THEME, applyDarkModeFilter } from "@excalidraw/common";
|
import { COLOR_WHITE, THEME, applyDarkModeFilter } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { StaticCanvasRenderConfig } from "../scene/types";
|
import type { StaticCanvasRenderConfig } from "../scene/types";
|
||||||
import type { AppState, StaticCanvasAppState } from "../types";
|
import type { AppState, StaticCanvasAppState } from "../types";
|
||||||
@@ -53,21 +53,31 @@ export const bootstrapCanvas = ({
|
|||||||
|
|
||||||
// Paint background
|
// Paint background
|
||||||
if (typeof viewBackgroundColor === "string") {
|
if (typeof viewBackgroundColor === "string") {
|
||||||
const hasTransparence =
|
// An opaque fill repaints every pixel, so clearRect would be redundant.
|
||||||
viewBackgroundColor === "transparent" ||
|
// For anything else — transparency, or a value we can't be certain about
|
||||||
viewBackgroundColor.length === 5 || // #RGBA
|
// (e.g. corrupted persisted state like "0000") — clear first so the
|
||||||
viewBackgroundColor.length === 9 || // #RRGGBBA
|
// previous frame can't bleed through.
|
||||||
/(hsla|rgba)\(/.test(viewBackgroundColor);
|
//
|
||||||
if (hasTransparence) {
|
// We skip opaque #RRGGBB and #RGB hex colors as a quick optimization.
|
||||||
|
const isOpaque = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(viewBackgroundColor);
|
||||||
|
|
||||||
|
if (!isOpaque) {
|
||||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||||
}
|
}
|
||||||
context.save();
|
|
||||||
context.fillStyle = applyDarkModeFilter(
|
if (viewBackgroundColor !== "transparent") {
|
||||||
viewBackgroundColor,
|
context.save();
|
||||||
theme === THEME.DARK,
|
// The canvas silently ignores an invalid fillStyle, which would leave a
|
||||||
);
|
// stale color from a previous draw. Seed a sane default so corrupted
|
||||||
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
|
// values fall back to white instead of painting garbage.
|
||||||
context.restore();
|
context.fillStyle = COLOR_WHITE;
|
||||||
|
context.fillStyle = applyDarkModeFilter(
|
||||||
|
viewBackgroundColor,
|
||||||
|
theme === THEME.DARK,
|
||||||
|
);
|
||||||
|
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
applyDarkModeFilter,
|
applyDarkModeFilter,
|
||||||
|
COLOR_WHITE,
|
||||||
FRAME_STYLE,
|
FRAME_STYLE,
|
||||||
THEME,
|
THEME,
|
||||||
throttleRAF,
|
throttleRAF,
|
||||||
@@ -204,7 +205,13 @@ const renderLinkIcon = (
|
|||||||
window.devicePixelRatio * appState.zoom.value,
|
window.devicePixelRatio * appState.zoom.value,
|
||||||
window.devicePixelRatio * appState.zoom.value,
|
window.devicePixelRatio * appState.zoom.value,
|
||||||
);
|
);
|
||||||
linkCanvasCacheContext.fillStyle = appState.viewBackgroundColor || "#fff";
|
|
||||||
|
// Seed a sane default so a corrupted color (silently rejected by the
|
||||||
|
// canvas) falls back to white instead of a stale fillStyle.
|
||||||
|
linkCanvasCacheContext.fillStyle = COLOR_WHITE;
|
||||||
|
linkCanvasCacheContext.fillStyle =
|
||||||
|
appState.viewBackgroundColor || COLOR_WHITE;
|
||||||
|
|
||||||
linkCanvasCacheContext.fillRect(0, 0, width, height);
|
linkCanvasCacheContext.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
if (canvasKey === "elementLink") {
|
if (canvasKey === "elementLink") {
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
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 };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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), and rather than
|
||||||
|
* tweening scrollX/scrollY directly we tween the *focal point* — the scene
|
||||||
|
* point under the viewport center — and derive scroll from it. Mixing a linear
|
||||||
|
* scroll with a geometric zoom makes the focal point swoop sideways
|
||||||
|
* mid-animation (most visible when zooming out); gliding the focal point keeps
|
||||||
|
* it steady. `width/2/zoom - scroll` is the inverse of `centerScrollOn` without
|
||||||
|
* offsets, so factor 0/1 land exactly on `from`/`target`.
|
||||||
|
*/
|
||||||
|
export const interpolateViewport = ({
|
||||||
|
from,
|
||||||
|
target,
|
||||||
|
factor,
|
||||||
|
}: {
|
||||||
|
from: Pick<AppState, "scrollX" | "scrollY" | "zoom" | "width" | "height">;
|
||||||
|
target: Viewport;
|
||||||
|
factor: number;
|
||||||
|
}): Viewport => {
|
||||||
|
const zoom = (from.zoom.value *
|
||||||
|
Math.pow(
|
||||||
|
target.zoom.value / from.zoom.value,
|
||||||
|
factor,
|
||||||
|
)) as NormalizedZoomValue;
|
||||||
|
|
||||||
|
const fromCenterX = from.width / 2 / from.zoom.value - from.scrollX;
|
||||||
|
const fromCenterY = from.height / 2 / from.zoom.value - from.scrollY;
|
||||||
|
const toCenterX = from.width / 2 / target.zoom.value - target.scrollX;
|
||||||
|
const toCenterY = from.height / 2 / target.zoom.value - target.scrollY;
|
||||||
|
|
||||||
|
const centerX = fromCenterX + (toCenterX - fromCenterX) * factor;
|
||||||
|
const centerY = fromCenterY + (toCenterY - fromCenterY) * factor;
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollX: from.width / 2 / zoom - centerX,
|
||||||
|
scrollY: from.height / 2 / zoom - centerY,
|
||||||
|
zoom: { value: 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" | "width" | "height">,
|
||||||
|
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({
|
||||||
|
...interpolateViewport({ from, target, factor }),
|
||||||
|
shouldCacheIgnoreZoom: progress < 1, // ignore zoom caching while animating
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -526,6 +526,53 @@ describe("restoreElements", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should mark extremely large linear elements as deleted to avoid freezing", () => {
|
||||||
|
const consoleError = vi
|
||||||
|
.spyOn(console, "error")
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
// a degenerate line with astronomical coordinates (see #11497)
|
||||||
|
const hugeLine: any = API.createElement({
|
||||||
|
type: "line",
|
||||||
|
x: 419048829414166,
|
||||||
|
y: 8484,
|
||||||
|
});
|
||||||
|
hugeLine.points = [
|
||||||
|
[0, 0],
|
||||||
|
[-302985021938436, 0],
|
||||||
|
[-838097658820234, 30],
|
||||||
|
];
|
||||||
|
|
||||||
|
const hugeArrow: any = API.createElement({ type: "arrow" });
|
||||||
|
hugeArrow.points = [
|
||||||
|
[0, 0],
|
||||||
|
[900000, 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalLine: any = API.createElement({ type: "line" });
|
||||||
|
normalLine.points = [
|
||||||
|
[0, 0],
|
||||||
|
[100, 200],
|
||||||
|
];
|
||||||
|
|
||||||
|
const [restoredLine, restoredArrow, restoredNormal] =
|
||||||
|
restore.restoreElements([hugeLine, hugeArrow, normalLine], null);
|
||||||
|
|
||||||
|
expect(restoredLine.isDeleted).toBe(true);
|
||||||
|
expect(restoredLine.width).toBe(100);
|
||||||
|
expect(restoredLine.height).toBe(100);
|
||||||
|
|
||||||
|
expect(restoredArrow.isDeleted).toBe(true);
|
||||||
|
expect(restoredArrow.width).toBe(100);
|
||||||
|
expect(restoredArrow.height).toBe(100);
|
||||||
|
|
||||||
|
expect(restoredNormal.isDeleted).toBe(false);
|
||||||
|
expect(restoredNormal.width).toBe(100);
|
||||||
|
expect(restoredNormal.height).toBe(200);
|
||||||
|
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("when the number of points of a line is greater or equal 2", () => {
|
it("when the number of points of a line is greater or equal 2", () => {
|
||||||
const lineElement_0 = API.createElement({
|
const lineElement_0 = API.createElement({
|
||||||
type: "line",
|
type: "line",
|
||||||
|
|||||||
@@ -1,20 +1,62 @@
|
|||||||
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 { SCROLL_TO_CONTENT_ANIMATION_KEY } from "../scroll";
|
||||||
|
|
||||||
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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
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 () => {
|
it("should scroll the viewport to the selected element", async () => {
|
||||||
await render(<Excalidraw />);
|
await render(<Excalidraw />);
|
||||||
|
|
||||||
@@ -109,11 +179,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 +205,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 +224,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 +247,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,9 +262,48 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user