Compare commits

..

2 Commits

Author SHA1 Message Date
dwelle 638d730cd5 split strokeWidth constants into own file 2026-06-24 09:23:42 +02:00
dwelle 80e83374d4 refactor(editor): add a lookup map for freedraw StrokeWidthKey 2026-06-24 09:18:44 +02:00
29 changed files with 454 additions and 773 deletions
@@ -0,0 +1,63 @@
import type { ExcalidrawElement } from "@excalidraw/element/types";
export type StrokeWidthKey = "thin" | "medium" | "bold";
export const STROKE_WIDTH_KEYS: readonly StrokeWidthKey[] = [
"thin",
"medium",
"bold",
];
export const STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 1,
medium: 2,
bold: 4,
extraBold: 8, // unused (may be introduced in the future)
};
// freedraw schema 2.0 uses thinner stroke, but to maintain backwards and
// forwards compatibility, instead of changing the shape renderer, we scale
// the stroke width by 1/2 (previous, thin was 1, medium 2 etc.)
//
// note that in the UI, STROKE_WIDTH.thin == FREEDRAW_STROKE_WIDTH.thin still
export const FREEDRAW_STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 0.5,
medium: 1,
bold: 2,
extraBold: 4, // legacy (may be used again in the future)
};
const STROKE_WIDTH_TO_KEY = {
generic: Object.fromEntries(
Object.entries(STROKE_WIDTH).map(([key, value]) => [value, key]),
) as Record<ExcalidrawElement["strokeWidth"], StrokeWidthKey | undefined>,
freedraw: Object.fromEntries(
Object.entries(FREEDRAW_STROKE_WIDTH).map(([key, value]) => [value, key]),
) as Record<ExcalidrawElement["strokeWidth"], StrokeWidthKey | undefined>,
};
export const getStrokeWidthKeyForElement = (
element: Pick<ExcalidrawElement, "type" | "strokeWidth">,
): StrokeWidthKey | null => {
const strokeWidthToKey =
element.type === "freedraw"
? STROKE_WIDTH_TO_KEY.freedraw
: STROKE_WIDTH_TO_KEY.generic;
return strokeWidthToKey[element.strokeWidth] ?? null;
};
export const getStrokeWidthByKey = (
elementType: ExcalidrawElement["type"],
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return elementType === "freedraw"
? FREEDRAW_STROKE_WIDTH[strokeWidthKey]
: STROKE_WIDTH[strokeWidthKey];
};
export const DEFAULT_ELEMENT_STROKE_WIDTH_KEY: StrokeWidthKey = "medium";
+5 -43
View File
@@ -5,6 +5,10 @@ import type {
import type { AppProps, AppState } from "@excalidraw/excalidraw/types"; import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors"; import { COLOR_PALETTE } from "./colors";
import {
STROKE_WIDTH,
DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
} from "./constants.strokeWidth";
export const supportsResizeObserver = export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window; typeof window !== "undefined" && "ResizeObserver" in window;
@@ -404,48 +408,6 @@ export const ROUGHNESS = {
cartoonist: 2, cartoonist: 2,
} as const; } as const;
export type StrokeWidthKey = "thin" | "medium" | "bold";
export const STROKE_WIDTH_KEYS: readonly StrokeWidthKey[] = [
"thin",
"medium",
"bold",
];
export const STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 1,
medium: 2,
bold: 4,
extraBold: 8, // unused (may be introduced in the future)
};
// freedraw schema 2.0 uses thinner stroke, but to maintain backwards and
// forwards compatibility, instead of changing the shape renderer, we scale
// the stroke width by 1/2 (previous, thin was 1, medium 2 etc.)
//
// note that in the UI, STROKE_WIDTH.thin == FREEDRAW_STROKE_WIDTH.thin still
export const FREEDRAW_STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 0.5,
medium: 1,
bold: 2,
extraBold: 4, // legacy (may be used again in the future)
};
export const getStrokeWidthByKey = (
elementType: ExcalidrawElement["type"],
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return elementType === "freedraw"
? FREEDRAW_STROKE_WIDTH[strokeWidthKey]
: STROKE_WIDTH[strokeWidthKey];
};
export const DEFAULT_ELEMENT_STROKE_WIDTH_KEY: StrokeWidthKey = "medium";
export const DEFAULT_ELEMENT_PROPS: { export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"]; strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"]; backgroundColor: ExcalidrawElement["backgroundColor"];
@@ -552,4 +514,4 @@ export const MOBILE_ACTION_BUTTON_BG = {
} as const; } as const;
export const DEFAULT_STROKE_STREAMLINE = 0.5; export const DEFAULT_STROKE_STREAMLINE = 0.5;
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.2; export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.3;
+1
View File
@@ -2,6 +2,7 @@ export * from "./binary-heap";
export * from "./bounds"; export * from "./bounds";
export * from "./colors"; export * from "./colors";
export * from "./constants"; export * from "./constants";
export * from "./constants.strokeWidth";
export * from "./font-metadata"; export * from "./font-metadata";
export * from "./queue"; export * from "./queue";
export * from "./keys"; export * from "./keys";
+129
View File
@@ -204,6 +204,135 @@ 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[],
+1 -2
View File
@@ -1,8 +1,7 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/types", "outDir": "./dist/types"
"rootDir": "../"
}, },
"include": ["src/**/*", "global.d.ts"], "include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"] "exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+7 -1
View File
@@ -26,6 +26,7 @@ import {
LINE_POLYGON_POINT_MERGE_DISTANCE, LINE_POLYGON_POINT_MERGE_DISTANCE,
applyDarkModeFilter, applyDarkModeFilter,
DEFAULT_STROKE_STREAMLINE, DEFAULT_STROKE_STREAMLINE,
DEFAULT_STROKE_STREAMLINE_PRECISE,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator"; import { RoughGenerator } from "roughjs/bin/generator";
@@ -1185,15 +1186,20 @@ const VARIABLE_WIDTH_FREEDRAW = {
SIZE_FACTOR: 4.25, SIZE_FACTOR: 4.25,
THINNING: 0.6, THINNING: 0.6,
SMOOTHING: 0.5, SMOOTHING: 0.5,
STREAMLINE: DEFAULT_STROKE_STREAMLINE,
} as const; } as const;
const CONSTANT_WIDTH_FREEDRAW = { const CONSTANT_WIDTH_FREEDRAW = {
/** Stroke size relative to `strokeWidth` for uniform (laser) strokes. */ /** Stroke size relative to `strokeWidth` for uniform (laser) strokes. */
SIZE_FACTOR: 1.4, SIZE_FACTOR: 1.4,
STREAMLINE: DEFAULT_STROKE_STREAMLINE_PRECISE,
} as const; } as const;
const getFreedrawStreamline = (element: ExcalidrawFreeDrawElement) => const getFreedrawStreamline = (element: ExcalidrawFreeDrawElement) =>
element.strokeOptions?.streamline ?? DEFAULT_STROKE_STREAMLINE; element.strokeOptions?.streamline ??
(element.strokeOptions?.variability === "constant"
? CONSTANT_WIDTH_FREEDRAW.STREAMLINE
: VARIABLE_WIDTH_FREEDRAW.STREAMLINE);
/** /**
* Pressure-sensitive (variable width) freedraw outline, rendered with * Pressure-sensitive (variable width) freedraw outline, rendered with
-1
View File
@@ -1,7 +1,6 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types" "outDir": "./dist/types"
}, },
"include": ["src/**/*", "global.d.ts"], "include": ["src/**/*", "global.d.ts"],
@@ -112,6 +112,9 @@ export const actionClearCanvas = register({
theme: appState.theme, theme: appState.theme,
penMode: appState.penMode, penMode: appState.penMode,
penDetected: appState.penDetected, penDetected: appState.penDetected,
currentItemStrokeVariability: appState.penDetected
? "variable"
: "constant",
exportBackground: appState.exportBackground, exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene, exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize, gridSize: appState.gridSize,
@@ -12,7 +12,6 @@ import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS, ROUNDNESS,
STROKE_WIDTH_KEYS,
VERTICAL_ALIGN, VERTICAL_ALIGN,
KEYS, KEYS,
randomInteger, randomInteger,
@@ -25,6 +24,7 @@ import {
invariant, invariant,
FONT_SIZES, FONT_SIZES,
type StrokeWidthKey, type StrokeWidthKey,
getStrokeWidthKeyForElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element"; import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
@@ -87,7 +87,6 @@ import type { CaptureUpdateActionType } from "@excalidraw/element";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { RadioSelection } from "../components/RadioSelection"; import { RadioSelection } from "../components/RadioSelection";
import { ToolButton } from "../components/ToolButton";
import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { FontPicker } from "../components/FontPicker/FontPicker"; import { FontPicker } from "../components/FontPicker/FontPicker";
import { IconPicker } from "../components/IconPicker"; import { IconPicker } from "../components/IconPicker";
@@ -555,23 +554,6 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
}, },
}); });
const getStrokeWidthKeyForElement = (
element: ExcalidrawElement,
): StrokeWidthKey | null => {
return (
STROKE_WIDTH_KEYS.find(
(key) => getStrokeWidthByKey(element.type, key) === element.strokeWidth,
) ?? null
);
};
const getStrokeWidthForElement = (
element: ExcalidrawElement,
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return getStrokeWidthByKey(element.type, strokeWidthKey);
};
export const actionChangeStrokeWidth = register<StrokeWidthKey>({ export const actionChangeStrokeWidth = register<StrokeWidthKey>({
name: "changeStrokeWidth", name: "changeStrokeWidth",
label: "labels.strokeWidth", label: "labels.strokeWidth",
@@ -582,7 +564,7 @@ export const actionChangeStrokeWidth = register<StrokeWidthKey>({
return { return {
elements: changeProperty(elements, appState, (el) => elements: changeProperty(elements, appState, (el) =>
newElementWith(el, { newElementWith(el, {
strokeWidth: getStrokeWidthForElement(el, value), strokeWidth: getStrokeWidthByKey(el.type, value),
}), }),
), ),
appState: { ...appState, currentItemStrokeWidthKey: value }, appState: { ...appState, currentItemStrokeWidthKey: value },
@@ -719,25 +701,6 @@ export const actionChangeFreedrawMode = register<StrokeVariability>({
hasSelection ? null : appState.currentItemStrokeVariability, hasSelection ? null : appState.currentItemStrokeVariability,
) ?? 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 ( return (
<fieldset> <fieldset>
<legend>{t("labels.pressure")}</legend> <legend>{t("labels.pressure")}</legend>
+13 -42
View File
@@ -395,17 +395,11 @@ const CombinedShapeProperties = ({
hasStrokeWidth(element.type), hasStrokeWidth(element.type),
)) && )) &&
renderAction("changeStrokeWidth")} renderAction("changeStrokeWidth")}
{ {(hasFreedrawMode(appState.activeTool.type) ||
/* in compact UI the freedraw pressure setting is rendered as a targetElements.some((element) =>
standalone cycle button in the compact actions list; we render hasFreedrawMode(element.type),
it in the combined properties popup as well for clarity )) &&
*/ renderAction("changeFreedrawMode")}
(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) =>
hasFreedrawMode(element.type),
)) &&
renderAction("changeFreedrawMode")
}
{(hasStrokeStyle(appState.activeTool.type) || {(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => targetElements.some((element) =>
hasStrokeStyle(element.type), hasStrokeStyle(element.type),
@@ -838,14 +832,6 @@ export const CompactShapeActions = ({
</div> </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 <CombinedShapeProperties
appState={appState} appState={appState}
renderAction={renderAction} renderAction={renderAction}
@@ -1074,11 +1060,6 @@ export const ShapesSwitcher = ({
const isFullStylesPanel = stylesPanelMode === "full"; const isFullStylesPanel = stylesPanelMode === "full";
const isCompactStylesPanel = stylesPanelMode === "compact"; 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 = [ const SELECTION_TOOLS = [
{ {
type: "selection", type: "selection",
@@ -1177,13 +1158,8 @@ export const ShapesSwitcher = ({
aria-keyshortcuts={shortcut} aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`} data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => { 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") { if (!app.state.penDetected && pointerType === "pen") {
pendingPenDetectionRef.current = true; app.togglePenMode(true);
} }
if (value === "selection") { if (value === "selection") {
@@ -1194,21 +1170,16 @@ export const ShapesSwitcher = ({
} }
} }
}} }}
onChange={() => { onChange={({ pointerType }) => {
if (app.state.activeTool.type !== value) { if (app.state.activeTool.type !== value) {
trackEvent("toolbar", value, "ui"); trackEvent("toolbar", value, "ui");
} }
app.setActiveTool({ type: value }); if (value === "image") {
app.setActiveTool({
// Apply the pen detection captured on pointer-down now that the type: value,
// tool is selected. rAF keeps the resulting re-render out of the });
// `change` event itself. We rely on the pointer-down detection } else {
// rather than this handler's pointerType because the latter is app.setActiveTool({ type: value });
// unreliable on iOS (its backing ref is cleared before the
// delayed click fires).
if (pendingPenDetectionRef.current) {
pendingPenDetectionRef.current = false;
requestAnimationFrame(() => app.togglePenMode(true));
} }
}} }}
/> />
+143 -57
View File
@@ -77,9 +77,11 @@ import {
updateObject, updateObject,
updateActiveTool, updateActiveTool,
isTransparent, isTransparent,
easeToValuesRAF,
muteFSAbortError, muteFSAbortError,
isTestEnv, isTestEnv,
isDevEnv, isDevEnv,
easeOut,
updateStable, updateStable,
addEventListener, addEventListener,
normalizeEOL, normalizeEOL,
@@ -201,6 +203,7 @@ import {
cropElement, cropElement,
wrapText, wrapText,
isElementLink, isElementLink,
parseElementLinkFromURL,
isMeasureTextSupported, isMeasureTextSupported,
normalizeText, normalizeText,
measureText, measureText,
@@ -261,7 +264,6 @@ 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";
@@ -329,7 +331,7 @@ import {
actionToggleCropEditor, actionToggleCropEditor,
} from "../actions"; } from "../actions";
import { actionWrapTextInContainer } from "../actions/actionBoundText"; import { actionWrapTextInContainer } from "../actions/actionBoundText";
import { actionToggleHandTool } from "../actions/actionCanvas"; import { actionToggleHandTool, zoomToFit } 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";
@@ -411,11 +413,6 @@ 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,
@@ -428,12 +425,16 @@ 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,
@@ -4307,9 +4308,7 @@ class App extends React.Component<AppProps, AppState> {
return { return {
penMode: force ?? !prevState.penMode, penMode: force ?? !prevState.penMode,
penDetected: true, penDetected: true,
currentItemStrokeVariability: !prevState.penDetected currentItemStrokeVariability: "variable",
? "variable"
: prevState.currentItemStrokeVariability,
}; };
}); });
}; };
@@ -4340,60 +4339,148 @@ 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 NonDeletedExcalidrawElement[], | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: ScrollToContentOptions, 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;
},
) => { ) => {
let elements: readonly NonDeleted<ExcalidrawElement>[];
if (typeof target === "string") { if (typeof target === "string") {
const id = isElementLink(target) let id: string | null;
? parseElementLinkFromURL(target) if (isElementLink(target)) {
: target; id = parseElementLinkFromURL(target);
elements = id ? this.scene.getElementsFromId(id) : []; } else {
} else if (Array.isArray(target)) { id = target;
elements = target;
} else if (target) {
elements = [target as NonDeleted<ExcalidrawElement>];
} else {
elements = this.scene.getNonDeletedElements();
}
if (!elements.length) {
if (typeof target === "string" && isElementLink(target)) {
this.setState({
toast: {
message: t("elementLink.notFound"),
duration: 3000,
closable: true,
},
});
} }
if (id) {
const elements = this.scene.getElementsFromId(id);
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,
},
});
}
}
return; return;
} }
// Navigating to an element by id or element-link defaults to zooming the this.cancelInProgressAnimation?.();
// 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,
}
: opts;
scrollToElements( // convert provided target into ExcalidrawElement[] if necessary
this.state, const targetElements = Array.isArray(target) ? target : [target];
elements,
this.setState.bind(this), let zoom = this.state.zoom;
resolvedOpts, let scrollX = this.state.scrollX;
); let scrollY = this.state.scrollY;
if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({
canvasOffsets: opts.canvasOffsets,
targetElements,
appState: this.state,
fitToViewport: !!opts?.fitToViewport,
viewportZoomFactor: opts?.viewportZoomFactor,
minZoom: opts?.minZoom,
maxZoom: opts?.maxZoom,
});
zoom = appState.zoom;
scrollX = appState.scrollX;
scrollY = appState.scrollY;
} else {
// compute only the viewport location, without any zoom adjustment
const scroll = calculateScrollCenter(targetElements, this.state);
scrollX = scroll.scrollX;
scrollY = scroll.scrollY;
}
// when animating, we use RequestAnimationFrame to prevent the animation
// from slowing down other processes
if (opts?.animate) {
const origScrollX = this.state.scrollX;
const origScrollY = this.state.scrollY;
const origZoom = this.state.zoom.value;
const cancel = easeToValuesRAF({
fromValues: {
scrollX: origScrollX,
scrollY: origScrollY,
zoom: origZoom,
},
toValues: { scrollX, scrollY, zoom: zoom.value },
interpolateValue: (from, to, progress, key) => {
// for zoom, use different easing
if (key === "zoom") {
return from * Math.pow(to / from, easeOut(progress));
}
// handle using default
return undefined;
},
onStep: ({ scrollX, scrollY, zoom }) => {
this.setState({
scrollX,
scrollY,
zoom: { value: zoom },
});
},
onStart: () => {
this.setState({ shouldCacheIgnoreZoom: true });
},
onEnd: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
onCancel: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
duration: opts?.duration ?? 500,
});
this.cancelInProgressAnimation = () => {
cancel();
this.cancelInProgressAnimation = null;
};
} else {
this.setState({ scrollX, scrollY, zoom });
}
}; };
private maybeUnfollowRemoteUser = () => { private maybeUnfollowRemoteUser = () => {
@@ -4406,8 +4493,7 @@ class App extends React.Component<AppProps, AppState> {
private translateCanvas: React.Component<any, AppState>["setState"] = ( private translateCanvas: React.Component<any, AppState>["setState"] = (
state, state,
) => { ) => {
AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY); this.cancelInProgressAnimation?.();
this.setState({ shouldCacheIgnoreZoom: false });
this.maybeUnfollowRemoteUser(); this.maybeUnfollowRemoteUser();
this.setState(state); this.setState(state);
}; };
@@ -8929,7 +9015,7 @@ class App extends React.Component<AppProps, AppState> {
strokeOptions: { strokeOptions: {
variability: strokeVariability, variability: strokeVariability,
streamline: streamline:
event.pointerType !== "mouse" strokeVariability === "constant" && event.pointerType !== "mouse"
? DEFAULT_STROKE_STREAMLINE_PRECISE ? DEFAULT_STROKE_STREAMLINE_PRECISE
: DEFAULT_STROKE_STREAMLINE, : DEFAULT_STROKE_STREAMLINE,
}, },
@@ -120,24 +120,6 @@
} }
} }
// 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 { .disable-view-mode {
display: flex; display: flex;
justify-content: center; justify-content: center;
+10 -30
View File
@@ -235,6 +235,8 @@ const LayerUI = ({
); );
const renderSelectedShapeActions = () => { const renderSelectedShapeActions = () => {
const isCompactMode = isCompactStylesPanel;
return ( return (
<Section <Section
heading="selectedShapeActions" heading="selectedShapeActions"
@@ -242,7 +244,7 @@ const LayerUI = ({
"transition-left": appState.zenModeEnabled, "transition-left": appState.zenModeEnabled,
})} })}
> >
{isCompactStylesPanel ? ( {isCompactMode ? (
<Island <Island
className={clsx("compact-shape-actions-island")} className={clsx("compact-shape-actions-island")}
padding={0} padding={0}
@@ -310,23 +312,6 @@ const LayerUI = ({
> >
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</div> </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> </Stack.Col>
{!appState.viewModeEnabled && {!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && ( appState.openDialog?.name !== "elementLinkSelector" && (
@@ -358,18 +343,13 @@ const LayerUI = ({
/> />
{heading} {heading}
<Stack.Row gap={spacing.toolbarInnerRowGap}> <Stack.Row gap={spacing.toolbarInnerRowGap}>
{/* in compact UI the pen mode button is rendered <PenModeButton
as a separate floating button below the compact zenModeEnabled={appState.zenModeEnabled}
actions menu */} checked={appState.penMode}
{!isCompactStylesPanel && ( onChange={() => onPenModeToggle(null)}
<PenModeButton title={t("toolBar.penMode")}
zenModeEnabled={appState.zenModeEnabled} penDetected={appState.penDetected}
checked={appState.penMode} />
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
)}
<LockButton <LockButton
checked={appState.activeTool.locked} checked={appState.activeTool.locked}
onChange={onLockToggle} onChange={onLockToggle}
+36 -38
View File
@@ -19,9 +19,8 @@ import {
getSizeFromPoints, getSizeFromPoints,
normalizeLink, normalizeLink,
getLineHeight, getLineHeight,
STROKE_WIDTH,
STROKE_WIDTH_KEYS, STROKE_WIDTH_KEYS,
type StrokeWidthKey, STROKE_WIDTH,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
@@ -58,6 +57,8 @@ import { getNormalizedDimensions } from "@excalidraw/element";
import { isInvisiblySmallElement } from "@excalidraw/element"; import { isInvisiblySmallElement } from "@excalidraw/element";
import type { StrokeWidthKey } from "@excalidraw/common";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
import type { import type {
@@ -101,38 +102,7 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height"
>; >;
const MAX_LINEAR_PX = 75_000; const MAX_ARROW_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,
@@ -591,7 +561,7 @@ export const restoreElement = (
} as ExcalidrawLinearElement)); } as ExcalidrawLinearElement));
} }
const restoredLine = restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
type: "line", type: "line",
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
@@ -609,8 +579,6 @@ 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 =
@@ -677,7 +645,37 @@ export const restoreElement = (
), ),
}; };
return handleOversizedLinearElements(normalizedRestoredElement); // 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;
} }
// generic elements // generic elements
@@ -123,9 +123,4 @@ export class AnimationController {
AnimationController.animations.delete(key); AnimationController.animations.delete(key);
AnimationController.cancelScheduledFrameIfIdle(); AnimationController.cancelScheduledFrameIfIdle();
} }
static reset() {
AnimationController.animations.clear();
AnimationController.cancelScheduledFrame();
}
} }
@@ -1,90 +0,0 @@
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();
});
});
+14 -24
View File
@@ -1,4 +1,4 @@
import { COLOR_WHITE, THEME, applyDarkModeFilter } from "@excalidraw/common"; import { 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,31 +53,21 @@ export const bootstrapCanvas = ({
// Paint background // Paint background
if (typeof viewBackgroundColor === "string") { if (typeof viewBackgroundColor === "string") {
// An opaque fill repaints every pixel, so clearRect would be redundant. const hasTransparence =
// For anything else — transparency, or a value we can't be certain about viewBackgroundColor === "transparent" ||
// (e.g. corrupted persisted state like "0000") — clear first so the viewBackgroundColor.length === 5 || // #RGBA
// previous frame can't bleed through. viewBackgroundColor.length === 9 || // #RRGGBBA
// /(hsla|rgba)\(/.test(viewBackgroundColor);
// We skip opaque #RRGGBB and #RGB hex colors as a quick optimization. if (hasTransparence) {
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();
if (viewBackgroundColor !== "transparent") { context.fillStyle = applyDarkModeFilter(
context.save(); viewBackgroundColor,
// The canvas silently ignores an invalid fillStyle, which would leave a theme === THEME.DARK,
// stale color from a previous draw. Seed a sane default so corrupted );
// values fall back to white instead of painting garbage. context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.fillStyle = COLOR_WHITE; context.restore();
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 -8
View File
@@ -1,6 +1,5 @@
import { import {
applyDarkModeFilter, applyDarkModeFilter,
COLOR_WHITE,
FRAME_STYLE, FRAME_STYLE,
THEME, THEME,
throttleRAF, throttleRAF,
@@ -205,13 +204,7 @@ 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") {
-180
View File
@@ -1,180 +0,0 @@
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,53 +526,6 @@ 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",
+20 -137
View File
@@ -1,62 +1,20 @@
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<void>((resolve) => { new Promise((resolve) => {
let remaining = frames; requestAnimationFrame(() => {
const step = () => { requestAnimationFrame(resolve);
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);
}), }),
); );
}; };
@@ -119,34 +77,6 @@ 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 />);
@@ -179,16 +109,11 @@ describe("fitToContent", () => {
describe("fitToContent animated", () => { describe("fitToContent animated", () => {
beforeEach(() => { beforeEach(() => {
// pace the animation via requestAnimationFrame instead of a tight vi.spyOn(window, "requestAnimationFrame");
// setTimeout(0) loop, which would otherwise starve the test's own timers
window.EXCALIDRAW_THROTTLE_RENDER = true;
}); });
afterEach(() => { afterEach(() => {
window.EXCALIDRAW_THROTTLE_RENDER = undefined; vi.restoreAllMocks();
// 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 () => {
@@ -205,18 +130,17 @@ describe("fitToContent animated", () => {
}); });
act(() => { act(() => {
h.app.scrollToContent(rectElement, { h.app.scrollToContent(rectElement, { animate: true });
animate: true,
duration: LONG_ANIMATION_DURATION,
});
}); });
// the animation hasn't progressed yet, so we're still at the origin 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
expect(h.state.scrollX).toBe(0); expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0); expect(h.state.scrollY).toBe(0);
// Since this is an animation, we expect values to change through time. await waitForNextAnimationFrame();
await waitForAnimationProgress();
const prevScrollX = h.state.scrollX; const prevScrollX = h.state.scrollX;
const prevScrollY = h.state.scrollY; const prevScrollY = h.state.scrollY;
@@ -224,7 +148,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 waitForAnimationProgress(); await waitForNextAnimationFrame();
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);
@@ -247,14 +171,12 @@ describe("fitToContent animated", () => {
expect(h.state.scrollY).toBe(0); expect(h.state.scrollY).toBe(0);
act(() => { act(() => {
h.app.scrollToContent(rectElement, { h.app.scrollToContent(rectElement, { animate: true, fitToContent: true });
animate: true,
fitToContent: true,
duration: LONG_ANIMATION_DURATION,
});
}); });
await waitForAnimationProgress(); expect(window.requestAnimationFrame).toHaveBeenCalled();
await waitForNextAnimationFrame();
const prevScrollX = h.state.scrollX; const prevScrollX = h.state.scrollX;
const prevScrollY = h.state.scrollY; const prevScrollY = h.state.scrollY;
@@ -262,48 +184,9 @@ 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 waitForAnimationProgress(); await waitForNextAnimationFrame();
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);
});
}); });
-1
View File
@@ -1,7 +1,6 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types" "outDir": "./dist/types"
}, },
"include": ["**/*"], "include": ["**/*"],
+1 -2
View File
@@ -1,8 +1,7 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/types", "outDir": "./dist/types"
"rootDir": "../"
}, },
"include": ["src/**/*", "global.d.ts"], "include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"] "exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+2 -2
View File
@@ -3,12 +3,12 @@
"version": "1.3.1", "version": "1.3.1",
"description": "Generate outline for laser pointer tool", "description": "Generate outline for laser pointer tool",
"type": "module", "type": "module",
"types": "./dist/types/laser-pointer/src/index.d.ts", "types": "./dist/types/index.d.ts",
"main": "./dist/prod/index.js", "main": "./dist/prod/index.js",
"module": "./dist/prod/index.js", "module": "./dist/prod/index.js",
"exports": { "exports": {
".": { ".": {
"types": "./dist/types/laser-pointer/src/index.d.ts", "types": "./dist/types/index.d.ts",
"development": "./dist/dev/index.js", "development": "./dist/dev/index.js",
"production": "./dist/prod/index.js", "production": "./dist/prod/index.js",
"default": "./dist/prod/index.js" "default": "./dist/prod/index.js"
+1 -2
View File
@@ -1,8 +1,7 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/types", "outDir": "./dist/types"
"rootDir": "../"
}, },
"include": ["src/**/*", "global.d.ts"], "include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"] "exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
-1
View File
@@ -1,7 +1,6 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types" "outDir": "./dist/types"
}, },
"include": ["src/**/*", "global.d.ts"], "include": ["src/**/*", "global.d.ts"],
+1 -1
View File
@@ -6,7 +6,7 @@
"declaration": true, "declaration": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "Node",
"resolveJsonModule": true, "resolveJsonModule": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
-1
View File
@@ -1,7 +1,6 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types" "outDir": "./dist/types"
}, },
"include": ["src/**/*", "global.d.ts"], "include": ["src/**/*", "global.d.ts"],
+1 -1
View File
@@ -12,7 +12,7 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,