Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ce70b815e | |||
| c070c8ffa6 | |||
| e4c70cb6c6 | |||
| 20f694d110 | |||
| 2a82821ec5 | |||
| 070df27e4d |
@@ -552,4 +552,4 @@ export const MOBILE_ACTION_BUTTON_BG = {
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_STROKE_STREAMLINE = 0.5;
|
||||
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.3;
|
||||
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.2;
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types"
|
||||
"outDir": "./dist/types",
|
||||
"rootDir": "../"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||
applyDarkModeFilter,
|
||||
DEFAULT_STROKE_STREAMLINE,
|
||||
DEFAULT_STROKE_STREAMLINE_PRECISE,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
@@ -1186,20 +1185,15 @@ const VARIABLE_WIDTH_FREEDRAW = {
|
||||
SIZE_FACTOR: 4.25,
|
||||
THINNING: 0.6,
|
||||
SMOOTHING: 0.5,
|
||||
STREAMLINE: DEFAULT_STROKE_STREAMLINE,
|
||||
} as const;
|
||||
|
||||
const CONSTANT_WIDTH_FREEDRAW = {
|
||||
/** Stroke size relative to `strokeWidth` for uniform (laser) strokes. */
|
||||
SIZE_FACTOR: 1.4,
|
||||
STREAMLINE: DEFAULT_STROKE_STREAMLINE_PRECISE,
|
||||
} as const;
|
||||
|
||||
const getFreedrawStreamline = (element: ExcalidrawFreeDrawElement) =>
|
||||
element.strokeOptions?.streamline ??
|
||||
(element.strokeOptions?.variability === "constant"
|
||||
? CONSTANT_WIDTH_FREEDRAW.STREAMLINE
|
||||
: VARIABLE_WIDTH_FREEDRAW.STREAMLINE);
|
||||
element.strokeOptions?.streamline ?? DEFAULT_STROKE_STREAMLINE;
|
||||
|
||||
/**
|
||||
* Pressure-sensitive (variable width) freedraw outline, rendered with
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../",
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
|
||||
@@ -112,9 +112,6 @@ export const actionClearCanvas = register({
|
||||
theme: appState.theme,
|
||||
penMode: appState.penMode,
|
||||
penDetected: appState.penDetected,
|
||||
currentItemStrokeVariability: appState.penDetected
|
||||
? "variable"
|
||||
: "constant",
|
||||
exportBackground: appState.exportBackground,
|
||||
exportEmbedScene: appState.exportEmbedScene,
|
||||
gridSize: appState.gridSize,
|
||||
|
||||
@@ -87,6 +87,7 @@ import type { CaptureUpdateActionType } from "@excalidraw/element";
|
||||
|
||||
import { trackEvent } from "../analytics";
|
||||
import { RadioSelection } from "../components/RadioSelection";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { FontPicker } from "../components/FontPicker/FontPicker";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
@@ -718,6 +719,25 @@ export const actionChangeFreedrawMode = register<StrokeVariability>({
|
||||
hasSelection ? null : appState.currentItemStrokeVariability,
|
||||
) ?? appState.currentItemStrokeVariability;
|
||||
|
||||
// in the compact UI the pressure setting is rendered as a single button
|
||||
// that cycles between the two variability modes on click
|
||||
if (data?.cycle) {
|
||||
const isVariable = strokeVariability === "variable";
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={
|
||||
isVariable
|
||||
? strokeVariabilityVariableIcon
|
||||
: strokeVariabilityConstantIcon
|
||||
}
|
||||
title={t("labels.pressure")}
|
||||
aria-label={t("labels.pressure")}
|
||||
onClick={() => updateData(isVariable ? "constant" : "variable")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.pressure")}</legend>
|
||||
|
||||
@@ -395,11 +395,17 @@ const CombinedShapeProperties = ({
|
||||
hasStrokeWidth(element.type),
|
||||
)) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
{(hasFreedrawMode(appState.activeTool.type) ||
|
||||
targetElements.some((element) =>
|
||||
hasFreedrawMode(element.type),
|
||||
)) &&
|
||||
renderAction("changeFreedrawMode")}
|
||||
{
|
||||
/* in compact UI the freedraw pressure setting is rendered as a
|
||||
standalone cycle button in the compact actions list; we render
|
||||
it in the combined properties popup as well for clarity
|
||||
*/
|
||||
(hasFreedrawMode(appState.activeTool.type) ||
|
||||
targetElements.some((element) =>
|
||||
hasFreedrawMode(element.type),
|
||||
)) &&
|
||||
renderAction("changeFreedrawMode")
|
||||
}
|
||||
{(hasStrokeStyle(appState.activeTool.type) ||
|
||||
targetElements.some((element) =>
|
||||
hasStrokeStyle(element.type),
|
||||
@@ -832,6 +838,14 @@ export const CompactShapeActions = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Freedraw pressure: standalone button cycling the variability mode */}
|
||||
{(hasFreedrawMode(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasFreedrawMode(element.type))) && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeFreedrawMode", { cycle: true })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CombinedShapeProperties
|
||||
appState={appState}
|
||||
renderAction={renderAction}
|
||||
@@ -1060,6 +1074,11 @@ export const ShapesSwitcher = ({
|
||||
const isFullStylesPanel = stylesPanelMode === "full";
|
||||
const isCompactStylesPanel = stylesPanelMode === "compact";
|
||||
|
||||
// a pen detected on a tool button's pointer-down, to be applied (enabling
|
||||
// pen mode) only after the tap's `change` has committed — see the tool
|
||||
// button handlers below
|
||||
const pendingPenDetectionRef = useRef(false);
|
||||
|
||||
const SELECTION_TOOLS = [
|
||||
{
|
||||
type: "selection",
|
||||
@@ -1158,8 +1177,13 @@ export const ShapesSwitcher = ({
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
// Detect the pen here (pointerType is reliable on pointer-down)
|
||||
// but DON'T enable pen mode yet: calling setState mid-gesture
|
||||
// re-renders the controlled radio and, on iOS/iPadOS, aborts
|
||||
// the ensuing click so the tool isn't selected on the first pen
|
||||
// tap. Defer it until the tap's `change` has committed (below).
|
||||
if (!app.state.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
pendingPenDetectionRef.current = true;
|
||||
}
|
||||
|
||||
if (value === "selection") {
|
||||
@@ -1170,16 +1194,21 @@ export const ShapesSwitcher = ({
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
onChange={() => {
|
||||
if (app.state.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
app.setActiveTool({ type: value });
|
||||
|
||||
// Apply the pen detection captured on pointer-down now that the
|
||||
// tool is selected. rAF keeps the resulting re-render out of the
|
||||
// `change` event itself. We rely on the pointer-down detection
|
||||
// rather than this handler's pointerType because the latter is
|
||||
// unreliable on iOS (its backing ref is cleared before the
|
||||
// delayed click fires).
|
||||
if (pendingPenDetectionRef.current) {
|
||||
pendingPenDetectionRef.current = false;
|
||||
requestAnimationFrame(() => app.togglePenMode(true));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
@@ -4308,7 +4307,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return {
|
||||
penMode: force ?? !prevState.penMode,
|
||||
penDetected: true,
|
||||
currentItemStrokeVariability: "variable",
|
||||
currentItemStrokeVariability: !prevState.penDetected
|
||||
? "variable"
|
||||
: prevState.currentItemStrokeVariability,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -4339,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 = () => {
|
||||
@@ -4493,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);
|
||||
};
|
||||
@@ -9015,7 +8929,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
strokeOptions: {
|
||||
variability: strokeVariability,
|
||||
streamline:
|
||||
strokeVariability === "constant" && event.pointerType !== "mouse"
|
||||
event.pointerType !== "mouse"
|
||||
? DEFAULT_STROKE_STREAMLINE_PRECISE
|
||||
: DEFAULT_STROKE_STREAMLINE,
|
||||
},
|
||||
|
||||
@@ -120,6 +120,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// on tablet, the pen mode button is rendered as a separate floating button
|
||||
// below the compact actions menu (see LayerUI.tsx)
|
||||
.App-menu_top__left > .ToolIcon__penMode {
|
||||
justify-self: center;
|
||||
|
||||
.ToolIcon__icon {
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
background-color: var(--island-bg-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
}
|
||||
|
||||
// no shadow while pen mode is active (the active fill is enough)
|
||||
.ToolIcon_type_checkbox:checked + .ToolIcon__icon {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.disable-view-mode {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -235,8 +235,6 @@ const LayerUI = ({
|
||||
);
|
||||
|
||||
const renderSelectedShapeActions = () => {
|
||||
const isCompactMode = isCompactStylesPanel;
|
||||
|
||||
return (
|
||||
<Section
|
||||
heading="selectedShapeActions"
|
||||
@@ -244,7 +242,7 @@ const LayerUI = ({
|
||||
"transition-left": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{isCompactMode ? (
|
||||
{isCompactStylesPanel ? (
|
||||
<Island
|
||||
className={clsx("compact-shape-actions-island")}
|
||||
padding={0}
|
||||
@@ -312,6 +310,23 @@ const LayerUI = ({
|
||||
>
|
||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||
</div>
|
||||
{/* in compact UI the pen mode button lives outside the toolbar, as
|
||||
a separate floating button below the compact actions menu
|
||||
(same as we render it on mobile); shown alongside the compact
|
||||
actions island, i.e. when a drawing tool or elements are
|
||||
selected */}
|
||||
{isCompactStylesPanel &&
|
||||
!appState.viewModeEnabled &&
|
||||
shouldRenderSelectedShapeActions && (
|
||||
<PenModeButton
|
||||
zenModeEnabled={appState.zenModeEnabled}
|
||||
checked={appState.penMode}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
title={t("toolBar.penMode")}
|
||||
isMobile
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
)}
|
||||
</Stack.Col>
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
||||
@@ -343,13 +358,18 @@ const LayerUI = ({
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={spacing.toolbarInnerRowGap}>
|
||||
<PenModeButton
|
||||
zenModeEnabled={appState.zenModeEnabled}
|
||||
checked={appState.penMode}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
title={t("toolBar.penMode")}
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
{/* in compact UI the pen mode button is rendered
|
||||
as a separate floating button below the compact
|
||||
actions menu */}
|
||||
{!isCompactStylesPanel && (
|
||||
<PenModeButton
|
||||
zenModeEnabled={appState.zenModeEnabled}
|
||||
checked={appState.penMode}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
title={t("toolBar.penMode")}
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
)}
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
|
||||
@@ -101,7 +101,38 @@ type RestoredAppState = Omit<
|
||||
"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 = (
|
||||
points: unknown,
|
||||
@@ -560,7 +591,7 @@ export const restoreElement = (
|
||||
} as ExcalidrawLinearElement));
|
||||
}
|
||||
|
||||
return restoreElementWithProperties(element, {
|
||||
const restoredLine = restoreElementWithProperties(element, {
|
||||
type: "line",
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
@@ -578,6 +609,8 @@ export const restoreElement = (
|
||||
: {}),
|
||||
...getSizeFromPoints(points),
|
||||
});
|
||||
|
||||
return handleOversizedLinearElements(restoredLine);
|
||||
case "arrow": {
|
||||
const startArrowhead = normalizeArrowhead(element.startArrowhead);
|
||||
const endArrowhead =
|
||||
@@ -644,37 +677,7 @@ export const restoreElement = (
|
||||
),
|
||||
};
|
||||
|
||||
// Last resort fix for extremely large arrows
|
||||
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;
|
||||
return handleOversizedLinearElements(normalizedRestoredElement);
|
||||
}
|
||||
|
||||
// generic elements
|
||||
|
||||
@@ -123,4 +123,9 @@ export class AnimationController {
|
||||
AnimationController.animations.delete(key);
|
||||
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 { AppState, StaticCanvasAppState } from "../types";
|
||||
@@ -53,21 +53,31 @@ export const bootstrapCanvas = ({
|
||||
|
||||
// Paint background
|
||||
if (typeof viewBackgroundColor === "string") {
|
||||
const hasTransparence =
|
||||
viewBackgroundColor === "transparent" ||
|
||||
viewBackgroundColor.length === 5 || // #RGBA
|
||||
viewBackgroundColor.length === 9 || // #RRGGBBA
|
||||
/(hsla|rgba)\(/.test(viewBackgroundColor);
|
||||
if (hasTransparence) {
|
||||
// An opaque fill repaints every pixel, so clearRect would be redundant.
|
||||
// For anything else — transparency, or a value we can't be certain about
|
||||
// (e.g. corrupted persisted state like "0000") — clear first so the
|
||||
// previous frame can't bleed through.
|
||||
//
|
||||
// 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.save();
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
viewBackgroundColor,
|
||||
theme === THEME.DARK,
|
||||
);
|
||||
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
|
||||
context.restore();
|
||||
|
||||
if (viewBackgroundColor !== "transparent") {
|
||||
context.save();
|
||||
// The canvas silently ignores an invalid fillStyle, which would leave a
|
||||
// stale color from a previous draw. Seed a sane default so corrupted
|
||||
// values fall back to white instead of painting garbage.
|
||||
context.fillStyle = COLOR_WHITE;
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
viewBackgroundColor,
|
||||
theme === THEME.DARK,
|
||||
);
|
||||
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
|
||||
context.restore();
|
||||
}
|
||||
} else {
|
||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
COLOR_WHITE,
|
||||
FRAME_STYLE,
|
||||
THEME,
|
||||
throttleRAF,
|
||||
@@ -204,7 +205,13 @@ const renderLinkIcon = (
|
||||
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);
|
||||
|
||||
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", () => {
|
||||
const lineElement_0 = API.createElement({
|
||||
type: "line",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../",
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["**/*"],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types"
|
||||
"outDir": "./dist/types",
|
||||
"rootDir": "../"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"version": "1.3.1",
|
||||
"description": "Generate outline for laser pointer tool",
|
||||
"type": "module",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"types": "./dist/types/laser-pointer/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
"module": "./dist/prod/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"types": "./dist/types/laser-pointer/src/index.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types"
|
||||
"outDir": "./dist/types",
|
||||
"rootDir": "../"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../",
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"declaration": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"jsx": "react-jsx",
|
||||
"emitDeclarationOnly": true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../",
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
Reference in New Issue
Block a user