Compare commits

...

19 Commits

Author SHA1 Message Date
dwelle 7859531efe fix ts 2026-03-21 20:56:56 +01:00
dwelle 3b47fe2f87 [debug] arrow control points 2026-03-21 20:50:19 +01:00
Mark Tolmacs d73f700fa8 fix: Arrowheads
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-21 15:46:01 +01:00
Mark Tolmacs ae1195a8f2 feat: Ghost points at the end of the splines
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-20 14:33:04 +00:00
David Luzar 81ab857a6f feat(editor): various text related improvements (#10979) 2026-03-19 16:00:58 +01:00
David Luzar e8b4620a96 feat(editor): put caret at pointer coords when clicking on selected text element (#10970) 2026-03-18 19:14:44 +01:00
David Luzar 2b0e4c9623 fix(editor): remove leftover debug code path (#10954) 2026-03-14 13:12:48 +01:00
David Luzar c9ba7f839c chore(editor): bump @excalidraw/mermaid-to-excalidraw@2.1.1 (#10944) 2026-03-12 17:32:13 +01:00
David Luzar b4ce7c713b fix(editor): arrowhead picker overflowing viewport (#10943) 2026-03-12 16:08:46 +01:00
David Luzar 816c81c12e feat(editor): ERD arrowheads and diagrams (#10940) 2026-03-11 22:00:31 +01:00
David Luzar 92d25446d6 feat(packages/excalidraw): tweak and expose more API around state and lifecycle (#10939) 2026-03-11 16:51:03 +01:00
David Luzar e73a5b0116 docs(packages/excalidraw): improve readme (#10932) 2026-03-11 09:49:12 +01:00
David Luzar 21dd1cfacc feat(packages/excalidraw): state tracking, api hook, and others (#10870) 2026-03-08 23:15:18 +01:00
David Luzar fa1f7d9f22 feat(packages/excalidraw): export throttleRAF (#10912) 2026-03-07 12:05:33 +01:00
David Luzar 3d8c12fba4 fix(editor): do not conditionally disable midpoint snapping menu preference (#10906) 2026-03-06 20:44:57 +01:00
David Luzar 757dfeb6ad fix(editor): call throttleRAF with lastArgs and remove trailing (#10905)
Co-authored-by: Varun Chawla <varun_6april@hotmail.com>
Co-authored-by: aziamimoh <aziamimoh@users.noreply.github.com>
Co-authored-by: pgzcoa <pgzcoa@users.noreply.github.com>
Co-authored-by: TinaZhang24 <TinaZhang24@users.noreply.github.com>
2026-03-06 20:40:36 +01:00
David Luzar a0e93b6040 feat(editor): sync export theme with ui theme (#10903) 2026-03-06 18:37:28 +01:00
Hendrik Horstmann 499e9d64a5 fix: dropdownMenu item badge position (#10895) 2026-03-06 08:41:49 +00:00
David Luzar c1dbbdf678 feat(editor): mermaid code editor & improve parsing (#10897) 2026-03-05 18:52:41 +01:00
108 changed files with 7561 additions and 1346 deletions
+22 -1
View File
@@ -39,5 +39,26 @@
"allowReferrer": true "allowReferrer": true
} }
] ]
} },
"overrides": [
{
"files": ["packages/excalidraw/**/*.{ts,tsx}"],
"excludedFiles": ["packages/excalidraw/**/*.test.{ts,tsx}", "packages/excalidraw/**/*.test.*.{ts,tsx}"],
"rules": {
"@typescript-eslint/no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@excalidraw/excalidraw"],
"message": "Do not import from the barrel 'index.tsx' files. Use direct relative imports to the specific module instead.",
"allowTypeImports": true
}
],
"paths": [".", "..", "../..", "../../..", "../../../..", "../../../../..", "../index", "../../index", "../../../index", "../../../../index"]
}
]
}
}
]
} }
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow", type: "arrow",
x: 450, x: 450,
y: 20, y: 20,
startArrowhead: "dot", startArrowhead: "circle",
endArrowhead: "triangle", endArrowhead: "triangle",
strokeColor: "#1971c2", strokeColor: "#1971c2",
strokeWidth: 2, strokeWidth: 2,
@@ -1,4 +1,4 @@
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform"; import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform";
import type { FileId } from "@excalidraw/excalidraw/element/types"; import type { FileId } from "@excalidraw/excalidraw/element/types";
const elements: ExcalidrawElementSkeleton[] = [ const elements: ExcalidrawElementSkeleton[] = [
+5 -5
View File
@@ -3,14 +3,14 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"@excalidraw/excalidraw": "*", "@excalidraw/excalidraw": "*",
"browser-fs-access": "0.29.1" "browser-fs-access": "0.38.0",
"react": "19.0.0",
"react-dom": "19.0.0"
}, },
"devDependencies": { "devDependencies": {
"vite": "5.0.12", "typescript": "^5",
"typescript": "^5" "vite": "5.0.12"
}, },
"scripts": { "scripts": {
"start": "vite", "start": "vite",
-36
View File
@@ -4,8 +4,6 @@ import { unstable_batchedUpdates } from "react-dom";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500;
export type ResolvablePromise<T> = Promise<T> & { export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void; resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
@@ -54,40 +52,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions, extensions,
mimeTypes, mimeTypes,
multiple: opts.multiple ?? false, multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener("keyup", scheduleRejection);
document.addEventListener("pointerup", scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (input.files?.length) {
const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType);
}
};
requestAnimationFrame(() => {
window.addEventListener("focus", focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
scheduleRejection.cancel();
window.removeEventListener("focus", focusHandler);
document.removeEventListener("keyup", scheduleRejection);
document.removeEventListener("pointerup", scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new Error("Request Aborted"));
}
};
},
}) as Promise<RetType>; }) as Promise<RetType>;
}; };
+92 -20
View File
@@ -5,6 +5,8 @@ import {
CaptureUpdateAction, CaptureUpdateAction,
reconcileElements, reconcileElements,
useEditorInterface, useEditorInterface,
ExcalidrawAPIProvider,
useExcalidrawAPI,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics"; import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -34,7 +36,6 @@ import {
import polyfill from "@excalidraw/excalidraw/polyfill"; import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob"; import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
import { t } from "@excalidraw/excalidraw/i18n"; import { t } from "@excalidraw/excalidraw/i18n";
import { import {
@@ -74,6 +75,7 @@ import type {
BinaryFiles, BinaryFiles,
ExcalidrawInitialDataState, ExcalidrawInitialDataState,
UIAppState, UIAppState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type { ResolutionType } from "@excalidraw/common/utility-types"; import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils"; import type { ResolvablePromise } from "@excalidraw/common/utils";
@@ -114,6 +116,7 @@ import {
} from "./data"; } from "./data";
import { updateStaleImageStatuses } from "./data/FileManager"; import { updateStaleImageStatuses } from "./data/FileManager";
import { FileStatusStore } from "./data/fileStatusStore";
import { import {
importFromLocalStorage, importFromLocalStorage,
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
@@ -369,6 +372,8 @@ const initializeScene = async (opts: {
}; };
const ExcalidrawWrapper = () => { const ExcalidrawWrapper = () => {
const excalidrawAPI = useExcalidrawAPI();
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe(); const isCollabDisabled = isRunningInIframe();
@@ -399,9 +404,6 @@ const ExcalidrawWrapper = () => {
}, VERSION_TIMEOUT); }, VERSION_TIMEOUT);
}, []); }, []);
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [, setShareDialogState] = useAtom(shareDialogStateAtom); const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom); const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -433,18 +435,15 @@ const ExcalidrawWrapper = () => {
} }
}, [excalidrawAPI]); }, [excalidrawAPI]);
useEffect(() => { // ---------------------------------------------------------------------------
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) { // Hoisted loadImages
return; // ---------------------------------------------------------------------------
} const loadImages = useCallback(
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
const loadImages = ( if (!data.scene || !excalidrawAPI) {
data: ResolutionType<typeof initializeScene>,
isInitialLoad = false,
) => {
if (!data.scene) {
return; return;
} }
if (collabAPI?.isCollaborating()) { if (collabAPI?.isCollaborating()) {
if (data.scene.elements) { if (data.scene.elements) {
collabAPI collabAPI
@@ -471,6 +470,12 @@ const ExcalidrawWrapper = () => {
}, [] as FileId[]) || []; }, [] as FileId[]) || [];
if (data.isExternalScene) { if (data.isExternalScene) {
if (fileIds.length) {
// Direct Firebase call (not through FileManager), so track manually
FileStatusStore.updateStatuses(
fileIds.map((id) => [id, "loading"]),
);
}
loadFilesFromFirebase( loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`, `${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key, data.key,
@@ -482,12 +487,18 @@ const ExcalidrawWrapper = () => {
erroredFiles, erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(), elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
}); });
FileStatusStore.updateStatuses([
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
...[...erroredFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
]);
}); });
} else if (isInitialLoad) { } else if (isInitialLoad) {
if (fileIds.length) { if (fileIds.length) {
LocalData.fileStorage LocalData.fileStorage
.getFiles(fileIds) .getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => { .then(async ({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) { if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles); excalidrawAPI.addFiles(loadedFiles);
} }
@@ -500,10 +511,19 @@ const ExcalidrawWrapper = () => {
} }
// on fresh load, clear unused files from IDB (from previous // on fresh load, clear unused files from IDB (from previous
// session) // session)
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds }); LocalData.fileStorage.clearObsoleteFiles({
currentFileIds: fileIds,
});
} }
} }
}; },
[collabAPI, excalidrawAPI],
);
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
}
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => { initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true); loadImages(data, /* isInitialLoad */ true);
@@ -628,7 +648,7 @@ const ExcalidrawWrapper = () => {
false, false,
); );
}; };
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]); }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
useEffect(() => { useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => { const unloadHandler = (event: BeforeUnloadEvent) => {
@@ -773,6 +793,56 @@ const ExcalidrawWrapper = () => {
[setShareDialogState], [setShareDialogState],
); );
// ---------------------------------------------------------------------------
// onExport — intercepts file save to wait for pending image loads
// ---------------------------------------------------------------------------
const onExport: Required<ExcalidrawProps>["onExport"] = useCallback(
async function* () {
let snapshot = FileStatusStore.getSnapshot();
const { pending, total } = FileStatusStore.getPendingCount(
snapshot.value,
);
if (pending === 0) {
return;
}
// Yield initial progress
yield {
type: "progress",
progress: (total - pending) / total,
message: `Loading images (${total - pending}/${total})...`,
};
// Wait for all pending images to finish
while (true) {
snapshot = await FileStatusStore.pull(snapshot.version);
const { pending: nowPending, total: nowTotal } =
FileStatusStore.getPendingCount(snapshot.value);
yield {
type: "progress",
progress: (nowTotal - nowPending) / nowTotal,
message: `Loading images (${nowTotal - nowPending}/${nowTotal})...`,
};
if (nowPending === 0) {
await new Promise((r) => setTimeout(r, 500));
yield {
type: "progress",
message: `Preparing export...`,
};
return;
}
}
},
[],
);
// const onExport = () => {
// return new Promise((r) => setTimeout(r, 2500));
// // console.log("onExport");
// };
// browsers generally prevent infinite self-embedding, there are // browsers generally prevent infinite self-embedding, there are
// cases where it still happens, and while we disallow self-embedding // cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard // by not whitelisting our own origin, this serves as an additional guard
@@ -839,8 +909,8 @@ const ExcalidrawWrapper = () => {
})} })}
> >
<Excalidraw <Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange} onChange={onChange}
onExport={onExport}
initialData={initialStatePromiseRef.current.promise} initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate} onPointerUpdate={collabAPI?.onPointerUpdate}
@@ -1206,7 +1276,9 @@ const ExcalidrawApp = () => {
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<Provider store={appJotaiStore}> <Provider store={appJotaiStore}>
<ExcalidrawWrapper /> <ExcalidrawAPIProvider>
<ExcalidrawWrapper />
</ExcalidrawAPIProvider>
</Provider> </Provider>
</TopErrorBoundary> </TopErrorBoundary>
); );
+2
View File
@@ -72,6 +72,7 @@ import {
FileManager, FileManager,
updateStaleImageStatuses, updateStaleImageStatuses,
} from "../data/FileManager"; } from "../data/FileManager";
import { FileStatusStore } from "../data/fileStatusStore";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { import {
isSavedToFirebase, isSavedToFirebase,
@@ -149,6 +150,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}; };
this.portal = new Portal(this); this.portal = new Portal(this);
this.fileManager = new FileManager({ this.fileManager = new FileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles: async (fileIds) => { getFiles: async (fileIds) => {
const { roomId, roomKey } = this.portal; const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) { if (!roomId || !roomKey) {
+599 -18
View File
@@ -3,24 +3,41 @@ import {
CloseIcon, CloseIcon,
TrashIcon, TrashIcon,
} from "@excalidraw/excalidraw/components/icons"; } from "@excalidraw/excalidraw/components/icons";
import { useExcalidrawAPI } from "@excalidraw/excalidraw/index";
import { import {
bootstrapCanvas, bootstrapCanvas,
fillCircle,
getNormalizedCanvasDimensions, getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers"; } from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types"; import { type AppState } from "@excalidraw/excalidraw/types";
import { arrayToMap, throttleRAF } from "@excalidraw/common"; import {
import { useCallback } from "react"; arrayToMap,
sceneCoordsToViewportCoords,
throttleRAF,
viewportCoordsToSceneCoords,
} from "@excalidraw/common";
import { useCallback, useEffect, useRef, useState } from "react";
import { import {
CaptureUpdateAction,
clearSimpleArrowTangentOverride,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
getElementAbsoluteCoords,
getSimpleArrowCurveDebugData,
isArrowElement, isArrowElement,
isBindableElement, isBindableElement,
setSimpleArrowTangentOverride,
ShapeCache,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { import {
isLineSegment, isLineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint, type GlobalPoint,
type LocalPoint,
type LineSegment, type LineSegment,
type Radians,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve"; import { isCurve } from "@excalidraw/math/curve";
@@ -36,11 +53,260 @@ import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
FixedPointBinding, FixedPointBinding,
Ordered,
OrderedExcalidrawElement, OrderedExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { SimpleArrowCurveDebugData } from "@excalidraw/element";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
type DebugCanvasOverlayState = Pick<
AppState,
| "width"
| "height"
| "zoom"
| "scrollX"
| "scrollY"
| "offsetLeft"
| "offsetTop"
| "selectedElementIds"
>;
type SimpleArrowHandleDirection = "incoming" | "outgoing";
type SimpleArrowHandleDescriptor = {
key: string;
elementId: string;
pointIndex: number;
direction: SimpleArrowHandleDirection;
point: GlobalPoint;
handle: GlobalPoint;
isOverridden: boolean;
title: string;
};
type SelectedSimpleArrowDebugState = {
element: ExcalidrawArrowElement;
debugData: SimpleArrowCurveDebugData<GlobalPoint>;
handles: SimpleArrowHandleDescriptor[];
};
const areSimpleArrowTangentHandlesEnabled = () =>
window.EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENTS !== false;
const pickOverlayState = (appState: AppState): DebugCanvasOverlayState => ({
width: appState.width,
height: appState.height,
zoom: appState.zoom,
scrollX: appState.scrollX,
scrollY: appState.scrollY,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
selectedElementIds: appState.selectedElementIds,
});
const getSimpleArrowTransform = (
element: ExcalidrawArrowElement,
elementsMap: ElementsMap,
) => {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
const center = pointFrom<GlobalPoint>(cx, cy);
return {
pointToGlobal: (point: LocalPoint): GlobalPoint => {
const rotated = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointFrom<GlobalPoint>(rotated[0], rotated[1]);
},
vectorToGlobal: (vector: [number, number]): [number, number] => {
const rotated = pointRotateRads(
pointFrom<GlobalPoint>(vector[0], vector[1]),
pointFrom<GlobalPoint>(0, 0),
element.angle,
);
return [rotated[0], rotated[1]];
},
vectorToLocal: (vector: [number, number]): [number, number] => {
const rotated = pointRotateRads(
pointFrom<GlobalPoint>(vector[0], vector[1]),
pointFrom<GlobalPoint>(0, 0),
-element.angle as Radians,
);
return [rotated[0], rotated[1]];
},
};
};
const getSimpleArrowDebugStateForElement = (
elements: readonly OrderedExcalidrawElement[],
elementId: string,
): SelectedSimpleArrowDebugState | null => {
const element = elements.find(
(candidate): candidate is Ordered<ExcalidrawArrowElement> =>
candidate.id === elementId &&
!candidate.isDeleted &&
isArrowElement(candidate) &&
!!candidate.roundness &&
!candidate.elbowed,
);
if (!element) {
return null;
}
const elementsMap = arrayToMap(elements);
const transform = getSimpleArrowTransform(element, elementsMap);
const localDebugData = getSimpleArrowCurveDebugData(element.points, 0.5, {
elementId: element.id,
});
const debugData: SimpleArrowCurveDebugData<GlobalPoint> = {
...localDebugData,
tangents: localDebugData.tangents.map((tangent) => ({
...tangent,
point: transform.pointToGlobal(tangent.point as LocalPoint),
base: transform.vectorToGlobal(tangent.base),
autoScaled: transform.vectorToGlobal(tangent.autoScaled),
scaled: transform.vectorToGlobal(tangent.scaled),
})),
segments: localDebugData.segments.map((segment) => ({
...segment,
start: transform.pointToGlobal(segment.start as LocalPoint),
end: transform.pointToGlobal(segment.end as LocalPoint),
baseCp1: transform.pointToGlobal(segment.baseCp1 as LocalPoint),
baseCp2: transform.pointToGlobal(segment.baseCp2 as LocalPoint),
cp1: transform.pointToGlobal(segment.cp1 as LocalPoint),
cp2: transform.pointToGlobal(segment.cp2 as LocalPoint),
})),
};
const handles = debugData.tangents.flatMap((tangent, pointIndex) => {
const descriptors: SimpleArrowHandleDescriptor[] = [];
const lengthRatio =
tangent.normalized.finalLengthVsMinNeighbor === null
? "n/a"
: tangent.normalized.finalLengthVsMinNeighbor.toFixed(3);
const angleDelta = tangent.normalized.angleDelta.toFixed(3);
const title = `point ${pointIndex} · len/min ${lengthRatio} · dAngle ${angleDelta} · dblclick resets`;
if (pointIndex < debugData.tangents.length - 1) {
descriptors.push({
key: `${element.id}:${pointIndex}:out`,
elementId: element.id,
pointIndex,
direction: "outgoing",
point: tangent.point,
handle: pointFrom<GlobalPoint>(
tangent.point[0] + tangent.scaled[0] / 3,
tangent.point[1] + tangent.scaled[1] / 3,
),
isOverridden: tangent.isOverridden,
title,
});
}
if (pointIndex > 0) {
descriptors.push({
key: `${element.id}:${pointIndex}:in`,
elementId: element.id,
pointIndex,
direction: "incoming",
point: tangent.point,
handle: pointFrom<GlobalPoint>(
tangent.point[0] - tangent.scaled[0] / 3,
tangent.point[1] - tangent.scaled[1] / 3,
),
isOverridden: tangent.isOverridden,
title,
});
}
return descriptors;
});
return {
element,
debugData,
handles,
};
};
const getSelectedSimpleArrowDebugState = (
elements: readonly OrderedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
) => {
const selectedIds = Object.keys(appState.selectedElementIds);
if (selectedIds.length !== 1) {
return null;
}
return getSimpleArrowDebugStateForElement(elements, selectedIds[0]);
};
const renderSelectedSimpleArrowTangentOverlay = (
context: CanvasRenderingContext2D,
zoom: number,
debugState: SelectedSimpleArrowDebugState,
) => {
context.save();
context.lineWidth = 1;
context.setLineDash([6, 4]);
context.strokeStyle = "rgba(134, 142, 150, 0.75)";
for (const segment of debugState.debugData.segments) {
context.beginPath();
context.moveTo(segment.start[0] * zoom, segment.start[1] * zoom);
context.lineTo(segment.baseCp1[0] * zoom, segment.baseCp1[1] * zoom);
context.lineTo(segment.baseCp2[0] * zoom, segment.baseCp2[1] * zoom);
context.lineTo(segment.end[0] * zoom, segment.end[1] * zoom);
context.stroke();
}
context.setLineDash([]);
for (const segment of debugState.debugData.segments) {
context.strokeStyle = segment.overshootsBaseline
? "rgba(245, 159, 0, 0.9)"
: "rgba(94, 90, 216, 0.85)";
context.beginPath();
context.moveTo(segment.start[0] * zoom, segment.start[1] * zoom);
context.lineTo(segment.cp1[0] * zoom, segment.cp1[1] * zoom);
context.lineTo(segment.cp2[0] * zoom, segment.cp2[1] * zoom);
context.lineTo(segment.end[0] * zoom, segment.end[1] * zoom);
context.stroke();
}
for (const tangent of debugState.debugData.tangents) {
if (!tangent.isAdjusted) {
continue;
}
context.strokeStyle = tangent.isOverridden
? "rgba(230, 73, 128, 0.95)"
: "rgba(201, 42, 42, 0.85)";
context.fillStyle = tangent.isOverridden
? "rgba(230, 73, 128, 0.95)"
: "rgba(201, 42, 42, 0.95)";
fillCircle(
context,
tangent.point[0] * zoom,
tangent.point[1] * zoom,
3,
true,
);
}
context.restore();
};
const renderLine = ( const renderLine = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
zoom: number, zoom: number,
@@ -359,6 +625,13 @@ const _debugRenderer = (
renderOrigin(context, appState.zoom.value); renderOrigin(context, appState.zoom.value);
renderBindings(context, elements, appState.zoom.value); renderBindings(context, elements, appState.zoom.value);
const selectedSimpleArrowDebugState = areSimpleArrowTangentHandlesEnabled()
? getSelectedSimpleArrowDebugState(elements, appState)
: null;
window.EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW =
selectedSimpleArrowDebugState?.debugData;
if ( if (
window.visualDebug?.currentFrame && window.visualDebug?.currentFrame &&
window.visualDebug?.data && window.visualDebug?.data &&
@@ -375,6 +648,14 @@ const _debugRenderer = (
}); });
} }
if (selectedSimpleArrowDebugState) {
renderSelectedSimpleArrowTangentOverlay(
context,
appState.zoom.value,
selectedSimpleArrowDebugState,
);
}
if (window.visualDebug) { if (window.visualDebug) {
window.visualDebug!.data = window.visualDebug!.data =
window.visualDebug?.data.map((frame) => window.visualDebug?.data.map((frame) =>
@@ -414,7 +695,6 @@ export const debugRenderer = throttleRAF(
) => { ) => {
_debugRenderer(canvas, appState, elements, scale); _debugRenderer(canvas, appState, elements, scale);
}, },
{ trailing: true },
); );
export const loadSavedDebugState = () => { export const loadSavedDebugState = () => {
@@ -543,23 +823,324 @@ interface DebugCanvasProps {
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>( const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => { ({ appState, scale }, ref) => {
const { width, height } = appState; const excalidrawAPI = useExcalidrawAPI();
const [overlayState, setOverlayState] = useState<DebugCanvasOverlayState>(
() => pickOverlayState(appState),
);
const [selectedSimpleArrowDebugState, setSelectedSimpleArrowDebugState] =
useState<SelectedSimpleArrowDebugState | null>(null);
const dragStateRef = useRef<{
elementId: string;
pointIndex: number;
direction: SimpleArrowHandleDirection;
} | null>(null);
const syncSelectedSimpleArrowDebugState = useCallback(
(
nextAppState: AppState,
nextElements?: readonly OrderedExcalidrawElement[],
) => {
if (!excalidrawAPI || excalidrawAPI.isDestroyed) {
return;
}
const elements =
nextElements ??
(excalidrawAPI.getSceneElements() as OrderedExcalidrawElement[]);
setOverlayState(pickOverlayState(nextAppState));
const nextDebugState = areSimpleArrowTangentHandlesEnabled()
? getSelectedSimpleArrowDebugState(elements, nextAppState)
: null;
window.EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW =
nextDebugState?.debugData;
setSelectedSimpleArrowDebugState(nextDebugState);
},
[excalidrawAPI],
);
useEffect(() => {
if (!excalidrawAPI || excalidrawAPI.isDestroyed) {
return;
}
syncSelectedSimpleArrowDebugState(excalidrawAPI.getAppState());
const unsubscribeChange = excalidrawAPI.onChange(
(elements, nextAppState) =>
syncSelectedSimpleArrowDebugState(
nextAppState,
elements as readonly OrderedExcalidrawElement[],
),
);
const unsubscribeState = excalidrawAPI.onStateChange(
[
"selectedElementIds",
"zoom",
"scrollX",
"scrollY",
"offsetLeft",
"offsetTop",
"width",
"height",
],
(_value, nextAppState) =>
syncSelectedSimpleArrowDebugState(nextAppState),
);
return () => {
unsubscribeChange();
unsubscribeState();
};
}, [excalidrawAPI, syncSelectedSimpleArrowDebugState]);
const rerenderSceneForSimpleArrowOverride = useCallback(() => {
if (!excalidrawAPI || excalidrawAPI.isDestroyed) {
return;
}
excalidrawAPI.updateScene({
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
captureUpdate: CaptureUpdateAction.NEVER,
});
}, [excalidrawAPI]);
const commitOverrideForHandle = useCallback(
(
handle: Pick<
SimpleArrowHandleDescriptor,
"elementId" | "pointIndex" | "direction"
>,
clientX: number,
clientY: number,
) => {
if (!excalidrawAPI || excalidrawAPI.isDestroyed) {
return;
}
const nextAppState = excalidrawAPI.getAppState();
const nextElements =
excalidrawAPI.getSceneElements() as OrderedExcalidrawElement[];
const debugState = getSimpleArrowDebugStateForElement(
nextElements,
handle.elementId,
);
if (!debugState) {
return;
}
const scenePoint = viewportCoordsToSceneCoords(
{ clientX, clientY },
nextAppState,
);
const tangent =
handle.direction === "outgoing"
? ([
(scenePoint.x -
debugState.debugData.tangents[handle.pointIndex].point[0]) *
3,
(scenePoint.y -
debugState.debugData.tangents[handle.pointIndex].point[1]) *
3,
] as [number, number])
: ([
(debugState.debugData.tangents[handle.pointIndex].point[0] -
scenePoint.x) *
3,
(debugState.debugData.tangents[handle.pointIndex].point[1] -
scenePoint.y) *
3,
] as [number, number]);
const localTangent = getSimpleArrowTransform(
debugState.element,
arrayToMap(nextElements),
).vectorToLocal(tangent);
setSimpleArrowTangentOverride(
debugState.element.id,
handle.pointIndex,
localTangent,
);
ShapeCache.delete(debugState.element);
rerenderSceneForSimpleArrowOverride();
syncSelectedSimpleArrowDebugState(nextAppState, nextElements);
},
[
excalidrawAPI,
rerenderSceneForSimpleArrowOverride,
syncSelectedSimpleArrowDebugState,
],
);
const onPointerMove = useCallback(
(event: PointerEvent) => {
if (!dragStateRef.current) {
return;
}
event.preventDefault();
commitOverrideForHandle(
dragStateRef.current,
event.clientX,
event.clientY,
);
},
[commitOverrideForHandle],
);
const onPointerUp = useCallback(
(event: PointerEvent) => {
if (!dragStateRef.current) {
return;
}
event.preventDefault();
dragStateRef.current = null;
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
},
[onPointerMove],
);
useEffect(() => {
return () => {
dragStateRef.current = null;
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
};
}, [onPointerMove, onPointerUp]);
const handlePointerDown = useCallback(
(
handle: SimpleArrowHandleDescriptor,
event: React.PointerEvent<HTMLDivElement>,
) => {
event.preventDefault();
event.stopPropagation();
dragStateRef.current = {
elementId: handle.elementId,
pointIndex: handle.pointIndex,
direction: handle.direction,
};
window.addEventListener("pointermove", onPointerMove, {
passive: false,
});
window.addEventListener("pointerup", onPointerUp, {
passive: false,
});
window.addEventListener("pointercancel", onPointerUp, {
passive: false,
});
commitOverrideForHandle(handle, event.clientX, event.clientY);
},
[commitOverrideForHandle, onPointerMove, onPointerUp],
);
const resetHandleOverride = useCallback(
(
handle: SimpleArrowHandleDescriptor,
event: React.MouseEvent<HTMLDivElement>,
) => {
event.preventDefault();
event.stopPropagation();
if (!excalidrawAPI || excalidrawAPI.isDestroyed) {
return;
}
clearSimpleArrowTangentOverride(handle.elementId, handle.pointIndex);
const nextElements =
excalidrawAPI.getSceneElements() as OrderedExcalidrawElement[];
const element = nextElements.find(
(candidate): candidate is Ordered<ExcalidrawArrowElement> =>
candidate.id === handle.elementId &&
!candidate.isDeleted &&
isArrowElement(candidate),
);
if (element) {
ShapeCache.delete(element);
rerenderSceneForSimpleArrowOverride();
}
syncSelectedSimpleArrowDebugState(
excalidrawAPI.getAppState(),
nextElements,
);
},
[
excalidrawAPI,
rerenderSceneForSimpleArrowOverride,
syncSelectedSimpleArrowDebugState,
],
);
const { width, height } = overlayState;
return ( return (
<canvas <>
style={{ <canvas
width, style={{
height, width,
position: "absolute", height,
zIndex: 2, position: "absolute",
pointerEvents: "none", inset: 0,
}} zIndex: 2,
width={width * scale} pointerEvents: "none",
height={height * scale} }}
ref={ref} width={width * scale}
> height={height * scale}
Debug Canvas ref={ref}
</canvas> >
Debug Canvas
</canvas>
{selectedSimpleArrowDebugState?.handles.map((handle) => {
const { x, y } = sceneCoordsToViewportCoords(
{
sceneX: handle.handle[0],
sceneY: handle.handle[1],
},
overlayState,
);
return (
<div
key={handle.key}
title={handle.title}
onPointerDown={(event) => handlePointerDown(handle, event)}
onDoubleClick={(event) => resetHandleOverride(handle, event)}
style={{
position: "absolute",
left: x - overlayState.offsetLeft,
top: y - overlayState.offsetTop,
width: 12,
height: 12,
zIndex: 3,
borderRadius: handle.direction === "outgoing" ? "999px" : 3,
transform: "translate(-50%, -50%)",
background: handle.isOverridden
? "rgba(230, 73, 128, 0.95)"
: handle.direction === "outgoing"
? "rgba(94, 90, 216, 0.95)"
: "rgba(245, 159, 0, 0.95)",
border: "2px solid rgba(255,255,255,0.95)",
boxShadow: "0 0 0 1px rgba(0, 0, 0, 0.18)",
pointerEvents: "auto",
cursor: "grab",
}}
/>
);
})}
</>
); );
}, },
); );
+22
View File
@@ -40,10 +40,12 @@ export class FileManager {
private _getFiles; private _getFiles;
private _saveFiles; private _saveFiles;
private _onFileStatusChange;
constructor({ constructor({
getFiles, getFiles,
saveFiles, saveFiles,
onFileStatusChange,
}: { }: {
getFiles: (fileIds: FileId[]) => Promise<{ getFiles: (fileIds: FileId[]) => Promise<{
loadedFiles: BinaryFileData[]; loadedFiles: BinaryFileData[];
@@ -53,9 +55,13 @@ export class FileManager {
savedFiles: Map<FileId, BinaryFileData>; savedFiles: Map<FileId, BinaryFileData>;
erroredFiles: Map<FileId, BinaryFileData>; erroredFiles: Map<FileId, BinaryFileData>;
}>; }>;
onFileStatusChange?: (
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
) => void;
}) { }) {
this._getFiles = getFiles; this._getFiles = getFiles;
this._saveFiles = saveFiles; this._saveFiles = saveFiles;
this._onFileStatusChange = onFileStatusChange;
} }
/** /**
@@ -146,6 +152,8 @@ export class FileManager {
this.fetchingFiles.set(id, true); this.fetchingFiles.set(id, true);
} }
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
try { try {
const { loadedFiles, erroredFiles } = await this._getFiles(ids); const { loadedFiles, erroredFiles } = await this._getFiles(ids);
@@ -156,6 +164,13 @@ export class FileManager {
this.erroredFiles_fetch.set(fileId, true); this.erroredFiles_fetch.set(fileId, true);
} }
this._onFileStatusChange?.([
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
...[...erroredFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
]);
return { loadedFiles, erroredFiles }; return { loadedFiles, erroredFiles };
} finally { } finally {
for (const id of ids) { for (const id of ids) {
@@ -195,6 +210,13 @@ export class FileManager {
}; };
reset() { reset() {
if (this._onFileStatusChange && this.fetchingFiles.size) {
this._onFileStatusChange(
[...this.fetchingFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
);
}
this.fetchingFiles.clear(); this.fetchingFiles.clear();
this.savingFiles.clear(); this.savingFiles.clear();
this.savedFiles.clear(); this.savedFiles.clear();
+2
View File
@@ -42,6 +42,7 @@ import type { MaybePromise } from "@excalidraw/common/utility-types";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager"; import { FileManager } from "./FileManager";
import { FileStatusStore } from "./fileStatusStore";
import { Locker } from "./Locker"; import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync"; import { updateBrowserStateVersion } from "./tabSync";
@@ -166,6 +167,7 @@ export class LocalData {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static fileStorage = new LocalFileManager({ static fileStorage = new LocalFileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles(ids) { getFiles(ids) {
return getMany(ids, filesStore).then( return getMany(ids, filesStore).then(
async (filesData: (BinaryFileData | undefined)[]) => { async (filesData: (BinaryFileData | undefined)[]) => {
+48
View File
@@ -0,0 +1,48 @@
import { VersionedSnapshotStore } from "@excalidraw/common";
import type { FileId } from "@excalidraw/element/types";
export type FileLoadingStatus = "loading" | "loaded" | "error";
export class FileStatusStore {
private static store = new VersionedSnapshotStore<
Map<FileId, FileLoadingStatus>
>(new Map());
static getSnapshot() {
return this.store.getSnapshot();
}
static pull(sinceVersion?: number) {
return this.store.pull(sinceVersion);
}
static updateStatuses(updates: Array<[FileId, FileLoadingStatus]>) {
if (!updates.length) {
return;
}
this.store.update((prev) => {
let changed = false;
const next = new Map(prev);
for (const [id, status] of updates) {
if (next.get(id) !== status) {
next.set(id, status);
changed = true;
}
}
return changed ? next : prev;
});
}
static getPendingCount(statuses: Map<FileId, FileLoadingStatus>) {
let pending = 0;
let total = 0;
for (const status of statuses.values()) {
total++;
if (status === "loading") {
pending++;
}
}
return { pending, total };
}
}
+10 -1
View File
@@ -106,6 +106,10 @@ export default defineConfig(({ mode }) => {
if (id.includes("@excalidraw/mermaid-to-excalidraw")) { if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
return "mermaid-to-excalidraw"; return "mermaid-to-excalidraw";
} }
if (id.includes("@codemirror/") || id.includes("@lezer/")) {
return "codemirror.chunk";
}
}, },
}, },
}, },
@@ -150,6 +154,11 @@ export default defineConfig(({ mode }) => {
"**/locales/**", "**/locales/**",
"service-worker.js", "service-worker.js",
"**/*.chunk-*.js", "**/*.chunk-*.js",
// CodeMirrorEditor can't be assigned a `.chunk` name via
// manualChunks because Rollup would hoist shared deps (React)
// via a static import from the main bundle, defeating lazy
// loading. So we exclude it by name instead.
"**/CodeMirrorEditor-*.js",
], ],
runtimeCaching: [ runtimeCaching: [
{ {
@@ -189,7 +198,7 @@ export default defineConfig(({ mode }) => {
}, },
}, },
{ {
urlPattern: new RegExp(".chunk-.+.js"), urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"),
handler: "CacheFirst", handler: "CacheFirst",
options: { options: {
cacheName: "chunk", cacheName: "chunk",
+74
View File
@@ -0,0 +1,74 @@
import { AppEventBus } from "./appEventBus";
type TestEvents = {
initialize: [api: number];
pointerUp: [pointerId: string];
viewState: [zoom: number];
};
const behavior = {
initialize: { cardinality: "once", replay: "last" },
pointerUp: { cardinality: "many", replay: "none" },
viewState: { cardinality: "many", replay: "last" },
} as const;
const flushMicrotasks = async () => Promise.resolve();
describe("AppEventBus", () => {
it("replays once events to late callback and Promise subscribers", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("initialize", 42);
const calls: number[] = [];
bus.on("initialize", (value) => {
calls.push(value);
});
expect(calls).toEqual([]);
await flushMicrotasks();
expect(calls).toEqual([42]);
await expect(bus.on("initialize")).resolves.toBe(42);
});
it("does not replay stream events to late subscribers", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("pointerUp", "first");
const calls: string[] = [];
bus.on("pointerUp", (pointerId) => {
calls.push(pointerId);
});
await flushMicrotasks();
expect(calls).toEqual([]);
bus.emit("pointerUp", "second");
expect(calls).toEqual(["second"]);
});
it("replays replay-last stream events and stays subscribed", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("viewState", 1);
const calls: number[] = [];
bus.on("viewState", (zoom) => {
calls.push(zoom);
});
await flushMicrotasks();
expect(calls).toEqual([1]);
bus.emit("viewState", 2);
expect(calls).toEqual([1, 2]);
});
it("throws when emitting a once event twice", () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("initialize", 1);
expect(() => {
bus.emit("initialize", 2);
}).toThrow('Event "initialize" can only be emitted once');
});
});
+136
View File
@@ -0,0 +1,136 @@
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
import { Emitter } from "./emitter";
import { isProdEnv } from "./utils";
export type AppEventPayloadMap = Record<string, unknown[]>;
export type AppEventBehavior = {
cardinality: "once" | "many";
replay: "none" | "last";
};
export type AppEventBehaviorMap<Events extends AppEventPayloadMap> = {
[K in keyof Events]: AppEventBehavior;
};
type AwaitableAppEventKeys<
Events extends AppEventPayloadMap,
Behavior extends AppEventBehaviorMap<Events>,
> = {
[K in keyof Events]: Behavior[K]["cardinality"] extends "once"
? Behavior[K]["replay"] extends "last"
? K
: never
: never;
}[keyof Events];
type AppEventPromiseValue<Args extends any[]> = Args extends [infer Only]
? Only
: Args;
export class AppEventBus<
Events extends AppEventPayloadMap,
Behavior extends AppEventBehaviorMap<Events>,
> {
private readonly emitters = new Map<keyof Events, Emitter<any>>();
private readonly lastPayload = new Map<keyof Events, any[]>();
private readonly emittedOnce = new Set<keyof Events>();
constructor(private readonly behavior: Behavior) {}
private getEmitter<K extends keyof Events>(name: K): Emitter<Events[K]> {
let emitter = this.emitters.get(name);
if (!emitter) {
emitter = new Emitter<any>();
this.emitters.set(name, emitter);
}
return emitter as Emitter<Events[K]>;
}
private toPromiseValue<Args extends any[]>(
args: Args,
): AppEventPromiseValue<Args> {
return (args.length === 1 ? args[0] : args) as AppEventPromiseValue<Args>;
}
public on<K extends keyof Events>(
name: K,
callback: (...args: Events[K]) => void,
): UnsubscribeCallback;
public on<K extends AwaitableAppEventKeys<Events, Behavior>>(
name: K,
): Promise<AppEventPromiseValue<Events[K]>>;
public on<K extends keyof Events>(
name: K,
callback?: (...args: Events[K]) => void,
): UnsubscribeCallback | Promise<AppEventPromiseValue<Events[K]>> {
const eventBehavior = this.behavior[name];
const cachedPayload = this.lastPayload.get(name) as Events[K] | undefined;
if (callback) {
if (eventBehavior.replay === "last" && cachedPayload) {
queueMicrotask(() => callback(...cachedPayload));
if (eventBehavior.cardinality === "once") {
return () => {};
}
}
return this.getEmitter(name).on(callback);
}
if (
eventBehavior.cardinality !== "once" ||
eventBehavior.replay !== "last"
) {
throw new Error(`Event "${String(name)}" requires a callback`);
}
if (cachedPayload) {
return Promise.resolve(this.toPromiseValue(cachedPayload));
}
return new Promise<AppEventPromiseValue<Events[K]>>((resolve) => {
this.getEmitter(name).once((...args: Events[K]) => {
resolve(this.toPromiseValue(args));
});
});
}
public emit<K extends keyof Events>(name: K, ...args: Events[K]) {
const eventBehavior = this.behavior[name];
if (!isProdEnv()) {
if (eventBehavior.cardinality === "once") {
if (this.emittedOnce.has(name)) {
throw new Error(`Event "${String(name)}" can only be emitted once`);
}
this.emittedOnce.add(name);
}
}
if (eventBehavior.replay === "last") {
this.lastPayload.set(name, args);
}
try {
this.getEmitter(name).trigger(...args);
} finally {
if (eventBehavior.cardinality === "once") {
this.getEmitter(name).clear();
}
}
}
public clear() {
this.lastPayload.clear();
this.emittedOnce.clear();
for (const emitter of this.emitters.values()) {
emitter.clear();
}
this.emitters.clear();
}
}
+2
View File
@@ -11,5 +11,7 @@ export * from "./random";
export * from "./url"; export * from "./url";
export * from "./utils"; export * from "./utils";
export * from "./emitter"; export * from "./emitter";
export * from "./appEventBus";
export * from "./editorInterface"; export * from "./editorInterface";
export * from "./versionedSnapshotStore";
export { Debug } from "../debug"; export { Debug } from "../debug";
+89
View File
@@ -3,6 +3,12 @@ import {
mapFind, mapFind,
reduceToCommonValue, reduceToCommonValue,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { vi } from "vitest";
// Import directly to avoid the @excalidraw/common throttleRAF mock from setupTests.ts.
import { throttleRAF } from "./utils";
type RafCallback = FrameRequestCallback;
describe("@excalidraw/common/utils", () => { describe("@excalidraw/common/utils", () => {
describe("isTransparent()", () => { describe("isTransparent()", () => {
@@ -79,4 +85,87 @@ describe("@excalidraw/common/utils", () => {
expect(mapFind([1, 2], () => null)).toBe(undefined); expect(mapFind([1, 2], () => null)).toBe(undefined);
}); });
}); });
describe("throttleRAF()", () => {
let frameCallbacks: Map<number, RafCallback>;
let nextFrameId: number;
const runScheduledFrame = (timestamp = 16) => {
const callbacks = [...frameCallbacks.values()];
frameCallbacks.clear();
callbacks.forEach((callback) => callback(timestamp));
};
beforeEach(() => {
frameCallbacks = new Map();
nextFrameId = 0;
vi.spyOn(window, "requestAnimationFrame").mockImplementation(
(callback) => {
const frameId = ++nextFrameId;
frameCallbacks.set(frameId, callback);
return frameId;
},
);
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((frameId) => {
frameCallbacks.delete(frameId);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should invoke the callback with the last args from the same frame", () => {
const fn = vi.fn();
const throttled = throttleRAF(fn);
throttled("first", 1);
throttled("second", 2);
throttled("last", 3);
expect(fn).not.toHaveBeenCalled();
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
runScheduledFrame();
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("last", 3);
});
it("should flush the pending callback immediately", () => {
const fn = vi.fn();
const throttled = throttleRAF(fn);
throttled("first");
throttled("last");
throttled.flush();
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith("last");
runScheduledFrame();
expect(fn).toHaveBeenCalledTimes(1);
});
it("should cancel the pending callback", () => {
const fn = vi.fn();
const throttled = throttleRAF(fn);
throttled("first");
throttled("last");
throttled.cancel();
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
runScheduledFrame();
expect(fn).not.toHaveBeenCalled();
});
});
}); });
+13 -23
View File
@@ -88,7 +88,8 @@ export const isWritableElement = (
(target.type === "text" || (target.type === "text" ||
target.type === "number" || target.type === "number" ||
target.type === "password" || target.type === "password" ||
target.type === "search")); target.type === "search")) ||
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
export const getFontFamilyString = ({ export const getFontFamilyString = ({
fontFamily, fontFamily,
@@ -150,38 +151,27 @@ export const debounce = <T extends any[]>(
return ret; return ret;
}; };
// throttle callback to execute once per animation frame // throttle callback to execute once per animation frame using the latest args
export const throttleRAF = <T extends any[]>( export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
fn: (...args: T) => void,
opts?: { trailing?: boolean },
) => {
let timerId: number | null = null; let timerId: number | null = null;
let lastArgs: T | null = null; let lastArgs: T | null = null;
let lastArgsTrailing: T | null = null;
const scheduleFunc = (args: T) => { const scheduleFunc = () => {
timerId = window.requestAnimationFrame(() => { timerId = window.requestAnimationFrame(() => {
timerId = null; timerId = null;
fn(...args); const args = lastArgs;
lastArgs = null; lastArgs = null;
if (lastArgsTrailing) {
lastArgs = lastArgsTrailing; if (args) {
lastArgsTrailing = null; fn(...args);
scheduleFunc(lastArgs);
} }
}); });
}; };
const ret = (...args: T) => { const ret = (...args: T) => {
if (isTestEnv()) {
fn(...args);
return;
}
lastArgs = args; lastArgs = args;
if (timerId === null) { if (timerId === null) {
scheduleFunc(lastArgs); scheduleFunc();
} else if (opts?.trailing) {
lastArgsTrailing = args;
} }
}; };
ret.flush = () => { ret.flush = () => {
@@ -190,12 +180,12 @@ export const throttleRAF = <T extends any[]>(
timerId = null; timerId = null;
} }
if (lastArgs) { if (lastArgs) {
fn(...(lastArgsTrailing || lastArgs)); fn(...lastArgs);
lastArgs = lastArgsTrailing = null; lastArgs = null;
} }
}; };
ret.cancel = () => { ret.cancel = () => {
lastArgs = lastArgsTrailing = null; lastArgs = null;
if (timerId !== null) { if (timerId !== null) {
cancelAnimationFrame(timerId); cancelAnimationFrame(timerId);
timerId = null; timerId = null;
@@ -0,0 +1,70 @@
export type VersionedSnapshot<T> = Readonly<{
version: number;
value: T;
}>;
export class VersionedSnapshotStore<T> {
private version = 0;
private value: T;
private readonly waiters = new Set<
(snapshot: VersionedSnapshot<T>) => void
>();
private readonly subscribers = new Set<
(snapshot: VersionedSnapshot<T>) => void
>();
constructor(
initialValue: T,
private readonly isEqual: (prev: T, next: T) => boolean = Object.is,
) {
this.value = initialValue;
}
public getSnapshot(): VersionedSnapshot<T> {
return { version: this.version, value: this.value };
}
public set(nextValue: T): boolean {
if (this.isEqual(this.value, nextValue)) {
return false;
}
this.value = nextValue;
this.version += 1;
const snapshot = this.getSnapshot();
for (const subscriber of this.subscribers) {
subscriber(snapshot);
}
for (const waiter of this.waiters) {
waiter(snapshot);
}
this.waiters.clear();
return true;
}
public update(updater: (prev: T) => T): boolean {
return this.set(updater(this.value));
}
public subscribe(
subscriber: (snapshot: VersionedSnapshot<T>) => void,
): () => void {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
}
public pull(sinceVersion = -1): Promise<VersionedSnapshot<T>> {
if (this.version !== sinceVersion) {
return Promise.resolve(this.getSnapshot());
}
return new Promise((resolve) => {
this.waiters.add(resolve);
});
}
}
@@ -1822,7 +1822,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 120, "width": 120,
"x": 187.75450000000004, "x": 187.7545,
"y": 44.5, "y": 44.5,
} }
`; `;
+32
View File
@@ -0,0 +1,32 @@
import type { Arrowhead, AnyArrowhead } from "./types";
export const normalizeArrowhead = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
switch (arrowhead) {
case undefined:
case null:
return null;
case "dot":
return "circle";
case "crowfoot_one":
return "cardinality_one";
case "crowfoot_many":
return "cardinality_many";
case "crowfoot_one_or_many":
return "cardinality_one_or_many";
default:
return arrowhead;
}
};
export const getArrowheadForPicker = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
const normalizedArrowhead = normalizeArrowhead(arrowhead);
if (normalizedArrowhead === null) {
return null;
}
return normalizedArrowhead;
};
+72 -45
View File
@@ -709,6 +709,9 @@ const getFreeDrawElementAbsoluteCoords = (
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2]; return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
}; };
const CARDINALITY_MARKER_SIZE = 20;
const CROWFOOT_ARROWHEAD_SIZE = 15;
/** @returns number in pixels */ /** @returns number in pixels */
export const getArrowheadSize = (arrowhead: Arrowhead): number => { export const getArrowheadSize = (arrowhead: Arrowhead): number => {
switch (arrowhead) { switch (arrowhead) {
@@ -717,10 +720,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond": case "diamond":
case "diamond_outline": case "diamond_outline":
return 12; return 12;
case "crowfoot_many": case "cardinality_many":
case "crowfoot_one": case "cardinality_one_or_many":
case "crowfoot_one_or_many": case "cardinality_zero_or_many":
return 20; return CROWFOOT_ARROWHEAD_SIZE;
case "cardinality_one":
case "cardinality_exactly_one":
case "cardinality_zero_or_one":
return CARDINALITY_MARKER_SIZE;
default: default:
return 15; return 15;
} }
@@ -743,7 +750,12 @@ export const getArrowheadPoints = (
shape: Drawable[], shape: Drawable[],
position: "start" | "end", position: "start" | "end",
arrowhead: Arrowhead, arrowhead: Arrowhead,
offsetMultiplier = 0,
) => { ) => {
if (arrowhead === null) {
return null;
}
if (shape.length < 1) { if (shape.length < 1) {
return null; return null;
} }
@@ -777,27 +789,41 @@ export const getArrowheadPoints = (
p0 = pointFrom(prevOp.data[4], prevOp.data[5]); p0 = pointFrom(prevOp.data[4], prevOp.data[5]);
} }
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 // We know the last point of the arrow (or the first, if start arrowhead).
const equation = (t: number, idx: number) =>
Math.pow(1 - t, 3) * p3[idx] +
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
// Ee know the last point of the arrow (or the first, if start arrowhead).
const [x2, y2] = position === "start" ? p0 : p3; const [x2, y2] = position === "start" ? p0 : p3;
// By using cubic bezier equation (B(t)) and the given parameters, // Use the analytic tangent at the Bézier endpoint for a precise arrowhead
// we calculate a point that is closer to the last point. // direction. For a cubic Bézier B(t) with control points p0p3:
// The value 0.3 is chosen arbitrarily and it works best for all // B'(1): (p3 p2) tangent at the end
// the tested cases. // B'(0): (p1 p0) for start arrowhead, arrow points away: (p0 p1)
const [x1, y1] = [equation(0.3, 0), equation(0.3, 1)]; let dx: number;
let dy: number;
// Find the normalized direction vector based on the if (position === "end") {
// previously calculated points. dx = p3[0] - p2[0];
const distance = Math.hypot(x2 - x1, y2 - y1); dy = p3[1] - p2[1];
const nx = (x2 - x1) / distance; if (Math.hypot(dx, dy) < 1e-6) {
const ny = (y2 - y1) / distance; dx = p3[0] - p1[0];
dy = p3[1] - p1[1];
}
if (Math.hypot(dx, dy) < 1e-6) {
dx = p3[0] - p0[0];
dy = p3[1] - p0[1];
}
} else {
dx = p0[0] - p1[0];
dy = p0[1] - p1[1];
if (Math.hypot(dx, dy) < 1e-6) {
dx = p0[0] - p2[0];
dy = p0[1] - p2[1];
}
if (Math.hypot(dx, dy) < 1e-6) {
dx = p0[0] - p3[0];
dy = p0[1] - p3[1];
}
}
const distance = Math.hypot(dx, dy);
const nx = dx / distance;
const ny = dy / distance;
const size = getArrowheadSize(arrowhead); const size = getArrowheadSize(arrowhead);
@@ -824,29 +850,30 @@ export const getArrowheadPoints = (
const lengthMultiplier = const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5; arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier); const minSize = Math.min(size, length * lengthMultiplier);
const xs = x2 - nx * minSize; const tx = x2 - nx * minSize * offsetMultiplier;
const ys = y2 - ny * minSize; const ty = y2 - ny * minSize * offsetMultiplier;
const xs = tx - nx * minSize;
const ys = ty - ny * minSize;
if ( if (arrowhead === "circle" || arrowhead === "circle_outline") {
arrowhead === "dot" || const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
arrowhead === "circle" || return [tx, ty, diameter];
arrowhead === "circle_outline"
) {
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
return [x2, y2, diameter];
} }
const angle = getArrowheadAngle(arrowhead); const angle = getArrowheadAngle(arrowhead);
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") { if (
arrowhead === "cardinality_many" ||
arrowhead === "cardinality_one_or_many"
) {
// swap (xs, ys) with (x2, y2) // swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(x2, y2), pointFrom(tx, ty),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(-angle as Degrees), degreesToRadians(-angle as Degrees),
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(x2, y2), pointFrom(tx, ty),
pointFrom(xs, ys), pointFrom(xs, ys),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -856,12 +883,12 @@ export const getArrowheadPoints = (
// Return points // Return points
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(x2, y2), pointFrom(tx, ty),
((-angle * Math.PI) / 180) as Radians, ((-angle * Math.PI) / 180) as Radians,
); );
const [x4, y4] = pointRotateRads( const [x4, y4] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
pointFrom(x2, y2), pointFrom(tx, ty),
degreesToRadians(angle), degreesToRadians(angle),
); );
@@ -874,9 +901,9 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 + minSize * 2, y2), pointFrom(tx + minSize * 2, ty),
pointFrom(x2, y2), pointFrom(tx, ty),
Math.atan2(py - y2, px - x2) as Radians, Math.atan2(py - ty, px - tx) as Radians,
); );
} else { } else {
const [px, py] = const [px, py] =
@@ -885,16 +912,16 @@ export const getArrowheadPoints = (
: [0, 0]; : [0, 0];
[ox, oy] = pointRotateRads( [ox, oy] = pointRotateRads(
pointFrom(x2 - minSize * 2, y2), pointFrom(tx - minSize * 2, ty),
pointFrom(x2, y2), pointFrom(tx, ty),
Math.atan2(y2 - py, x2 - px) as Radians, Math.atan2(ty - py, tx - px) as Radians,
); );
} }
return [x2, y2, x3, y3, ox, oy, x4, y4]; return [tx, ty, x3, y3, ox, oy, x4, y4];
} }
return [x2, y2, x3, y3, x4, y4]; return [tx, ty, x3, y3, x4, y4];
}; };
// TODO reuse shape.ts // TODO reuse shape.ts
+1
View File
@@ -99,3 +99,4 @@ export * from "./typeChecks";
export * from "./utils"; export * from "./utils";
export * from "./zindex"; export * from "./zindex";
export * from "./arrows/helpers"; export * from "./arrows/helpers";
export * from "./arrowheads";
@@ -784,9 +784,20 @@ export class LinearElementEditor {
elementsMap, elementsMap,
); );
// For curved (non-elbow) arrows the quadratic spline produces N-2 arcs
// for N anchor points. Cap the loop to the actual segment count so we
// don't request a midpoint for a segment that doesn't exist.
const [lines, segCurves] = deconstructLinearOrFreeDrawElement(element);
const segmentCount = lines.length + segCurves.length;
let index = 0; let index = 0;
const midpoints: (GlobalPoint | null)[] = []; const midpoints: (GlobalPoint | null)[] = [];
while (index < points.length - 1) { while (index < points.length - 1) {
if (segmentCount > 0 && index >= segmentCount) {
midpoints.push(null);
index++;
continue;
}
if ( if (
LinearElementEditor.isSegmentTooShort( LinearElementEditor.isSegmentTooShort(
element, element,
+18
View File
@@ -11,6 +11,7 @@ import {
isBoundToContainer, isBoundToContainer,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
@@ -25,6 +26,7 @@ import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
ExcalidrawElement, ExcalidrawElement,
NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
@@ -288,3 +290,19 @@ export const getSelectionStateForElements = (
), ),
}; };
}; };
/**
* Returns editing or single-selected text element, if any.
*/
export const getActiveTextElement = (
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
appState: Pick<AppState, "editingTextElement">,
) => {
const activeTextElement =
appState.editingTextElement ||
(selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
selectedElements[0]);
return activeTextElement || null;
};
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -441,7 +441,8 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: { export const isValidTextContainer = (element: {
type: ExcalidrawElementType; type: ExcalidrawElementType;
}) => VALID_CONTAINER_TYPES.has(element.type); }): element is ExcalidrawTextContainer =>
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (
dimension: number, dimension: number,
+207 -37
View File
@@ -4,6 +4,22 @@ import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types"; import type { FontString } from "./types";
/**
* This module approximates browser-like soft wrapping for Excalidraw text.
*
* The flow is:
* 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
* 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
* records where each visual line came from in the source text.
* 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
* that is wider than the available width.
* 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
* whitespace so the rendered text stays consistent with what users see on canvas.
*
* Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
* that need metadata such as mapping visual lines back to `originalText`
* for caret placement or future editor features.
*/
let cachedCjkRegex: RegExp | undefined; let cachedCjkRegex: RegExp | undefined;
let cachedLineBreakRegex: RegExp | undefined; let cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined; let cachedEmojiRegex: RegExp | undefined;
@@ -358,6 +374,10 @@ const Break = {
/** /**
* Breaks the line into the tokens based on the found line break opporutnities. * Breaks the line into the tokens based on the found line break opporutnities.
*
* Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
* their composed variants for wrapping. Any code that needs exact source offsets should
* keep in mind that this assumes the input text is already NFC-normalized.
*/ */
export const parseTokens = (line: string) => { export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex(); const breakLineRegex = getLineBreakRegex();
@@ -370,56 +390,120 @@ export const parseTokens = (line: string) => {
/** /**
* Wraps the original text into the lines based on the given width. * Wraps the original text into the lines based on the given width.
*
* This is a convenience adapter over `getWrappedTextLines()` for call sites
* that only need the rendered wrapped string and not the source offsets.
*/ */
export const wrapText = ( export const wrapText = (
text: string, text: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): string => { ): string => {
return getWrappedTextLines(text, font, maxWidth)
.map((line) => line.text)
.join("\n");
};
/**
* A single rendered visual line produced from the original text.
*
* `start` and `end` are end-exclusive code-unit offsets into the original text, and do
* not include synthetic soft line breaks inserted by this module. If trailing whitespace
* was trimmed away at a wrap boundary, `end` points to the last rendered character.
*/
export type WrappedTextLine = {
text: string;
start: number;
end: number;
};
/**
* Splits only on existing hard line breaks and preserves original offsets.
*/
const getHardLineBreaks = (text: string): WrappedTextLine[] => {
let offset = 0;
return text.split("\n").map((line) => {
const start = offset;
const end = start + line.length;
offset = end + 1;
return {
text: line,
start,
end,
};
});
};
/**
* Returns the rendered visual lines together with their source offsets.
*
* This is the source-of-truth wrapping pipeline for callers that need more than the
* final wrapped string, for example caret placement or future editor/rich-text mapping.
*/
export const getWrappedTextLines = (
text: string,
font: FontString,
maxWidth: number,
): WrappedTextLine[] => {
// if maxWidth is not finite or NaN which can happen in case of bugs in // if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up // computation, we need to make sure we don't continue as we'll end up
// in an infinite loop // in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) { if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text; return getHardLineBreaks(text);
} }
const lines: Array<string> = []; const lines: WrappedTextLine[] = [];
const originalLines = text.split("\n"); let offset = 0;
for (const originalLine of originalLines) { for (const originalLine of text.split("\n")) {
const currentLineWidth = getLineWidth(originalLine, font); const originalLineWidth = getLineWidth(originalLine, font);
if (currentLineWidth <= maxWidth) { if (originalLineWidth <= maxWidth) {
lines.push(originalLine); lines.push({
continue; text: originalLine,
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
} }
const wrappedLine = wrapLine(originalLine, font, maxWidth); offset += originalLine.length + 1;
lines.push(...wrappedLine);
} }
return lines.join("\n"); return lines;
}; };
/** /**
* Wraps the original line into the lines based on the given width. * Wraps a single hard line into one or more visual lines.
*
* The line-local offsets are tracked in original-text code units so
* we can map the visual line back to the source.
*/ */
const wrapLine = ( const wrapLine = (
line: string, line: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): string[] => { lineStart: number,
const lines: Array<string> = []; ): WrappedTextLine[] => {
const lines: WrappedTextLine[] = [];
const tokens = parseTokens(line); const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = ""; let currentLine = "";
let currentLineStart = lineStart;
let currentLineEnd = lineStart;
let currentLineWidth = 0; let currentLineWidth = 0;
// Tracks the next token's code-unit position in the original source string.
let tokenOffset = lineStart;
let tokenIndex = 0;
let iterator = tokenIterator.next(); while (tokenIndex < tokens.length) {
const token = tokens[tokenIndex];
while (!iterator.done) { const tokenStart = tokenOffset;
const token = iterator.value; const tokenEnd = tokenStart + token.length;
const testLine = currentLine + token; const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
@@ -429,37 +513,59 @@ const wrapLine = (
// build up the current line, skipping length check for possibly trailing whitespaces // build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) { if (/\s/.test(token) || testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = tokenStart;
}
currentLine = testLine; currentLine = testLine;
currentLineEnd = tokenEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
iterator = tokenIterator.next(); tokenOffset = tokenEnd;
tokenIndex++;
continue; continue;
} }
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped // current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) { if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth); const wrappedWord = wrapWord(token, font, maxWidth, tokenStart);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? ""; const trailingLine = wrappedWord[wrappedWord.length - 1] ?? {
text: "",
start: tokenStart,
end: tokenStart,
};
const precedingLines = wrappedWord.slice(0, -1); const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines); lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s // trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine; currentLine = trailingLine.text;
currentLineWidth = getLineWidth(trailingLine, font); currentLineStart = trailingLine.start;
iterator = tokenIterator.next(); currentLineEnd = trailingLine.end;
currentLineWidth = getLineWidth(trailingLine.text, font);
tokenOffset = tokenEnd;
tokenIndex++;
} else { } else {
// push & reset, but don't iterate on the next token, as we didn't use it yet! // push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push(currentLine.trimEnd()); lines.push(
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
);
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = ""; currentLine = "";
currentLineStart = tokenStart;
currentLineEnd = tokenStart;
currentLineWidth = 0; currentLineWidth = 0;
} }
} }
// iterator done, push the trailing line if exists // iterator done, push the trailing line if exists
if (currentLine) { if (currentLine) {
const trailingLine = trimLine(currentLine, font, maxWidth); const trailingLine = trimLine(
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine); lines.push(trailingLine);
} }
@@ -467,59 +573,100 @@ const wrapLine = (
}; };
/** /**
* Wraps the word into the lines based on the given width. * Wraps a single word that could not be placed on an empty line as-is.
*/ */
const wrapWord = ( const wrapWord = (
word: string, word: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): Array<string> => { wordStart: number,
): WrappedTextLine[] => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further // multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) { if (getEmojiRegex().test(word)) {
return [word]; return [
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
} }
satisfiesWordInvariant(word); satisfiesWordInvariant(word);
const lines: Array<string> = []; const lines: WrappedTextLine[] = [];
const chars = Array.from(word); const chars = Array.from(word);
let currentLine = ""; let currentLine = "";
let currentLineStart = wordStart;
let currentLineEnd = wordStart;
let currentLineWidth = 0; let currentLineWidth = 0;
let offset = wordStart;
for (const char of chars) { for (const char of chars) {
const charStart = offset;
const charEnd = charStart + char.length;
const _charWidth = charWidth.calculate(char, font); const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth; const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) { if (testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = charStart;
}
currentLine = currentLine + char; currentLine = currentLine + char;
currentLineEnd = charEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
offset = charEnd;
continue; continue;
} }
if (currentLine) { if (currentLine) {
lines.push(currentLine); lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
currentLine = char; currentLine = char;
currentLineStart = charStart;
currentLineEnd = charEnd;
currentLineWidth = _charWidth; currentLineWidth = _charWidth;
offset = charEnd;
} }
if (currentLine) { if (currentLine) {
lines.push(currentLine); lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
return lines; return lines;
}; };
/** /**
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`. * Trims trailing whitespace that is exceeding the `maxWidth`.
*
* Used for the trailing visual line of a hard line, where some trailing
* whitespace may still be visible if it fits into the available width.
*/ */
const trimLine = (line: string, font: FontString, maxWidth: number) => { const trimLine = (
line: string,
start: number,
end: number,
font: FontString,
maxWidth: number,
): WrappedTextLine => {
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth; const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) { if (!shouldTrimWhitespaces) {
return line; return {
text: line,
start,
end,
};
} }
// defensively default to `trimeEnd` in case the regex does not match // defensively default to `trimeEnd` in case the regex does not match
@@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
trimmedLineWidth = testLineWidth; trimmedLineWidth = testLineWidth;
} }
return trimmedLine; return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
};
/**
* Used for internal soft-wrap boundaries, where trailing whitespace should not
* survive into the rendered line even though it still exists in the original
* text.
*/
const trimLineEndAtSoftBreak = (
line: string,
start: number,
end: number,
): WrappedTextLine => {
const trimmedLine = line.trimEnd();
return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
}; };
/** /**
+17 -4
View File
@@ -303,19 +303,32 @@ export type PointsPositionUpdates = Map<
{ point: LocalPoint; isDragging?: boolean } { point: LocalPoint; isDragging?: boolean }
>; >;
export type CardinalityArrowhead =
| "cardinality_one"
| "cardinality_many"
| "cardinality_one_or_many"
| "cardinality_exactly_one"
| "cardinality_zero_or_one"
| "cardinality_zero_or_many";
export type ArrowheadLegacy =
| "dot"
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
| "bar" | "bar"
| "dot" // legacy. Do not use for new elements.
| "circle" | "circle"
| "circle_outline" | "circle_outline"
| "triangle" | "triangle"
| "triangle_outline" | "triangle_outline"
| "diamond" | "diamond"
| "diamond_outline" | "diamond_outline"
| "crowfoot_one" | CardinalityArrowhead;
| "crowfoot_many"
| "crowfoot_one_or_many"; export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
+4 -4
View File
@@ -135,9 +135,9 @@ describe("getElementBounds", () => {
} as ExcalidrawLinearElement; } as ExcalidrawLinearElement;
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
expect(x1).toEqual(360.9291017525165); expect(x1).toEqual(360.3176068760539);
expect(y1).toEqual(185.24770129343722); expect(y1).toEqual(185.90654264413516);
expect(x2).toEqual(481.4815539037601); expect(x2).toEqual(486.6924560404731);
expect(y2).toEqual(319.8162855827246); expect(y2).toEqual(320.391865303557);
}); });
}); });
@@ -434,12 +434,12 @@ describe("Test Linear Elements", () => {
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(` expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
[ [
[ [
"54.27552", "53.63967",
"46.16120", "47.15774",
], ],
[ [
"76.95494", "78.65236",
"44.56052", "44.31886",
], ],
] ]
`); `);
@@ -499,12 +499,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"104.27552", "103.63967",
"66.16120", "67.15774",
], ],
[ [
"126.95494", "128.65236",
"64.56052", "64.31886",
], ],
] ]
`); `);
@@ -815,12 +815,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"29.28349", "28.64089",
"20.91105", "21.69997",
], ],
[ [
"78.86048", "82.34322",
"46.12277", "47.57759",
], ],
] ]
`); `);
@@ -904,12 +904,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"54.27552", "53.63967",
"46.16120", "47.15774",
], ],
[ [
"76.95494", "78.65236",
"44.56052", "44.31886",
], ],
] ]
`); `);
@@ -1191,7 +1191,7 @@ describe("Test Linear Elements", () => {
20, 20,
105, 105,
80, 80,
"55.45894", "56.00000",
45, 45,
] ]
`); `);
@@ -1202,7 +1202,7 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
{ {
"height": 130, "height": 130,
"width": "366.11716", "width": "367.18528",
} }
`); `);
@@ -1214,7 +1214,7 @@ describe("Test Linear Elements", () => {
), ),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
{ {
"x": "271.11716", "x": "272.18528",
"y": 45, "y": 45,
} }
`); `);
@@ -1231,9 +1231,9 @@ describe("Test Linear Elements", () => {
[ [
20, 20,
35, 35,
"501.11716", "502.18528",
95, 95,
"205.45894", "208.69244",
"52.50000", "52.50000",
] ]
`); `);
+70 -1
View File
@@ -1,4 +1,8 @@
import { wrapText, parseTokens } from "../src/textWrapping"; import {
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types"; import type { FontString } from "../src/types";
@@ -102,6 +106,71 @@ describe("Test wrapText", () => {
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`); expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
}); });
it("should retain original text offsets for wrapped lines", () => {
expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([
{
text: "Hello",
start: 0,
end: 5,
},
{
text: "World!",
start: 6,
end: 12,
},
]);
});
it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => {
expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([
{
text: " Hello",
start: 0,
end: 7,
},
{
text: "World",
start: 9,
end: 14,
},
]);
});
it("should retain offsets when wrapping a single long token", () => {
expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([
{
text: "Excal",
start: 0,
end: 5,
},
{
text: "idraw",
start: 5,
end: 10,
},
]);
});
it("should preserve empty hard lines in metadata", () => {
expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([
{
text: "A",
start: 0,
end: 1,
},
{
text: "",
start: 2,
end: 2,
},
{
text: "B",
start: 3,
end: 4,
},
]);
});
describe("When text is CJK", () => { describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => { it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
+81
View File
@@ -11,6 +11,87 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section. Please add the latest change on the top under the correct section.
--> -->
## Unreleased
## Excalidraw API
### Breaking changes
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
### Features
- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting.
- Same events are also accessible imperatively through `api.onEvent(...)`.
```tsx
<Excalidraw
onExcalidrawAPI={(api) => {
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
console.log(container);
});
api.onEvent("editor:initialize").then((readyApi) => {
readyApi.scrollToContent();
});
}}
/>
```
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
- Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`.
- Exported `<ExcalidrawAPIProvider/>`, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
```tsx
<ExcalidrawAPIProvider>
<Excalidraw />
<Logger />
</ExcalidrawAPIProvider>;
function Logger() {
// initially null before the ExcalidrawAPIProvider initializes ater
// <Excalidraw/> renders
// When <Excalidraw/> unmounts, is reset back to null
const api = useExcalidrawAPI();
useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
console.log("view mode changed:", viewModeEnabled);
});
React.useEffect(() => {
if (api) {
console.log("editor instance id:", api.id);
}
}, [api]);
return null;
}
```
- Added `onExport` so host apps can delay JSON export until async work completes. The handler receives the export data plus an `AbortSignal`, and may return a `Promise` or an async generator that yields progress updates for the built-in toast UI.
```tsx
<Excalidraw
onExport={async function* (_type, { files }, { signal }) {
yield { type: "progress", message: "Waiting for images..." };
await waitForImagesToLoad(files, signal);
if (signal.aborted) {
return;
}
yield { type: "progress", message: "Export ready", progress: 1 };
}}
/>
```
## Excalidraw Library ## Excalidraw Library
## 0.18.0 (2025-03-11) ## 0.18.0 (2025-03-11)
+111 -14
View File
@@ -1,10 +1,10 @@
# Excalidraw # Excalidraw
**Excalidraw** is exported as a component to be directly embedded in your project. **Excalidraw** is exported as a React component that you can embed directly in your app.
## Installation ## Installation
Use `npm` or `yarn` to install the package. Install the package together with its React peer dependencies.
```bash ```bash
npm install react react-dom @excalidraw/excalidraw npm install react react-dom @excalidraw/excalidraw
@@ -12,34 +12,131 @@ npm install react react-dom @excalidraw/excalidraw
yarn add react react-dom @excalidraw/excalidraw yarn add react react-dom @excalidraw/excalidraw
``` ```
> **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`. > **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`.
#### Self-hosting fonts ## Quick start
By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod). The minimum working setup has two easy-to-miss requirements:
For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root: 1. Import the package CSS:
```js ```ts
<script>window.EXCALIDRAW_ASSET_PATH = "/";</script> import "@excalidraw/excalidraw/index.css";
``` ```
### Dimensions of Excalidraw 2. Render Excalidraw inside a container with a non-zero height.
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions. ```tsx
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function App() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
Excalidraw fills `100%` of the width and height of its parent. If the parent has no height, the canvas will not be visible.
## Next.js / SSR frameworks
Excalidraw should be rendered on the client. In SSR frameworks such as Next.js, use a client component and load it dynamically with SSR disabled.
```tsx
// app/components/ExcalidrawClient.tsx
"use client";
import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
export default function ExcalidrawClient() {
return (
<div style={{ height: "100vh" }}>
<Excalidraw />
</div>
);
}
```
```tsx
// app/page.tsx
import dynamic from "next/dynamic";
const ExcalidrawClient = dynamic(
() => import("./components/ExcalidrawClient"),
{ ssr: false },
);
export default function Page() {
return <ExcalidrawClient />;
}
```
See the local examples for complete setups:
- [examples/with-nextjs](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs)
- [examples/with-script-in-browser](https://github.com/excalidraw/excalidraw/tree/master/examples/with-script-in-browser)
## LLM / agent tips
If an LLM or coding agent is setting up Excalidraw, these shortcuts usually save more time than re-prompting:
- Start with a plain `<Excalidraw />` in a `100vh` container. Add refs, `initialData`, persistence, or custom UI only after the base embed works.
- If the canvas is blank, check the CSS import and parent height first. Those are the two most common integration failures.
- In Next.js or other SSR frameworks, assume client-only rendering first. Use `"use client"` and `dynamic(..., { ssr: false })` before debugging hydration or `window is not defined` errors.
- If imports or entrypoints are unclear, inspect `node_modules/@excalidraw/excalidraw/package.json`. The installed package exports are the source of truth.
- Do not set `window.EXCALIDRAW_ASSET_PATH` unless you are intentionally self-hosting fonts/assets.
- When docs and generated code drift, copy the nearest working example from this repo, especially `examples/with-nextjs` or `examples/with-script-in-browser`.
## Migrating to `@excalidraw/excalidraw@0.18.x`
Version `0.18.x` removes the old `types/`-prefixed deep import paths. If you were importing types from `@excalidraw/excalidraw/types/...`, switch to the new type-only subpaths below.
| Old path | New path |
| --- | --- |
| `@excalidraw/excalidraw/types/data/transform.js` | `@excalidraw/excalidraw/element/transform` |
| `@excalidraw/excalidraw/types/data/types.js` | `@excalidraw/excalidraw/data/types` |
| `@excalidraw/excalidraw/types/element/types.js` | `@excalidraw/excalidraw/element/types` |
| `@excalidraw/excalidraw/types/utility-types.js` | `@excalidraw/excalidraw/common/utility-types` |
| `@excalidraw/excalidraw/types/types.js` | `@excalidraw/excalidraw/types` |
Drop the `.js` extension. The new package `exports` map resolves these paths without it.
These deep subpaths are for `import type` only. Runtime imports should come from the package root, plus `@excalidraw/excalidraw/index.css` for styles.
For example:
```ts
import { exportToSvg } from "@excalidraw/excalidraw";
```
## Self-hosting fonts
By default, Excalidraw downloads the fonts it needs from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
For self-hosting, copy the contents of `node_modules/@excalidraw/excalidraw/dist/prod/fonts` into the path where your app serves static assets, for example `public/`. Then set `window.EXCALIDRAW_ASSET_PATH` to that same path:
```html
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
</script>
```
## Demo ## Demo
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example. Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser).
## Integration ## Integration
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration). Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
## API ## API
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api). Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
## Contributing ## Contributing
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing). Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
@@ -30,7 +30,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { useStylesPanelMode } from ".."; import { useStylesPanelMode } from "../components/App";
import { register } from "./register"; import { register } from "./register";
@@ -27,7 +27,7 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut"; import { getShortcutKey } from "../shortcut";
import { useStylesPanelMode } from ".."; import { useStylesPanelMode } from "../components/App";
import { register } from "./register"; import { register } from "./register";
+220 -38
View File
@@ -9,18 +9,20 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { Theme } from "@excalidraw/element/types"; import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
import { useEditorInterface } from "../components/App"; import { useEditorInterface } from "../components/App";
import { CheckboxItem } from "../components/CheckboxItem"; import { CheckboxItem } from "../components/CheckboxItem";
import { DarkModeToggle } from "../components/DarkModeToggle"; import { DarkModeToggle } from "../components/DarkModeToggle";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
import { Toast } from "../components/Toast";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { ExportIcon, questionCircle, saveAs } from "../components/icons"; import { ExportIcon, questionCircle, saveAs } from "../components/icons";
import { loadFromJSON, saveAsJSON } from "../data"; import { loadFromJSON, saveAsJSON } from "../data";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { resaveAsImageWithScene } from "../data/resave"; import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -31,7 +33,15 @@ import "../components/ToolIcon.scss";
import { register } from "./register"; import { register } from "./register";
import type { AppState } from "../types"; import type { JSONExportData } from "../data/json";
import type {
AppClassProperties,
AppState,
BinaryFiles,
ExcalidrawProps,
OnExportProgress,
} from "../types";
export const actionChangeProjectName = register<AppState["name"]>({ export const actionChangeProjectName = register<AppState["name"]>({
name: "changeProjectName", name: "changeProjectName",
@@ -150,6 +160,143 @@ export const actionChangeExportEmbedScene = register<
), ),
}); });
// ---------------------------------------------------------------------------
// onExport interception helpers
// ---------------------------------------------------------------------------
let onExportInProgress = false;
const onProgressToast = (
app: AppClassProperties,
progress: {
message?: OnExportProgress["message"];
progress?: number | null;
},
) => {
const message = progress.message ?? t("progressDialog.defaultMessage");
app.setAppState({
toast: {
message:
progress.progress != null ? (
<>
{message}
<Toast.ProgressBar progress={progress.progress} />
</>
) : (
message
),
duration: Infinity,
},
});
};
/** awaits host app's onExport result, and renders progress to the UI */
async function handleOnExportResult(
onExportResult: ReturnType<NonNullable<ExcalidrawProps["onExport"]>>,
opts: {
signal: AbortSignal;
app: AppClassProperties;
},
): Promise<void> {
if (opts.app.state.isLoading) {
onProgressToast(opts.app, { progress: null });
await opts.app.onStateChange({ predicate: (state) => !state.isLoading });
}
if (
onExportResult != null &&
typeof onExportResult === "object" &&
Symbol.asyncIterator in onExportResult
) {
for await (const value of onExportResult) {
if (opts.signal.aborted) {
onExportResult.return();
return;
}
if (value.type === "progress") {
onProgressToast(opts.app, {
message: value.message,
progress: value.progress ?? null,
});
} else if (value.type === "done") {
return;
}
}
// Generator completed without explicit "done" message
return;
}
if (onExportResult instanceof Promise) {
onProgressToast(opts.app, { progress: null });
await onExportResult;
}
}
function prepareDataForJSONExport(
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
app: AppClassProperties,
): { abortController: AbortController; data: Promise<JSONExportData> } {
const abortController = new AbortController();
const signal = abortController.signal;
const dataPromise = new Promise<JSONExportData>(async (resolve) => {
try {
if (app.props.onExport) {
await handleOnExportResult(
app.props.onExport(
"json",
{
elements,
appState,
files,
},
{
signal,
},
),
{
app,
signal,
},
);
}
} catch (error: any) {
if (error?.name === "AbortError") {
// if abort error, assume it's a reaction on the signal being aborted
console.warn(
`onExport() aborted by host app (signal aborted: ${signal.aborted})`,
);
} else {
// non-abort error
//
console.error("Error during props.onExport() handling", error);
}
// either way, we currently don't allow host apps to cancel save actions
// so we resolve to orig data
}
resolve({
elements,
appState,
// return latest files in case they finished loading during onExport
files: app.files,
});
});
return {
abortController,
data: dataPromise,
};
}
// ---------------------------------------------------------------------------
// Save actions
// ---------------------------------------------------------------------------
export const actionSaveToActiveFile = register({ export const actionSaveToActiveFile = register({
name: "saveToActiveFile", name: "saveToActiveFile",
label: "buttons.save", label: "buttons.save",
@@ -163,42 +310,62 @@ export const actionSaveToActiveFile = register({
); );
}, },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle; if (onExportInProgress) {
return false;
}
onExportInProgress = true;
const previousFileHandle = appState.fileHandle;
const filename = app.getName();
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try { try {
const { fileHandle } = isImageFileHandle(appState.fileHandle) const { fileHandle } = isImageFileHandle(previousFileHandle)
? await resaveAsImageWithScene( ? await resaveAsImageWithScene(
elements, exportedDataPromise,
appState, previousFileHandle,
app.files, filename,
app.getName(),
) )
: await saveAsJSON(elements, appState, app.files, app.getName()); : await saveAsJSON({
data: exportedDataPromise,
filename,
fileHandle: previousFileHandle,
});
return { return {
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.NEVER,
appState: { appState: {
...appState,
fileHandle, fileHandle,
toast: fileHandleExists toast: {
? { message:
message: fileHandle?.name previousFileHandle && fileHandle?.name
? t("toast.fileSavedToFilename").replace( ? t("toast.fileSavedToFilename").replace(
"{filename}", "{filename}",
`"${fileHandle.name}"`, `"${fileHandle.name}"`,
) )
: t("toast.fileSaved"), : t("toast.fileSaved"),
} duration: 1500,
: null, },
}, },
}; };
} catch (error: any) { } catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else { } else {
console.warn(error); console.warn(error);
} }
return { captureUpdate: CaptureUpdateAction.EVENTUALLY }; return {
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
} }
}, },
keyTest: (event) => keyTest: (event) =>
@@ -212,36 +379,50 @@ export const actionSaveFileToDisk = register({
viewMode: true, viewMode: true,
trackEvent: { category: "export" }, trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
if (onExportInProgress) {
return false;
}
onExportInProgress = true;
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try { try {
const { fileHandle } = await saveAsJSON( const { fileHandle: savedFileHandle } = await saveAsJSON({
elements, data: exportedDataPromise,
{ filename: app.getName(),
...appState, fileHandle: null,
fileHandle: null, });
},
app.files,
app.getName(),
);
return { return {
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.NEVER,
appState: { appState: {
...appState,
openDialog: null, openDialog: null,
fileHandle, fileHandle: savedFileHandle,
toast: { message: t("toast.fileSaved") }, toast: { message: t("toast.fileSaved"), duration: 3000 },
}, },
}; };
} catch (error: any) { } catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else { } else {
console.warn(error); console.warn(error);
} }
return { captureUpdate: CaptureUpdateAction.EVENTUALLY }; return {
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
} }
}, },
keyTest: (event) => keyTest: (event) =>
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD], event.key.toLowerCase() === KEYS.S &&
event.shiftKey &&
event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
@@ -300,7 +481,8 @@ export const actionExportWithDarkMode = register<
name: "exportWithDarkMode", name: "exportWithDarkMode",
label: "imageExportDialog.label.darkMode", label: "imageExportDialog.label.darkMode",
trackEvent: { category: "export", action: "toggleTheme" }, trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value) => { perform: (_elements, appState, value, app) => {
app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT;
return { return {
appState: { ...appState, exportWithDarkMode: value }, appState: { ...appState, exportWithDarkMode: value },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
@@ -18,7 +18,7 @@ import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter"; import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n"; import { t } from "../i18n";
import { useStylesPanelMode } from ".."; import { useStylesPanelMode } from "../components/App";
import type { History } from "../history"; import type { History } from "../history";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
+157 -90
View File
@@ -36,6 +36,7 @@ import {
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import { getArrowheadForPicker } from "@excalidraw/element";
import { import {
getBoundTextElement, getBoundTextElement,
@@ -124,9 +125,12 @@ import {
sharpArrowIcon, sharpArrowIcon,
roundArrowIcon, roundArrowIcon,
elbowArrowIcon, elbowArrowIcon,
ArrowheadCrowfootIcon, ArrowheadCardinalityExactlyOneIcon,
ArrowheadCrowfootOneIcon, ArrowheadCardinalityManyIcon,
ArrowheadCrowfootOneOrManyIcon, ArrowheadCardinalityOneIcon,
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
} from "../components/icons"; } from "../components/icons";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
@@ -187,7 +191,7 @@ export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
app: AppClassProperties, app: AppClassProperties,
getAttribute: (element: ExcalidrawElement) => T, getAttribute: (element: ExcalidrawElement) => T,
isRelevantElement: true | ((element: ExcalidrawElement) => boolean), elementPredicate: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T), defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingTextElement = app.state.editingTextElement; const editingTextElement = app.state.editingTextElement;
@@ -205,9 +209,9 @@ export const getFormValue = function <T extends Primitive>(
if (hasSelection) { if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state); const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements = const targetElements =
isRelevantElement === true elementPredicate === true
? selectedElements ? selectedElements
: selectedElements.filter((el) => isRelevantElement(el)); : selectedElements.filter((el) => elementPredicate(el));
ret = ret =
reduceToCommonValue(targetElements, getAttribute) ?? reduceToCommonValue(targetElements, getAttribute) ??
@@ -726,9 +730,28 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ app, updateData }) => ( PanelComponent: ({ elements, appState, app, updateData }) => {
<Range updateData={updateData} app={app} testId="opacity" /> const opacity = getFormValue(
), elements,
app,
(element) => element.opacity,
true,
(hasSelection) => (hasSelection ? null : appState.currentItemOpacity),
);
return (
<Range
label={t("labels.opacity")}
value={opacity ?? appState.currentItemOpacity}
hasCommonValue={opacity !== null}
onChange={updateData}
min={0}
max={100}
step={10}
testId="opacity"
/>
);
},
}); });
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>( export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
@@ -1550,80 +1573,117 @@ export const actionChangeRoundness = register<"sharp" | "round">({
}); });
const getArrowheadOptions = (flip: boolean) => { const getArrowheadOptions = (flip: boolean) => {
return [ return {
{ visibleSections: [
value: null, {
text: t("labels.arrowhead_none"), name: "default",
keyBinding: "q", options: [
icon: <ArrowheadNoneIcon flip={flip} />, {
}, value: null,
{ text: t("labels.arrowhead_none"),
value: "arrow", keyBinding: "q",
text: t("labels.arrowhead_arrow"), icon: <ArrowheadNoneIcon flip={flip} />,
keyBinding: "w", },
icon: <ArrowheadArrowIcon flip={flip} />, {
}, value: "arrow",
{ text: t("labels.arrowhead_arrow"),
value: "triangle", keyBinding: "w",
text: t("labels.arrowhead_triangle"), icon: <ArrowheadArrowIcon flip={flip} />,
icon: <ArrowheadTriangleIcon flip={flip} />, },
keyBinding: "e", {
}, value: "triangle",
{ text: t("labels.arrowhead_triangle"),
value: "triangle_outline", icon: <ArrowheadTriangleIcon flip={flip} />,
text: t("labels.arrowhead_triangle_outline"), keyBinding: "e",
icon: <ArrowheadTriangleOutlineIcon flip={flip} />, },
keyBinding: "r", {
}, value: "triangle_outline",
{ text: t("labels.arrowhead_triangle_outline"),
value: "circle", icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
text: t("labels.arrowhead_circle"), keyBinding: "r",
keyBinding: "a", },
icon: <ArrowheadCircleIcon flip={flip} />, ],
}, },
{ ],
value: "circle_outline", hiddenSections: [
text: t("labels.arrowhead_circle_outline"), {
keyBinding: "s", name: "default",
icon: <ArrowheadCircleOutlineIcon flip={flip} />, options: [
}, {
{ value: "circle",
value: "diamond", text: t("labels.arrowhead_circle"),
text: t("labels.arrowhead_diamond"), keyBinding: "a",
icon: <ArrowheadDiamondIcon flip={flip} />, icon: <ArrowheadCircleIcon flip={flip} />,
keyBinding: "d", },
}, {
{ value: "circle_outline",
value: "diamond_outline", text: t("labels.arrowhead_circle_outline"),
text: t("labels.arrowhead_diamond_outline"), keyBinding: "s",
icon: <ArrowheadDiamondOutlineIcon flip={flip} />, icon: <ArrowheadCircleOutlineIcon flip={flip} />,
keyBinding: "f", },
}, {
{ value: "diamond",
value: "bar", text: t("labels.arrowhead_diamond"),
text: t("labels.arrowhead_bar"), icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: "z", keyBinding: "d",
icon: <ArrowheadBarIcon flip={flip} />, },
}, {
{ value: "diamond_outline",
value: "crowfoot_one", text: t("labels.arrowhead_diamond_outline"),
text: t("labels.arrowhead_crowfoot_one"), icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
icon: <ArrowheadCrowfootOneIcon flip={flip} />, keyBinding: "f",
keyBinding: "x", },
}, {
{ value: "bar",
value: "crowfoot_many", text: t("labels.arrowhead_bar"),
text: t("labels.arrowhead_crowfoot_many"), keyBinding: "z",
icon: <ArrowheadCrowfootIcon flip={flip} />, icon: <ArrowheadBarIcon flip={flip} />,
keyBinding: "c", },
}, ],
{ },
value: "crowfoot_one_or_many", {
text: t("labels.arrowhead_crowfoot_one_or_many"), name: t("labels.cardinality"),
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />, options: [
keyBinding: "v", {
}, value: "cardinality_one",
] as const; text: t("labels.arrowhead_cardinality_one"),
icon: <ArrowheadCardinalityOneIcon flip={flip} />,
keyBinding: "x",
},
{
value: "cardinality_many",
text: t("labels.arrowhead_cardinality_many"),
icon: <ArrowheadCardinalityManyIcon flip={flip} />,
keyBinding: "c",
},
{
value: "cardinality_one_or_many",
text: t("labels.arrowhead_cardinality_one_or_many"),
icon: <ArrowheadCardinalityOneOrManyIcon flip={flip} />,
keyBinding: "v",
},
{
value: "cardinality_exactly_one",
text: t("labels.arrowhead_cardinality_exactly_one"),
icon: <ArrowheadCardinalityExactlyOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_one",
text: t("labels.arrowhead_cardinality_zero_or_one"),
icon: <ArrowheadCardinalityZeroOrOneIcon flip={flip} />,
keyBinding: null,
},
{
value: "cardinality_zero_or_many",
text: t("labels.arrowhead_cardinality_zero_or_many"),
icon: <ArrowheadCardinalityZeroOrManyIcon flip={flip} />,
keyBinding: null,
},
],
},
],
} as const;
}; };
export const actionChangeArrowhead = register<{ export const actionChangeArrowhead = register<{
@@ -1667,45 +1727,52 @@ export const actionChangeArrowhead = register<{
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app }) => {
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const startArrowheadOptions = useMemo(
() => getArrowheadOptions(!isRTL),
[isRTL],
);
const endArrowheadOptions = useMemo(
() => getArrowheadOptions(!!isRTL),
[isRTL],
);
return ( return (
<fieldset> <fieldset>
<legend>{t("labels.arrowheads")}</legend> <legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList buttonList"> <div className="iconSelectList buttonList">
<IconPicker <IconPicker
visibleSections={startArrowheadOptions.visibleSections}
hiddenSections={startArrowheadOptions.hiddenSections}
label="arrowhead_start" label="arrowhead_start"
options={getArrowheadOptions(!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
app, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead ? getArrowheadForPicker(element.startArrowhead)
: appState.currentItemStartArrowhead, : appState.currentItemStartArrowhead,
true, true,
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemStartArrowhead, hasSelection ? null : appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
<IconPicker <IconPicker
visibleSections={endArrowheadOptions.visibleSections}
hiddenSections={endArrowheadOptions.hiddenSections}
label="arrowhead_end" label="arrowhead_end"
group="arrowheads"
options={getArrowheadOptions(!!isRTL)}
value={getFormValue<Arrowhead | null>( value={getFormValue<Arrowhead | null>(
elements, elements,
app, app,
(element) => (element) =>
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead ? getArrowheadForPicker(element.endArrowhead)
: appState.currentItemEndArrowhead, : appState.currentItemEndArrowhead,
true, true,
(hasSelection) => (hasSelection) =>
hasSelection ? null : appState.currentItemEndArrowhead, hasSelection ? null : appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
</div> </div>
</fieldset> </fieldset>
@@ -1,24 +1,24 @@
import { getFontString } from "@excalidraw/common"; import { getFontString } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element"; import { isExcalidrawElement, newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element"; import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element"; import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties } from "../types";
export const actionTextAutoResize = register({ export const actionTextAutoResize = register({
name: "autoResize", name: "autoResize",
label: "labels.autoResize", label: "labels.autoResize",
icon: null, icon: null,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown, app: AppClassProperties) => { predicate: (elements, appState, _: unknown) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return ( return (
selectedElements.length === 1 && selectedElements.length === 1 &&
@@ -26,13 +26,18 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize !selectedElements[0].autoResize
); );
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, targetElement) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const targetTextElement =
isExcalidrawElement(targetElement) && isTextElement(targetElement)
? targetElement
: (selectedElements[0] as ExcalidrawElement | undefined);
return { return {
appState, appState,
elements: elements.map((element) => { elements: elements.map((element) => {
if (element.id === selectedElements[0].id && isTextElement(element)) { if (element.id === targetTextElement?.id && isTextElement(element)) {
const metrics = measureText( const metrics = measureText(
element.originalText, element.originalText,
getFontString(element), getFontString(element),
+2 -4
View File
@@ -33,8 +33,6 @@ import {
normalizeFile, normalizeFile,
} from "./data/blob"; } from "./data/blob";
import type { FileSystemHandle } from "./data/filesystem";
import type { BinaryFiles } from "./types"; import type { BinaryFiles } from "./types";
type ElementsClipboard = { type ElementsClipboard = {
@@ -369,7 +367,7 @@ type AllowedParsedDataTransferItem =
type: ValueOf<typeof IMAGE_MIME_TYPES>; type: ValueOf<typeof IMAGE_MIME_TYPES>;
kind: "file"; kind: "file";
file: File; file: File;
fileHandle: FileSystemHandle | null; fileHandle: FileSystemFileHandle | null;
} }
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string }; | { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
@@ -378,7 +376,7 @@ type ParsedDataTransferItem =
type: string; type: string;
kind: "file"; kind: "file";
file: File; file: File;
fileHandle: FileSystemHandle | null; fileHandle: FileSystemFileHandle | null;
} }
| { type: string; kind: "string"; value: string }; | { type: string; kind: "string"; value: string };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
import type { AppState, UnsubscribeCallback } from "../types";
type StateChangeSelector =
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown);
type StateChangePredicateOptions = {
predicate: (appState: AppState) => boolean;
callback?: (appState: AppState) => void;
once?: boolean;
};
type StateChangeArg = StateChangeSelector | StateChangePredicateOptions;
type StateChangeListener = {
predicate: (appState: AppState, prevState: AppState) => boolean;
getValue: (appState: AppState) => unknown;
callback: (value: any, appState: AppState) => void;
once: boolean;
};
type NormalizedStateChange = {
predicate: StateChangeListener["predicate"];
getValue: StateChangeListener["getValue"];
callback?: StateChangeListener["callback"];
once: boolean;
matchesImmediately: boolean;
};
export type OnStateChange = {
<K extends keyof AppState>(
prop: K,
callback: (value: AppState[K], appState: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
<K extends keyof AppState>(prop: K): Promise<AppState[K]>;
(
prop: (keyof AppState)[],
callback: (appState: AppState, appState2: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
(prop: (keyof AppState)[]): Promise<AppState>;
<T>(
prop: (appState: AppState) => T,
callback: (value: T, appState: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
<T>(prop: (appState: AppState) => T): Promise<T>;
(opts: {
predicate: (appState: AppState) => boolean;
callback: (appState: AppState) => void;
once?: boolean;
}): UnsubscribeCallback;
(opts: { predicate: (appState: AppState) => boolean }): Promise<AppState>;
(
selector: StateChangeSelector,
callback: (value: any, appState: AppState) => void,
): any;
};
export class AppStateObserver {
private listeners: StateChangeListener[] = [];
constructor(private readonly getState: () => AppState) {}
private isStateChangePredicateOptions(
propOrOpts: StateChangeArg,
): propOrOpts is StateChangePredicateOptions {
return (
typeof propOrOpts === "object" &&
!Array.isArray(propOrOpts) &&
"predicate" in propOrOpts
);
}
private subscribe(listener: StateChangeListener): UnsubscribeCallback {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(
(existingListener) => existingListener !== listener,
);
};
}
private normalize(
propOrOpts: StateChangeArg,
callback?: (value: any, appState: AppState) => void,
opts?: { once: boolean },
): NormalizedStateChange {
let predicate: StateChangeListener["predicate"];
let getValue: StateChangeListener["getValue"];
let normalizedCallback = callback;
let once = opts?.once ?? false;
let matchesImmediately = false;
if (this.isStateChangePredicateOptions(propOrOpts)) {
const {
predicate: predicateFn,
callback: callbackFromOpts,
once: onceFromOpts,
} = propOrOpts;
predicate = predicateFn;
getValue = (appState: AppState) => appState;
normalizedCallback = callbackFromOpts
? (_value: AppState, appState: AppState) => callbackFromOpts(appState)
: undefined;
once = onceFromOpts ?? false;
matchesImmediately = predicateFn(this.getState());
} else if (typeof propOrOpts === "function") {
const selector = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
selector(appState) !== selector(prevState);
getValue = (appState: AppState) => selector(appState);
} else if (Array.isArray(propOrOpts)) {
const keys = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
keys.some((key) => appState[key] !== prevState[key]);
getValue = (appState: AppState) => appState;
} else {
const key = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
appState[key] !== prevState[key];
getValue = (appState: AppState) => appState[key];
}
return {
predicate,
getValue,
callback: normalizedCallback,
once,
matchesImmediately,
};
}
public onStateChange: OnStateChange = ((
propOrOpts: StateChangeArg,
callback?: any,
opts?: { once: boolean },
) => {
const {
predicate,
getValue,
callback: stateChangeCallback,
once,
matchesImmediately,
} = this.normalize(propOrOpts, callback, opts);
if (stateChangeCallback) {
if (matchesImmediately) {
queueMicrotask(() => {
const state = this.getState();
stateChangeCallback(getValue(state), state);
});
if (once) {
return () => {};
}
}
return this.subscribe({
predicate,
getValue,
callback: stateChangeCallback,
once,
});
}
if (matchesImmediately) {
return Promise.resolve(getValue(this.getState()));
}
return new Promise<any>((resolve) => {
this.subscribe({
predicate,
getValue,
callback: (value) => resolve(value),
once: true,
});
});
}) as OnStateChange;
public flush(prevState: AppState) {
if (!this.listeners.length) {
return;
}
const state = this.getState();
const listenersToKeep: StateChangeListener[] = [];
for (const listener of this.listeners) {
if (listener.predicate(state, prevState)) {
listener.callback(listener.getValue(state), state);
if (!listener.once) {
listenersToKeep.push(listener);
}
} else {
listenersToKeep.push(listener);
}
}
this.listeners = listenersToKeep;
}
public clear() {
this.listeners = [];
}
}
@@ -10,12 +10,11 @@ import {
isWritableElement, isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
import type { MarkRequired } from "@excalidraw/common/utility-types"; import type { MarkRequired } from "@excalidraw/common/utility-types";
import { actionToggleShapeSwitch } from "../../actions/actionToggleShapeSwitch";
import { getShortcutKey } from "../../shortcut";
import { import {
actionClearCanvas, actionClearCanvas,
actionLink, actionLink,
@@ -1,7 +1,9 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { import {
bumpVersion,
getLinearElementSubType, getLinearElementSubType,
mutateElement,
updateElbowArrowPoints, updateElbowArrowPoints,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -37,6 +39,8 @@ import {
isProdEnv, isProdEnv,
mapFind, mapFind,
reduceToCommonValue, reduceToCommonValue,
ROUNDNESS,
sceneCoordsToViewportCoords,
updateActiveTool, updateActiveTool,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -71,12 +75,6 @@ import type {
import type { Scene } from "@excalidraw/element"; import type { Scene } from "@excalidraw/element";
import {
bumpVersion,
mutateElement,
ROUNDNESS,
sceneCoordsToViewportCoords,
} from "..";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { atom } from "../editor-jotai"; import { atom } from "../editor-jotai";
+15 -3
View File
@@ -6,14 +6,20 @@
padding: 0.5rem; padding: 0.5rem;
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75); border: 0 solid color.adjust(#fff, $alpha: -0.75);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island-stronger);
border-radius: 4px; border-radius: 4px;
position: absolute;
:root[dir="rtl"] & { :root[dir="rtl"] & {
padding: 0.4rem; padding: 0.4rem;
} }
} }
.picker-sections,
.picker-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.picker-container button, .picker-container button,
.picker button { .picker button {
position: relative; position: relative;
@@ -62,7 +68,13 @@
.picker-collapsible { .picker-collapsible {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.5rem 0; padding: 0;
color: var(--text-primary-color);
}
.picker-section-label {
font-size: 0.75rem;
color: var(--text-primary-color);
} }
.picker-keybinding { .picker-keybinding {
+157 -67
View File
@@ -1,6 +1,6 @@
import { Popover } from "radix-ui"; import { Popover } from "radix-ui";
import clsx from "clsx"; import clsx from "clsx";
import React, { useEffect } from "react"; import React, { useEffect, useMemo } from "react";
import { isArrowKey, KEYS } from "@excalidraw/common"; import { isArrowKey, KEYS } from "@excalidraw/common";
@@ -8,13 +8,15 @@ import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import Collapsible from "./Stats/Collapsible"; import Collapsible from "./Stats/Collapsible";
import { useEditorInterface, useExcalidrawContainer } from "./App"; import { useExcalidrawContainer } from "./App";
import "./IconPicker.scss"; import "./IconPicker.scss";
import type { JSX } from "react"; import type { JSX } from "react";
const moreOptionsAtom = atom(false); const moreOptionsAtom = atom(false);
const PICKER_COLUMNS = 4;
const DEFAULT_SECTION_NAME = "default";
type Option<T> = { type Option<T> = {
value: T; value: T;
@@ -23,28 +25,73 @@ type Option<T> = {
keyBinding: string | null; keyBinding: string | null;
}; };
type PickerSection<T> = {
name: string;
options: readonly Option<T>[];
};
const flattenOptions = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) => section.options);
const findOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => {
for (const section of sections) {
const option = section.options.find(predicate);
if (option) {
return option;
}
}
return null;
};
const hasOption = <T,>(
sections: readonly PickerSection<T>[],
predicate: (option: Option<T>) => boolean,
) => sections.some((section) => section.options.some(predicate));
const getNavigationRows = <T,>(sections: readonly PickerSection<T>[]) =>
sections.flatMap((section) =>
Array.from(
{ length: Math.ceil(section.options.length / PICKER_COLUMNS) },
(_, index) =>
section.options.slice(
index * PICKER_COLUMNS,
index * PICKER_COLUMNS + PICKER_COLUMNS,
),
),
);
function Picker<T>({ function Picker<T>({
options, visibleSections,
hiddenSections = [],
value, value,
label, label,
onChange, onChange,
onClose, onClose,
numberOfOptionsToAlwaysShow = options.length,
}: { }: {
label: string; label: string;
value: T; value: T;
options: readonly Option<T>[]; visibleSections: readonly PickerSection<T>[];
hiddenSections?: readonly PickerSection<T>[];
onChange: (value: T) => void; onChange: (value: T) => void;
onClose: () => void; onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) { }) {
const editorInterface = useEditorInterface();
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const allSections = [...visibleSections, ...hiddenSections];
const allOptions = flattenOptions(allSections);
const navigationRows = getNavigationRows([
...visibleSections,
...(showMoreOptions ? hiddenSections : []),
]);
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find( const pressedOption = allOptions.find(
(option) => option.keyBinding === event.key.toLowerCase(), (option) => option.keyBinding === event.key.toLowerCase(),
)!; );
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) { if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation // Keybinding navigation
@@ -52,17 +99,17 @@ function Picker<T>({
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.TAB) { } else if (event.key === KEYS.TAB) {
const index = options.findIndex((option) => option.value === value); const index = allOptions.findIndex((option) => option.value === value);
const nextIndex = event.shiftKey const nextIndex = event.shiftKey
? (options.length + index - 1) % options.length ? (allOptions.length + index - 1) % allOptions.length
: (index + 1) % options.length; : (index + 1) % allOptions.length;
onChange(options[nextIndex].value); onChange(allOptions[nextIndex].value);
} else if (isArrowKey(event.key)) { } else if (isArrowKey(event.key)) {
// Arrow navigation // Arrow navigation
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const index = options.findIndex((option) => option.value === value); const index = allOptions.findIndex((option) => option.value === value);
if (index !== -1) { if (index !== -1) {
const length = options.length; const length = allOptions.length;
let nextIndex = index; let nextIndex = index;
switch (event.key) { switch (event.key) {
@@ -76,18 +123,60 @@ function Picker<T>({
break; break;
// Go the next row // Go the next row
case KEYS.ARROW_DOWN: { case KEYS.ARROW_DOWN: {
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length; const currentRowIndex = navigationRows.findIndex((row) =>
row.some((option) => option.value === value),
);
const currentRow = navigationRows[currentRowIndex];
if (currentRowIndex !== -1 && currentRow) {
const column = currentRow.findIndex(
(option) => option.value === value,
);
const nextRow =
navigationRows[(currentRowIndex + 1) % navigationRows.length];
const nextOption =
nextRow[Math.min(column, nextRow.length - 1)] ??
allOptions[index];
onChange(nextOption.value);
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
return;
}
break; break;
} }
// Go the previous row // Go the previous row
case KEYS.ARROW_UP: { case KEYS.ARROW_UP: {
nextIndex = const currentRowIndex = navigationRows.findIndex((row) =>
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length; row.some((option) => option.value === value),
);
const currentRow = navigationRows[currentRowIndex];
if (currentRowIndex !== -1 && currentRow) {
const column = currentRow.findIndex(
(option) => option.value === value,
);
const previousRow =
navigationRows[
(navigationRows.length + currentRowIndex - 1) %
navigationRows.length
];
const previousOption =
previousRow[Math.min(column, previousRow.length - 1)] ??
allOptions[index];
onChange(previousOption.value);
event.preventDefault();
event.nativeEvent.stopImmediatePropagation();
event.stopPropagation();
return;
}
break; break;
} }
} }
onChange(options[nextIndex].value); onChange(allOptions[nextIndex].value);
} }
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -99,38 +188,29 @@ function Picker<T>({
event.stopPropagation(); event.stopPropagation();
}; };
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
const alwaysVisibleOptions = React.useMemo(
() => options.slice(0, numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
const moreOptions = React.useMemo(
() => options.slice(numberOfOptionsToAlwaysShow),
[options, numberOfOptionsToAlwaysShow],
);
useEffect(() => { useEffect(() => {
if (!alwaysVisibleOptions.some((option) => option.value === value)) { if (hasOption(hiddenSections, (option) => option.value === value)) {
setShowMoreOptions(true); setShowMoreOptions(true);
} }
}, [value, alwaysVisibleOptions, setShowMoreOptions]); }, [value, hiddenSections, setShowMoreOptions]);
const renderOptions = (options: Option<T>[]) => { const renderOptions = (options: readonly Option<T>[]) => {
return ( return (
<div className="picker-content"> <div className="picker-content">
{options.map((option, i) => ( {options.map((option) => (
<button <button
type="button" type="button"
className={clsx("picker-option", { className={clsx("picker-option", {
active: value === option.value, active: value === option.value,
})} })}
onClick={(event) => { onClick={() => {
onChange(option.value); onChange(option.value);
}} }}
title={`${option.text} ${ title={
option.keyBinding && `${option.keyBinding.toUpperCase()}` option.keyBinding
}`} ? `${option.text}${option.keyBinding.toUpperCase()}`
: option.text
}
aria-label={option.text || "none"} aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined} aria-keyshortcuts={option.keyBinding || undefined}
key={option.text} key={option.text}
@@ -153,26 +233,38 @@ function Picker<T>({
); );
}; };
const isMobile = editorInterface.formFactor === "phone"; const renderSections = (sections: readonly PickerSection<T>[]) =>
sections.map((section, index) =>
section.name === DEFAULT_SECTION_NAME ? (
<React.Fragment key={`${section.name}-${index}`}>
{renderOptions(section.options)}
</React.Fragment>
) : (
<div className="picker-section" key={`${section.name}-${index}`}>
<div className="picker-section-label">{section.name}</div>
{renderOptions(section.options)}
</div>
),
);
return ( return (
<Popover.Content <Popover.Content
side={isMobile ? "right" : "bottom"} className="picker"
role="dialog"
aria-modal="true"
aria-label={label}
side={"bottom"}
align="start" align="start"
sideOffset={isMobile ? 8 : 12} sideOffset={12}
alignOffset={12}
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }} style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
collisionBoundary={container ?? undefined} collisionBoundary={container ?? undefined}
> >
<div <div className="picker-sections">
className={`picker`} {renderSections(visibleSections)}
role="dialog"
aria-modal="true"
aria-label={label}
>
{renderOptions(alwaysVisibleOptions)}
{moreOptions.length > 0 && ( {hiddenSections.length > 0 && (
<Collapsible <Collapsible
label={t("labels.more_options")} label={t("labels.more_options")}
open={showMoreOptions} open={showMoreOptions}
@@ -181,7 +273,9 @@ function Picker<T>({
}} }}
className="picker-collapsible" className="picker-collapsible"
> >
{renderOptions(moreOptions)} <div className="picker-sections">
{renderSections(hiddenSections)}
</div>
</Collapsible> </Collapsible>
)} )}
</div> </div>
@@ -192,49 +286,45 @@ function Picker<T>({
export function IconPicker<T>({ export function IconPicker<T>({
value, value,
label, label,
options, visibleSections,
hiddenSections,
onChange, onChange,
group = "",
numberOfOptionsToAlwaysShow,
}: { }: {
label: string; label: string;
value: T; value: T;
options: readonly { visibleSections: readonly PickerSection<T>[];
value: T; hiddenSections?: readonly PickerSection<T>[];
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
onChange: (value: T) => void; onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string;
}) { }) {
const [isActive, setActive] = React.useState(false); const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null); const selectedOption = useMemo(
() =>
findOption(visibleSections, (option) => option.value === value) ??
findOption(hiddenSections ?? [], (option) => option.value === value),
[visibleSections, hiddenSections, value],
);
return ( return (
<div> <div>
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}> <Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
<Popover.Trigger <Popover.Trigger
name={group}
type="button" type="button"
aria-label={label} aria-label={label}
onClick={() => setActive(!isActive)} onClick={() => setActive(!isActive)}
ref={rPickerButton}
className={isActive ? "active" : ""} className={isActive ? "active" : ""}
> >
{options.find((option) => option.value === value)?.icon} {selectedOption?.icon}
</Popover.Trigger> </Popover.Trigger>
{isActive && ( {isActive && (
<Picker <Picker
options={options} visibleSections={visibleSections}
hiddenSections={hiddenSections}
value={value} value={value}
label={label} label={label}
onChange={onChange} onChange={onChange}
onClose={() => { onClose={() => {
setActive(false); setActive(false);
}} }}
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
/> />
)} )}
</Popover.Root> </Popover.Root>
@@ -59,6 +59,7 @@ type ImageExportModalProps = {
actionManager: ActionManager; actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"]; onExportImage: AppClassProperties["onExportImage"];
name: string; name: string;
exportWithDarkMode: boolean;
}; };
const ImageExportModal = ({ const ImageExportModal = ({
@@ -68,6 +69,7 @@ const ImageExportModal = ({
actionManager, actionManager,
onExportImage, onExportImage,
name, name,
exportWithDarkMode,
}: ImageExportModalProps) => { }: ImageExportModalProps) => {
const hasSelection = isSomeElementSelected( const hasSelection = isSomeElementSelected(
elementsSnapshot, elementsSnapshot,
@@ -79,15 +81,13 @@ const ImageExportModal = ({
const [exportWithBackground, setExportWithBackground] = useState( const [exportWithBackground, setExportWithBackground] = useState(
appStateSnapshot.exportBackground, appStateSnapshot.exportBackground,
); );
const [exportDarkMode, setExportDarkMode] = useState(
appStateSnapshot.exportWithDarkMode,
);
const [embedScene, setEmbedScene] = useState( const [embedScene, setEmbedScene] = useState(
appStateSnapshot.exportEmbedScene, appStateSnapshot.exportEmbedScene,
); );
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale); const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const previewRenderRequestIdRef = useRef(0);
const [renderError, setRenderError] = useState<Error | null>(null); const [renderError, setRenderError] = useState<Error | null>(null);
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus(); const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
@@ -99,7 +99,7 @@ const ImageExportModal = ({
}, [ }, [
projectName, projectName,
exportWithBackground, exportWithBackground,
exportDarkMode, exportWithDarkMode,
exportScale, exportScale,
embedScene, embedScene,
resetCopyStatus, resetCopyStatus,
@@ -122,13 +122,18 @@ const ImageExportModal = ({
return; return;
} }
const requestId = ++previewRenderRequestIdRef.current;
const isStaleRequest = () => {
return requestId !== previewRenderRequestIdRef.current;
};
exportToCanvas({ exportToCanvas({
elements: exportedElements, elements: exportedElements,
appState: { appState: {
...appStateSnapshot, ...appStateSnapshot,
name: projectName, name: projectName,
exportBackground: exportWithBackground, exportBackground: exportWithBackground,
exportWithDarkMode: exportDarkMode, exportWithDarkMode,
exportScale, exportScale,
exportEmbedScene: embedScene, exportEmbedScene: embedScene,
}, },
@@ -137,25 +142,41 @@ const ImageExportModal = ({
maxWidthOrHeight: Math.max(maxWidth, maxHeight), maxWidthOrHeight: Math.max(maxWidth, maxHeight),
exportingFrame, exportingFrame,
}) })
.then((canvas) => { .then(async (canvas) => {
if (isStaleRequest()) {
return;
}
// If converting to blob fails, there's some problem that will likely
// prevent preview and export (e.g. canvas too big).
try {
await canvasToBlob(canvas);
} catch (error: any) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw new Error(t("canvasError.canvasTooBig"));
}
throw error;
}
if (isStaleRequest()) {
return;
}
setRenderError(null); setRenderError(null);
// if converting to blob fails, there's some problem that will previewNode.replaceChildren(canvas);
// likely prevent preview and export (e.g. canvas too big)
return canvasToBlob(canvas)
.then(() => {
previewNode.replaceChildren(canvas);
})
.catch((e) => {
if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw new Error(t("canvasError.canvasTooBig"));
}
throw e;
});
}) })
.catch((error) => { .catch((error) => {
if (isStaleRequest()) {
return;
}
console.error(error); console.error(error);
setRenderError(error); setRenderError(error);
}); });
return () => {
previewRenderRequestIdRef.current += 1;
};
}, [ }, [
appStateSnapshot, appStateSnapshot,
files, files,
@@ -163,7 +184,7 @@ const ImageExportModal = ({
exportingFrame, exportingFrame,
projectName, projectName,
exportWithBackground, exportWithBackground,
exportDarkMode, exportWithDarkMode,
exportScale, exportScale,
embedScene, embedScene,
]); ]);
@@ -233,9 +254,8 @@ const ImageExportModal = ({
> >
<Switch <Switch
name="exportDarkModeSwitch" name="exportDarkModeSwitch"
checked={exportDarkMode} checked={exportWithDarkMode}
onChange={(checked) => { onChange={(checked) => {
setExportDarkMode(checked);
actionManager.executeAction( actionManager.executeAction(
actionExportWithDarkMode, actionExportWithDarkMode,
"ui", "ui",
@@ -399,6 +419,7 @@ export const ImageExportDialog = ({
actionManager={actionManager} actionManager={actionManager}
onExportImage={onExportImage} onExportImage={onExportImage}
name={name} name={name}
exportWithDarkMode={appState.exportWithDarkMode}
/> />
</Dialog> </Dialog>
); );
+25 -12
View File
@@ -60,6 +60,7 @@ import { ImageExportDialog } from "./ImageExportDialog";
import { Island } from "./Island"; import { Island } from "./Island";
import { JSONExportDialog } from "./JSONExportDialog"; import { JSONExportDialog } from "./JSONExportDialog";
import { LaserPointerButton } from "./LaserPointerButton"; import { LaserPointerButton } from "./LaserPointerButton";
import { Toast } from "./Toast";
import "./LayerUI.scss"; import "./LayerUI.scss";
import "./Toolbar.scss"; import "./Toolbar.scss";
@@ -605,18 +606,30 @@ const LayerUI = ({
showExitZenModeBtn={showExitZenModeBtn} showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
/> />
{appState.scrolledOutside && ( {(appState.toast || appState.scrolledOutside) && (
<button <div className="floating-status-stack">
type="button" {appState.toast && (
className="scroll-back-to-content" <Toast
onClick={() => { message={appState.toast.message}
setAppState((appState) => ({ onClose={() => setAppState({ toast: null })}
...calculateScrollCenter(elements, appState), duration={appState.toast.duration}
})); closable={appState.toast.closable}
}} />
> )}
{t("buttons.scrollBackToContent")} {!appState.toast && appState.scrolledOutside && (
</button> <button
type="button"
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState),
}));
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
)} )}
</div> </div>
{renderSidebars()} {renderSidebars()}
@@ -472,9 +472,9 @@ export const MobileToolBar = ({
onSelect={() => app.onMagicframeToolSelect()} onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon} icon={MagicIcon}
data-testid="toolbar-magicframe" data-testid="toolbar-magicframe"
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
> >
{t("toolBar.magicframe")} {t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
</> </>
)} )}
+37 -33
View File
@@ -1,74 +1,78 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { t } from "../i18n";
import "./Range.scss"; import "./Range.scss";
import type { AppClassProperties } from "../types";
export type RangeProps = { export type RangeProps = {
updateData: (value: number) => void; label: React.ReactNode;
app: AppClassProperties; value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
minLabel?: React.ReactNode;
hasCommonValue?: boolean;
testId?: string; testId?: string;
}; };
export const Range = ({ updateData, app, testId }: RangeProps) => { export const Range = ({
label,
value,
onChange,
min = 0,
max = 100,
step = 10,
minLabel = min,
hasCommonValue = true,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null); const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null); const valueRef = React.useRef<HTMLDivElement>(null);
const selectedElements = app.scene.getSelectedElements(app.state);
let hasCommonOpacity = true;
const firstElement = selectedElements.at(0);
const leastCommonOpacity = selectedElements.reduce((acc, element) => {
if (acc != null && acc !== element.opacity) {
hasCommonOpacity = false;
}
if (acc == null || acc > element.opacity) {
return element.opacity;
}
return acc;
}, firstElement?.opacity ?? null);
const value = leastCommonOpacity ?? app.state.currentItemOpacity;
useEffect(() => { useEffect(() => {
if (rangeRef.current && valueRef.current) { if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current; const rangeElement = rangeRef.current;
const valueElement = valueRef.current; const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth; const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 15; // 15 is the width of the thumb const thumbWidth =
parseFloat(
getComputedStyle(rangeElement).getPropertyValue(
"--slider-thumb-size",
),
) || 16;
const progress = ((value - min) / (max - min || 1)) * 100;
const position = const position =
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; (progress / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`; valueElement.style.left = `${position}px`;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`; rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${progress}%, var(--button-bg) ${progress}%, var(--button-bg) 100%)`;
} }
}, [value]); }, [max, min, value]);
return ( return (
<label className="control-label"> <label className="control-label">
{t("labels.opacity")} {label}
<div className="range-wrapper"> <div className="range-wrapper">
<input <input
style={{ style={{
["--color-slider-track" as string]: hasCommonOpacity ["--color-slider-track" as string]: hasCommonValue
? undefined ? undefined
: "var(--button-bg)", : "var(--button-bg)",
}} }}
ref={rangeRef} ref={rangeRef}
type="range" type="range"
min="0" min={min}
max="100" max={max}
step="10" step={step}
onChange={(event) => { onChange={(event) => {
updateData(+event.target.value); onChange(+event.target.value);
}} }}
value={value} value={value}
className="range-input" className="range-input"
data-testid={testId} data-testid={testId}
/> />
<div className="value-bubble" ref={valueRef}> <div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null} {value !== min ? value : null}
</div> </div>
<div className="zero-label">0</div> <div className="zero-label">{minLabel}</div>
</div> </div>
</label> </label>
); );
@@ -361,12 +361,10 @@ describe("stats for a non-generic element", () => {
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const editor = await getTextEditor(); const editor = await getTextEditor();
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
act(() => { Keyboard.exitTextEditor(editor);
editor.blur();
});
const text = h.elements[0] as ExcalidrawTextElement; const text = h.elements[0] as ExcalidrawTextElement;
mouse.clickOn(text); API.setSelectedElements([text]);
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
@@ -11,7 +11,7 @@ import { rateLimitsAtom } from "../TTDContext";
import { ChatHistoryMenu } from "./ChatHistoryMenu"; import { ChatHistoryMenu } from "./ChatHistoryMenu";
import { ChatInterface } from "."; import { ChatInterface } from "./ChatInterface";
import type { TTDPanelAction } from "../TTDDialogPanel"; import type { TTDPanelAction } from "../TTDDialogPanel";
@@ -0,0 +1,239 @@
import { useEffect, useRef } from "react";
import {
Decoration,
EditorView,
keymap,
lineNumbers,
placeholder as cmPlaceholder,
drawSelection,
} from "@codemirror/view";
import { Compartment, EditorState, type Extension } from "@codemirror/state";
import {
defaultKeymap,
history,
historyKeymap,
redo,
} from "@codemirror/commands";
import { syntaxHighlighting, HighlightStyle } from "@codemirror/language";
import { tags } from "@lezer/highlight";
import type { Theme } from "@excalidraw/element/types";
import { mermaidLite } from "./mermaid-lang-lite";
export interface CodeMirrorEditorProps {
value: string;
onChange: (value: string) => void;
onKeyboardSubmit?: () => void;
placeholder?: string;
theme: Theme;
errorLine?: number | null;
}
// ---- Dark theme ----
const darkTheme = EditorView.theme(
{
"&": {
backgroundColor: "#1e1e1e",
color: "#d4d4d4",
},
".cm-content": { caretColor: "#fff" },
".cm-cursor": { borderLeftColor: "#fff" },
".cm-gutters": {
backgroundColor: "#1e1e1e",
color: "#858585",
border: "none",
},
".cm-activeLineGutter": { backgroundColor: "#2a2a2a" },
".cm-activeLine": { backgroundColor: "#2a2a2a" },
".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.15)" },
},
{ dark: true },
);
const darkHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#569cd6" },
{ tag: tags.string, color: "#ce9178" },
{ tag: tags.comment, color: "#6a9955" },
{ tag: tags.number, color: "#b5cea8" },
{ tag: tags.operator, color: "#d4d4d4" },
{ tag: tags.punctuation, color: "#d4d4d4" },
{ tag: tags.variableName, color: "#9cdcfe" },
{ tag: tags.bracket, color: "#ffd700" },
]);
// ---- Light theme ----
const lightTheme = EditorView.theme({
"&": {
backgroundColor: "#ffffff",
color: "#1e1e1e",
},
".cm-content": { caretColor: "#000" },
".cm-cursor": { borderLeftColor: "#000" },
".cm-gutters": {
backgroundColor: "#fff",
color: "#999",
border: "none",
},
".cm-activeLineGutter": { backgroundColor: "#e8e8e8" },
".cm-activeLine": { backgroundColor: "#e8e8e8" },
".cm-errorLine": { backgroundColor: "rgba(255, 0, 0, 0.1)" },
});
const lightHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#0000ff" },
{ tag: tags.string, color: "#a31515" },
{ tag: tags.comment, color: "#008000" },
{ tag: tags.number, color: "#098658" },
{ tag: tags.operator, color: "#1e1e1e" },
{ tag: tags.punctuation, color: "#1e1e1e" },
{ tag: tags.variableName, color: "#001080" },
{ tag: tags.bracket, color: "#af00db" },
]);
// ---- Error line decoration ----
const errorLineDeco = Decoration.line({ class: "cm-errorLine" });
const getErrorLineExtension = (
errorLine: number | null | undefined,
doc: { line(n: number): { from: number }; lines: number },
): Extension => {
if (!errorLine || errorLine < 1 || errorLine > doc.lines) {
return EditorView.decorations.of(Decoration.none);
}
const line = doc.line(errorLine);
return EditorView.decorations.of(
Decoration.set([errorLineDeco.range(line.from)]),
);
};
// ---- Helpers ----
const getThemeExtensions = (theme: Theme) => {
if (theme === "dark") {
return [darkTheme, syntaxHighlighting(darkHighlight)];
}
return [lightTheme, syntaxHighlighting(lightHighlight)];
};
const CodeMirrorEditor = ({
value,
onChange,
onKeyboardSubmit,
placeholder,
theme,
errorLine,
}: CodeMirrorEditorProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const onKeyboardSubmitRef = useRef(onKeyboardSubmit);
const themeCompartmentRef = useRef(new Compartment());
const errorLineCompartmentRef = useRef(new Compartment());
onChangeRef.current = onChange;
onKeyboardSubmitRef.current = onKeyboardSubmit;
useEffect(() => {
if (!containerRef.current) {
return;
}
const themeCompartment = themeCompartmentRef.current;
const view = new EditorView({
state: EditorState.create({
doc: value,
extensions: [
keymap.of([
{
key: "Mod-Enter",
run: () => {
onKeyboardSubmitRef.current?.();
return true;
},
},
// historyKeymap binds Mod-Shift-z only on Mac; add it for all platforms
{ key: "Mod-Shift-z", run: redo, preventDefault: true },
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
lineNumbers(),
EditorView.lineWrapping,
themeCompartment.of(getThemeExtensions(theme)),
errorLineCompartmentRef.current.of([]),
mermaidLite(),
drawSelection({ drawRangeCursor: true }),
...(placeholder ? [cmPlaceholder(placeholder)] : []),
],
}),
parent: containerRef.current,
});
viewRef.current = view;
view.focus();
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Swap theme dynamically via compartment
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
view.dispatch({
effects: themeCompartmentRef.current.reconfigure(
getThemeExtensions(theme),
),
});
}, [theme]);
// Update error line highlight
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
view.dispatch({
effects: errorLineCompartmentRef.current.reconfigure(
getErrorLineExtension(errorLine, view.state.doc),
),
});
}, [errorLine]);
// Sync external value changes into EditorView
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
const currentDoc = view.state.doc.toString();
if (value !== currentDoc) {
view.dispatch({
changes: { from: 0, to: currentDoc.length, insert: value },
});
}
}, [value]);
return (
<div
ref={containerRef}
className="ttd-dialog-input ttd-dialog-input--codemirror"
/>
);
};
export default CodeMirrorEditor;
@@ -17,6 +17,11 @@ import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel"; import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogPanels } from "./TTDDialogPanels"; import { TTDDialogPanels } from "./TTDDialogPanels";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut"; import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
import {
getMermaidErrorLineNumber,
isMermaidAutoFixableError,
} from "./utils/mermaidError";
import { getMermaidAutoFixCandidates } from "./utils/mermaidAutoFix";
import { import {
convertMermaidToExcalidraw, convertMermaidToExcalidraw,
insertToEditor, insertToEditor,
@@ -33,6 +38,27 @@ const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]"; "flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
const debouncedSaveMermaidDefinition = debounce(saveMermaidDataToStorage, 300); const debouncedSaveMermaidDefinition = debounce(saveMermaidDataToStorage, 300);
const AUTO_FIX_DEBOUNCE_MS = 500;
const AUTO_FIX_MAX_DEPTH = 4;
const AUTO_FIX_MAX_CANDIDATES = 30;
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
if (
error &&
typeof error === "object" &&
"message" in error &&
typeof (error as { message?: unknown }).message === "string"
) {
return (error as { message: string }).message;
}
return "";
};
const MermaidToExcalidraw = ({ const MermaidToExcalidraw = ({
mermaidToExcalidrawLib, mermaidToExcalidrawLib,
@@ -46,8 +72,16 @@ const MermaidToExcalidraw = ({
EditorLocalStorage.get<string>(EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW) || EditorLocalStorage.get<string>(EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW) ||
MERMAID_EXAMPLE, MERMAID_EXAMPLE,
); );
const deferredText = useDeferredValue(text.trim()); const deferredText = useDeferredValue(text);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [autoFixCandidate, setAutoFixCandidate] = useState<string | null>(null);
const errorLine = (() => {
if (!error?.message) {
return null;
}
return getMermaidErrorLineNumber(error.message, deferredText);
})();
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{ const data = useRef<{
@@ -61,7 +95,7 @@ const MermaidToExcalidraw = ({
useEffect(() => { useEffect(() => {
const doRender = async () => { const doRender = async () => {
try { try {
if (!deferredText) { if (!deferredText.trim()) {
resetPreview({ canvasRef, setError }); resetPreview({ canvasRef, setError });
return; return;
} }
@@ -98,6 +132,88 @@ const MermaidToExcalidraw = ({
[], [],
); );
useEffect(() => {
const errorMessage = error?.message ?? "";
const sourceText = deferredText;
const shouldTryAutoFix =
isActive &&
isMermaidAutoFixableError(errorMessage) &&
!!sourceText.trim() &&
mermaidToExcalidrawLib.loaded;
if (!shouldTryAutoFix) {
setAutoFixCandidate(null);
return;
}
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
if (!candidates.length) {
setAutoFixCandidate(null);
return;
}
let cancelled = false;
const timer = setTimeout(async () => {
try {
const api = await mermaidToExcalidrawLib.api;
const seen = new Set<string>([sourceText]);
const queue = candidates.map((candidate) => ({
text: candidate,
depth: 1,
}));
let triedCandidates = 0;
while (queue.length > 0 && triedCandidates < AUTO_FIX_MAX_CANDIDATES) {
const current = queue.shift();
if (!current || seen.has(current.text)) {
continue;
}
seen.add(current.text);
triedCandidates += 1;
try {
await api.parseMermaidToExcalidraw(current.text);
if (!cancelled) {
setAutoFixCandidate(current.text);
}
return;
} catch (candidateError) {
if (current.depth >= AUTO_FIX_MAX_DEPTH) {
continue;
}
const nextErrorMessage = getErrorMessage(candidateError);
if (!nextErrorMessage) {
continue;
}
const nextCandidates = getMermaidAutoFixCandidates(
current.text,
nextErrorMessage,
);
for (const nextCandidate of nextCandidates) {
if (!seen.has(nextCandidate)) {
queue.push({
text: nextCandidate,
depth: current.depth + 1,
});
}
}
}
}
} catch {
// ignore auto-fix probe errors
}
if (!cancelled) {
setAutoFixCandidate(null);
}
}, AUTO_FIX_DEBOUNCE_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [deferredText, error?.message, isActive, mermaidToExcalidrawLib]);
const onInsertToEditor = () => { const onInsertToEditor = () => {
insertToEditor({ insertToEditor({
app, app,
@@ -107,21 +223,53 @@ const MermaidToExcalidraw = ({
}); });
}; };
const onApplyAutoFix = () => {
if (!autoFixCandidate) {
return;
}
setText(autoFixCandidate);
};
return ( return (
<> <>
<div className="ttd-dialog-desc"> <div className="ttd-dialog-desc">
<Trans <Trans
i18nKey="mermaid.description" i18nKey="mermaid.description"
flowchartLink={(el) => ( flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a> <a
href="https://mermaid.js.org/syntax/flowchart.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)} )}
sequenceLink={(el) => ( sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> <a
href="https://mermaid.js.org/syntax/sequenceDiagram.html"
target="_blank"
rel="noreferrer"
>
{el} {el}
</a> </a>
)} )}
classLink={(el) => ( classLink={(el) => (
<a href="https://mermaid.js.org/syntax/classDiagram.html">{el}</a> <a
href="https://mermaid.js.org/syntax/classDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)}
erdLink={(el) => (
<a
href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html"
target="_blank"
rel="noreferrer"
>
{el}
</a>
)} )}
/> />
</div> </div>
@@ -130,7 +278,8 @@ const MermaidToExcalidraw = ({
<TTDDialogInput <TTDDialogInput
input={text} input={text}
placeholder={t("mermaid.inputPlaceholder")} placeholder={t("mermaid.inputPlaceholder")}
onChange={(event) => setText(event.target.value)} onChange={(value) => setText(value)}
errorLine={errorLine}
onKeyboardSubmit={() => { onKeyboardSubmit={() => {
onInsertToEditor(); onInsertToEditor();
}} }}
@@ -153,6 +302,9 @@ const MermaidToExcalidraw = ({
canvasRef={canvasRef} canvasRef={canvasRef}
loaded={mermaidToExcalidrawLib.loaded} loaded={mermaidToExcalidrawLib.loaded}
error={error} error={error}
sourceText={text}
autoFixAvailable={!!autoFixCandidate}
onApplyAutoFix={onApplyAutoFix}
/> />
</TTDDialogPanel> </TTDDialogPanel>
</TTDDialogPanels> </TTDDialogPanels>
@@ -219,6 +219,49 @@ $fullScreenModalBreakpoint: 600px;
} }
} }
.ttd-dialog-input--loading {
display: flex;
align-items: center;
justify-content: center;
}
.ttd-dialog-input--codemirror {
padding: 0;
overflow: hidden;
// Override height:100% from .ttd-dialog-input use flex sizing
// so the editor fills remaining space without overflowing the panel
height: 0;
flex: 1 1 0;
min-height: 0;
.cm-editor {
height: 100%;
font-family: monospace;
&.cm-focused {
outline: none;
}
}
.cm-scroller {
padding: 0.85rem 0;
overflow: auto;
}
.cm-gutters {
padding-left: 0.25rem;
}
.cm-content {
padding: 0;
}
.cm-placeholder {
color: var(--color-gray-40);
font-style: italic;
}
}
.ttd-dialog-output-wrapper { .ttd-dialog-output-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -331,14 +374,55 @@ $fullScreenModalBreakpoint: 600px;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.ttd-dialog-output-error-summary {
width: 100%;
max-width: 640px;
color: var(--color-gray-50);
font-size: 0.9rem;
text-align: left;
&__headline {
font-weight: 600;
color: var(--color-gray-60);
}
&__label {
margin-top: 0.35rem;
font-weight: 500;
}
&__causes {
margin: 0.35rem 0 0;
padding-left: 2rem;
}
}
.ttd-dialog-output-error-message { .ttd-dialog-output-error-message {
text-align: left; text-align: left;
font-weight: 400; font-weight: 400;
color: var(--color-gray-50); color: var(--color-gray-50);
word-break: break-word; word-break: break-word;
white-space: pre-wrap; white-space: pre-wrap;
max-width: 100%; max-width: 640px;
width: 100%;
font-family: monospace; font-family: monospace;
&__caret {
color: var(--color-danger);
}
}
.ttd-dialog-output-error-autofix-slot {
align-self: flex-start;
margin-top: 0.35rem;
min-height: 2.5rem;
display: flex;
align-items: flex-start;
}
.ttd-dialog-output-error-autofix {
margin-top: 0;
white-space: nowrap;
} }
} }
@@ -1,28 +1,84 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { EVENT, KEYS } from "@excalidraw/common"; import { EVENT, KEYS } from "@excalidraw/common";
import type { ChangeEventHandler } from "react"; import Spinner from "../Spinner";
import { useUIAppState } from "../../context/ui-appState";
import type { ComponentType } from "react";
import type { CodeMirrorEditorProps } from "./CodeMirrorEditor";
interface TTDDialogInputProps { interface TTDDialogInputProps {
input: string; input: string;
placeholder: string; placeholder: string;
onChange: ChangeEventHandler<HTMLTextAreaElement>; onChange: (value: string) => void;
onKeyboardSubmit?: () => void; onKeyboardSubmit?: () => void;
errorLine?: number | null;
} }
type EditorState =
| { type: "loading" }
| { type: "ready"; component: ComponentType<CodeMirrorEditorProps> }
| { type: "fallback" };
const SPINNER_DELAY_MS = 300;
export const TTDDialogInput = ({ export const TTDDialogInput = ({
input, input,
placeholder, placeholder,
onChange, onChange,
onKeyboardSubmit, onKeyboardSubmit,
errorLine,
}: TTDDialogInputProps) => { }: TTDDialogInputProps) => {
const ref = useRef<HTMLTextAreaElement>(null); const ref = useRef<HTMLTextAreaElement>(null);
const callbackRef = useRef(onKeyboardSubmit); const callbackRef = useRef(onKeyboardSubmit);
callbackRef.current = onKeyboardSubmit; callbackRef.current = onKeyboardSubmit;
const [editorState, setEditorState] = useState<EditorState>({
type: "loading",
});
const [showSpinner, setShowSpinner] = useState(false);
const { theme } = useUIAppState();
// Lazy-load CodeMirror editor
useEffect(() => { useEffect(() => {
let cancelled = false;
const spinnerTimer = setTimeout(() => {
if (!cancelled) {
setShowSpinner(true);
}
}, SPINNER_DELAY_MS);
import("./CodeMirrorEditor")
.then((mod) => {
if (!cancelled) {
setEditorState({ type: "ready", component: mod.default });
}
})
.catch(() => {
if (!cancelled) {
setEditorState({ type: "fallback" });
}
})
.finally(() => {
clearTimeout(spinnerTimer);
});
return () => {
cancelled = true;
clearTimeout(spinnerTimer);
};
}, []);
// Keyboard shortcut + focus for textarea fallback
useEffect(() => {
if (editorState.type !== "fallback") {
return;
}
if (!callbackRef.current) { if (!callbackRef.current) {
return; return;
} }
@@ -40,15 +96,42 @@ export const TTDDialogInput = ({
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown); textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
}; };
} }
}, []); }, [editorState.type]);
return ( if (editorState.type === "ready") {
<textarea const CodeMirrorEditor = editorState.component;
className="ttd-dialog-input" return (
onChange={onChange} <CodeMirrorEditor
value={input} value={input}
placeholder={placeholder} onChange={onChange}
ref={ref} onKeyboardSubmit={onKeyboardSubmit}
/> placeholder={placeholder}
); theme={theme}
errorLine={errorLine}
/>
);
}
if (editorState.type === "fallback") {
return (
<textarea
className="ttd-dialog-input"
onChange={(e) => onChange(e.target.value)}
value={input}
placeholder={placeholder}
ref={ref}
/>
);
}
// Loading state
if (showSpinner) {
return (
<div className="ttd-dialog-input ttd-dialog-input--loading">
<Spinner />
</div>
);
}
return null;
}; };
@@ -1,14 +1,24 @@
import clsx from "clsx"; import clsx from "clsx";
import { Button } from "../Button";
import Spinner from "../Spinner"; import Spinner from "../Spinner";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { alertTriangleIcon } from "../icons"; import { alertTriangleIcon } from "../icons";
import {
formatMermaidParseErrorMessage,
getMermaidSyntaxErrorGuidance,
isMermaidCaretLine,
} from "./utils/mermaidError";
interface TTDDialogOutputProps { interface TTDDialogOutputProps {
error: Error | null; error: Error | null;
canvasRef: React.RefObject<HTMLDivElement | null>; canvasRef: React.RefObject<HTMLDivElement | null>;
loaded: boolean; loaded: boolean;
hideErrorDetails?: boolean; hideErrorDetails?: boolean;
sourceText?: string;
autoFixAvailable?: boolean;
onApplyAutoFix?: () => void;
} }
export const TTDDialogOutput = ({ export const TTDDialogOutput = ({
@@ -16,7 +26,24 @@ export const TTDDialogOutput = ({
canvasRef, canvasRef,
loaded, loaded,
hideErrorDetails, hideErrorDetails,
sourceText,
autoFixAvailable,
onApplyAutoFix,
}: TTDDialogOutputProps) => { }: TTDDialogOutputProps) => {
const errorMessage = error
? hideErrorDetails
? t("chat.errors.mermaidParseError")
: formatMermaidParseErrorMessage(error.message)
: null;
const syntaxGuidance =
error && !hideErrorDetails
? getMermaidSyntaxErrorGuidance(error.message, sourceText)
: null;
const showAutoFixButton =
!!autoFixAvailable && !!onApplyAutoFix && !hideErrorDetails;
const errorMessageLines = errorMessage?.split(/\r?\n/) ?? [];
return ( return (
<div <div
className={`ttd-dialog-output-wrapper ${ className={`ttd-dialog-output-wrapper ${
@@ -33,14 +60,48 @@ export const TTDDialogOutput = ({
<div className="ttd-dialog-output-error-icon"> <div className="ttd-dialog-output-error-icon">
{alertTriangleIcon} {alertTriangleIcon}
</div> </div>
<div className="ttd-dialog-output-error-title"> {syntaxGuidance && (
{t("ttd.error")} <div className="ttd-dialog-output-error-summary">
</div> <div className="ttd-dialog-output-error-summary__headline">
{syntaxGuidance.summary}
</div>
<div className="ttd-dialog-output-error-summary__label">
Likely causes:
</div>
<ul className="ttd-dialog-output-error-summary__causes">
{syntaxGuidance.likelyCauses.map((cause) => (
<li key={cause}>{cause}</li>
))}
</ul>
</div>
)}
<div className="ttd-dialog-output-error-message"> <div className="ttd-dialog-output-error-message">
{hideErrorDetails {errorMessageLines.map((line, index) => (
? t("chat.errors.mermaidParseError") <span
: error.message} key={`error-line-${index}`}
className={
isMermaidCaretLine(line)
? "ttd-dialog-output-error-message__caret"
: undefined
}
>
{line}
{index < errorMessageLines.length - 1 ? "\n" : ""}
</span>
))}
</div> </div>
{!hideErrorDetails && (
<div className="ttd-dialog-output-error-autofix-slot">
{showAutoFixButton ? (
<Button
className="ttd-dialog-panel-button ttd-dialog-output-error-autofix"
onSelect={onApplyAutoFix}
>
{t("mermaid.autoFixAvailable")}
</Button>
) : null}
</div>
)}
</div> </div>
</div> </div>
)} )}
@@ -1,4 +1,4 @@
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut"; import { getShortcutKey } from "../../shortcut";
export const TTDDialogSubmitShortcut = () => { export const TTDDialogSubmitShortcut = () => {
return ( return (
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import { convertMermaidToExcalidraw } from "./common";
type ConvertMermaidArgs = Parameters<typeof convertMermaidToExcalidraw>[0];
type ParseMermaidToExcalidraw = Awaited<
ConvertMermaidArgs["mermaidToExcalidrawLib"]["api"]
>["parseMermaidToExcalidraw"];
const createConvertArgs = (
mermaidDefinition: string,
parseMermaidToExcalidraw: ParseMermaidToExcalidraw,
): ConvertMermaidArgs => {
const parent = document.createElement("div");
const canvas = document.createElement("div");
parent.appendChild(canvas);
return {
canvasRef: { current: canvas },
mermaidToExcalidrawLib: {
loaded: true,
api: Promise.resolve({ parseMermaidToExcalidraw }),
},
mermaidDefinition,
setError: vi.fn(),
data: {
current: {
elements: [],
files: null,
},
},
theme: "light",
};
};
describe("convertMermaidToExcalidraw", () => {
it("returns the original parse error when quote-normalized fallback also fails", async () => {
const originalError = new Error("Parse error on line 9: ...");
const fallbackError = new Error("Parse error on line 6: ...");
const parseMermaidToExcalidraw = vi
.fn<ParseMermaidToExcalidraw>()
.mockRejectedValueOnce(originalError)
.mockRejectedValueOnce(fallbackError);
const mermaidDefinition =
'graph TD\nA["One"]\nB["Two"]x\nC["Three"]\nD["Four"]';
const result = await convertMermaidToExcalidraw(
createConvertArgs(mermaidDefinition, parseMermaidToExcalidraw),
);
expect(parseMermaidToExcalidraw).toHaveBeenCalledTimes(2);
expect(parseMermaidToExcalidraw).toHaveBeenNthCalledWith(
1,
mermaidDefinition,
);
expect(parseMermaidToExcalidraw).toHaveBeenNthCalledWith(
2,
mermaidDefinition.replace(/"/g, "'"),
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe(originalError);
}
});
it("does not retry quote normalization when the input has no double quotes", async () => {
const originalError = new Error("Parse error on line 9: ...");
const parseMermaidToExcalidraw = vi
.fn<ParseMermaidToExcalidraw>()
.mockRejectedValueOnce(originalError);
const mermaidDefinition = "graph TD\nA[One]\nB[Two]x";
const result = await convertMermaidToExcalidraw(
createConvertArgs(mermaidDefinition, parseMermaidToExcalidraw),
);
expect(parseMermaidToExcalidraw).toHaveBeenCalledTimes(1);
expect(parseMermaidToExcalidraw).toHaveBeenCalledWith(mermaidDefinition);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe(originalError);
}
});
});
@@ -1,4 +1,12 @@
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common"; import {
DEFAULT_EXPORT_PADDING,
EDITOR_LS_KEYS,
THEME,
} from "@excalidraw/common";
import { convertToExcalidrawElements } from "@excalidraw/element";
import { exportToCanvas } from "@excalidraw/utils";
import type { import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
@@ -6,11 +14,6 @@ import type {
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { EditorLocalStorage } from "../../data/EditorLocalStorage"; import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import {
convertToExcalidrawElements,
exportToCanvas,
THEME,
} from "../../index";
import type { MermaidToExcalidrawLibProps } from "./types"; import type { MermaidToExcalidrawLibProps } from "./types";
@@ -72,18 +75,26 @@ export const convertMermaidToExcalidraw = async ({
const api = await mermaidToExcalidrawLib.api; const api = await mermaidToExcalidrawLib.api;
try { try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
} catch (err: unknown) {
const originalParseError = err as Error;
if (!mermaidDefinition.includes('"')) {
return { success: false, error: originalParseError };
}
try { try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
} catch (err: unknown) {
ret = await api.parseMermaidToExcalidraw( ret = await api.parseMermaidToExcalidraw(
mermaidDefinition.replace(/"/g, "'"), mermaidDefinition.replace(/"/g, "'"),
); );
} catch {
// Keep the original error so line/column references stay aligned with
// the user's unmodified input.
return { success: false, error: originalParseError };
} }
} catch (err: unknown) {
return { success: false, error: err as Error };
} }
const { elements, files } = ret; const { elements, files = {} } = ret;
setError(null); setError(null);
data.current = { data.current = {
@@ -0,0 +1,82 @@
import { StreamLanguage } from "@codemirror/language";
const mermaidStreamParser = StreamLanguage.define({
token(stream) {
// Comments: %%...
if (stream.match(/^%%.*$/)) {
return "comment";
}
// Strings
if (stream.match(/^"(?:[^"\\]|\\.)*"/)) {
return "string";
}
// Diagram type keywords (at start of line or after whitespace)
if (
stream.match(
/^(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|mindmap|journey|gitGraph|timeline|quadrantChart|sankey|xychart)\b/i,
)
) {
return "keyword";
}
// Direction keywords
if (stream.match(/^(TB|TD|BT|RL|LR)\b/)) {
return "keyword";
}
// Keywords
if (
stream.match(
/^(subgraph|end|participant|actor|loop|alt|else|opt|par|critical|break|rect|note|over|activate|deactivate|title|section|class|style|linkStyle|classDef|click)\b/i,
)
) {
return "keyword";
}
// Arrows: -->, ---, -.->, ===>, etc.
if (stream.match(/^[-.=<>|ox]+>/)) {
return "operator";
}
if (stream.match(/^<[-.=<>|ox]+/)) {
return "operator";
}
if (stream.match(/^--+|\.\.+|==+/)) {
return "operator";
}
// Labels in brackets/parens: [text], (text), {text}, ((text)), etc.
if (stream.match(/^[[\](){}|<>]/)) {
return "bracket";
}
// Node IDs (alphanumeric)
if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) {
return "variableName";
}
// Numbers
if (stream.match(/^\d+(\.\d+)?/)) {
return "number";
}
// Punctuation
if (stream.match(/^[,:;]/)) {
return "punctuation";
}
// Skip whitespace
if (stream.eatSpace()) {
return null;
}
// Skip any other character
stream.next();
return null;
},
});
export function mermaidLite() {
return mermaidStreamParser;
}
@@ -1,9 +1,6 @@
import { RequestError } from "@excalidraw/excalidraw/errors"; import { RequestError } from "../../../errors";
import type { import type { LLMMessage, TTTDDialog } from "../types";
LLMMessage,
TTTDDialog,
} from "@excalidraw/excalidraw/components/TTDDialog/types";
interface RateLimitInfo { interface RateLimitInfo {
rateLimit?: number; rateLimit?: number;
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { getMermaidAutoFixCandidates } from "./mermaidAutoFix";
describe("getMermaidAutoFixCandidates", () => {
it("suggests removing trailing token after a closed label shape", () => {
const sourceText = `graph TD
L3_TCP["TCP (Transmission Control Protocol)"]x
L3_UDP["UDP (User Datagram Protocol)"]`;
const errorMessage = `Parse error on line 2:
...ission Control Protocol)"]x
-----------------------------^
Expecting 'SEMI', got 'NODE_STRING'`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`graph TD
L3_TCP["TCP (Transmission Control Protocol)"]
L3_UDP["UDP (User Datagram Protocol)"]`);
});
it("suggests appending missing end statements", () => {
const sourceText = `graph TD
subgraph A
A1[Start]`;
const errorMessage = `Parse error on line 3:
... A1[Start]
-------------^
Expecting 'end'`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`graph TD
subgraph A
A1[Start]
end`);
});
it("returns empty list for non-parse errors", () => {
expect(
getMermaidAutoFixCandidates("graph TD\nA-->B", "Network error"),
).toEqual([]);
});
it("extracts line index from lexical error format too", () => {
const sourceText = `graph TD
subgraph Layers["X"]x
direction TB`;
const errorMessage = `Lexical error on line 2. Unrecognized text.
... subgraph Layers["X"]x direction
-----------------------^`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`graph TD
subgraph Layers["X"]
direction TB`);
});
it("removes extra > after edge label", () => {
const sourceText = `flowchart TD
A["User Input"] -->|text|> B["Tokenization"]
A["User Input"] -->|text|> B["Tokenization"]`;
const errorMessage = `Parse error on line 2:
...A["User Input"] -->|text|> B["Tokenization"]
---------------------------^
Expecting 'NODE_STRING', got 'GT'`;
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`flowchart TD
A["User Input"] -->|text| B["Tokenization"]
A["User Input"] -->|text| B["Tokenization"]`);
});
it("suggests removing the last invalid deactivate for participant errors", () => {
const sourceText = `sequenceDiagram
participant QAEngineer as QA
activate QA
QA->>QA: Verifies Fix
deactivate QA
QA->>QA: Verifies Again
deactivate QA`;
const errorMessage = "Trying to inactivate an inactive participant (QA)";
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`sequenceDiagram
participant QAEngineer as QA
activate QA
QA->>QA: Verifies Fix
deactivate QA
QA->>QA: Verifies Again`);
});
it("adds a fallback candidate that removes all invalid deactivations", () => {
const sourceText = `sequenceDiagram
participant QAEngineer as QA
deactivate QA
QA->>QA: Verifies Fix
deactivate QA`;
const errorMessage = "Trying to inactivate an inactive participant (QA)";
const candidates = getMermaidAutoFixCandidates(sourceText, errorMessage);
expect(candidates).toContain(`sequenceDiagram
participant QAEngineer as QA
QA->>QA: Verifies Fix`);
});
});
@@ -0,0 +1,175 @@
import {
getMermaidErrorLineNumber,
getMermaidInactiveParticipant,
isMermaidAutoFixableError,
isMermaidParseSyntaxError,
} from "./mermaidError";
const getErrorLineIndex = (message: string, sourceText: string) => {
const lineNumber = getMermaidErrorLineNumber(message, sourceText);
if (lineNumber == null) {
return null;
}
return lineNumber - 1;
};
const replaceLineAt = (
lines: string[],
index: number,
transform: (line: string) => string,
) => {
if (index < 0 || index >= lines.length) {
return null;
}
const nextLine = transform(lines[index]);
if (nextLine === lines[index]) {
return null;
}
const nextLines = [...lines];
nextLines[index] = nextLine;
return nextLines.join("\n");
};
const stripTrailingTokenAfterShape = (line: string) => {
const alphaTailMatch = line.match(
/^(.*(?:\[[^\]]*]|\([^)]*\)|\{[^}]*}|"(?:[^"]*)"|'(?:[^']*)'))([A-Za-z]+)\s*$/,
);
if (alphaTailMatch) {
return alphaTailMatch[1];
}
const punctuationTailMatch = line.match(
/^(.*(?:\[[^\]]*]|\([^)]*\)|\{[^}]*}|"(?:[^"]*)"|'(?:[^']*)'))([,;:])\s*$/,
);
if (punctuationTailMatch) {
return punctuationTailMatch[1];
}
return line;
};
const removeExtraArrowheadAfterEdgeLabel = (line: string) => {
// Common typo in generated Mermaid: `-->|label|> Target` (extra `>`).
// Convert it to `-->|label| Target`.
return line.replace(/(\|[^|\n]+\|)\s*>\s*(?=[A-Za-z0-9_("[{'`])/g, "$1 ");
};
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const removeLastDeactivateForParticipant = (
sourceText: string,
participant: string,
) => {
const pattern = new RegExp(
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
);
const lines = sourceText.split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index--) {
if (pattern.test(lines[index])) {
return lines.filter((_, lineIndex) => lineIndex !== index).join("\n");
}
}
return null;
};
const removeAllDeactivateForParticipant = (
sourceText: string,
participant: string,
) => {
const pattern = new RegExp(
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
);
const lines = sourceText.split(/\r?\n/);
let removedAny = false;
const remainingLines = lines.filter((line) => {
if (!pattern.test(line)) {
return true;
}
removedAny = true;
return false;
});
return removedAny ? remainingLines.join("\n") : null;
};
const appendMissingEnds = (sourceText: string) => {
const subgraphCount = (sourceText.match(/^\s*subgraph\b/gm) || []).length;
const endCount = (sourceText.match(/^\s*end\s*$/gm) || []).length;
const missingCount = subgraphCount - endCount;
if (missingCount <= 0) {
return null;
}
const endings = Array.from({ length: missingCount }, () => "end").join("\n");
return `${sourceText.trimEnd()}\n${endings}`;
};
const normalizeSmartQuotes = (sourceText: string) =>
sourceText.replace(/[“”]/g, '"').replace(/[‘’]/g, "'");
export const getMermaidAutoFixCandidates = (
sourceText: string,
errorMessage: string,
) => {
if (!isMermaidAutoFixableError(errorMessage) || !sourceText.trim()) {
return [];
}
const candidates: string[] = [];
const seen = new Set<string>();
const addCandidate = (candidate: string | null) => {
if (!candidate || candidate === sourceText || seen.has(candidate)) {
return;
}
seen.add(candidate);
candidates.push(candidate);
};
const inactiveParticipant = getMermaidInactiveParticipant(errorMessage);
if (inactiveParticipant) {
addCandidate(
removeLastDeactivateForParticipant(sourceText, inactiveParticipant),
);
// Fallback for repeated invalid inactivations in one diagram.
addCandidate(
removeAllDeactivateForParticipant(sourceText, inactiveParticipant),
);
}
if (isMermaidParseSyntaxError(errorMessage)) {
const lines = sourceText.split(/\r?\n/);
const errorLineIndex = getErrorLineIndex(errorMessage, sourceText);
const lineIndexesToTry =
errorLineIndex == null
? []
: [errorLineIndex, errorLineIndex - 1, errorLineIndex + 1];
for (const lineIndex of lineIndexesToTry) {
addCandidate(
replaceLineAt(lines, lineIndex, (line) =>
stripTrailingTokenAfterShape(line),
),
);
addCandidate(
replaceLineAt(lines, lineIndex, (line) =>
removeExtraArrowheadAfterEdgeLabel(line),
),
);
}
// Also try full-text replacement so repeated occurrences on other lines
// are fixed together in a single candidate.
addCandidate(removeExtraArrowheadAfterEdgeLabel(sourceText));
addCandidate(appendMissingEnds(sourceText));
const normalizedQuotes = normalizeSmartQuotes(sourceText);
addCandidate(normalizedQuotes === sourceText ? null : normalizedQuotes);
}
return candidates;
};
@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import {
formatMermaidParseErrorMessage,
getMermaidErrorLineNumber,
getMermaidInactiveParticipant,
getMermaidSyntaxErrorGuidance,
isMermaidAutoFixableError,
isMermaidParseSyntaxError,
isMermaidCaretLine,
} from "./mermaidError";
describe("formatMermaidParseErrorMessage", () => {
it("strips the noisy Expecting clause from Mermaid parse errors", () => {
const message = `Parse error on line 6:
... Control Protocol)"]x L3_UDP
----------------------^
Expecting 'SEMI', 'NEWLINE', 'SPACE', got 'NODE_STRING'`;
expect(formatMermaidParseErrorMessage(message)).toBe(`Parse error on line 6:
... Control Protocol)"]x L3_UDP
----------------------^`);
});
it("keeps Mermaid parse errors unchanged when no Expecting clause exists", () => {
const message = `Parse error on line 3:
... some snippet
----^`;
expect(formatMermaidParseErrorMessage(message)).toBe(message);
});
it("does not modify non-Mermaid parse messages", () => {
const message =
"Unexpected token while parsing JSON. Expecting value at position 10.";
expect(formatMermaidParseErrorMessage(message)).toBe(message);
});
});
describe("isMermaidCaretLine", () => {
it("returns true for Mermaid caret lines", () => {
expect(isMermaidCaretLine("-----------------------^")).toBe(true);
});
it("returns false for regular lines", () => {
expect(isMermaidCaretLine(`... Control Protocol)"]x`)).toBe(false);
});
});
describe("isMermaidParseSyntaxError", () => {
it("returns true for Mermaid parser syntax errors", () => {
expect(isMermaidParseSyntaxError("Parse error on line 6: ...")).toBe(true);
});
it("returns true for Mermaid lexical syntax errors", () => {
expect(
isMermaidParseSyntaxError("Lexical error on line 2. Unrecognized text."),
).toBe(true);
});
it("returns false for non-parse errors", () => {
expect(isMermaidParseSyntaxError("Network error")).toBe(false);
});
});
describe("isMermaidAutoFixableError", () => {
it("returns true for Mermaid parser syntax errors", () => {
expect(isMermaidAutoFixableError("Parse error on line 6: ...")).toBe(true);
});
it("returns true for inactive participant runtime errors", () => {
expect(
isMermaidAutoFixableError(
"Trying to inactivate an inactive participant (QA)",
),
).toBe(true);
});
it("returns false for non-fixable errors", () => {
expect(isMermaidAutoFixableError("Network error")).toBe(false);
});
});
describe("getMermaidInactiveParticipant", () => {
it("extracts the participant id from inactive participant errors", () => {
expect(
getMermaidInactiveParticipant(
"Trying to inactivate an inactive participant (QA)",
),
).toBe("QA");
});
it("returns null for unrelated errors", () => {
expect(
getMermaidInactiveParticipant("Parse error on line 3: ..."),
).toBeNull();
});
});
describe("getMermaidErrorLineNumber", () => {
it("extracts line number from parse error format", () => {
expect(getMermaidErrorLineNumber("Parse error on line 6: ...")).toBe(6);
});
it("extracts line number from lexical error format", () => {
expect(
getMermaidErrorLineNumber("Lexical error on line 2. Unrecognized text."),
).toBe(2);
});
it("returns null for messages without Mermaid line details", () => {
expect(getMermaidErrorLineNumber("Network error")).toBeNull();
});
it("infers line from inactive participant errors when source text is provided", () => {
const sourceText = `sequenceDiagram
participant QA
deactivate QA
QA->>QA: Verifies Fix
deactivate QA`;
expect(
getMermaidErrorLineNumber(
"Trying to inactivate an inactive participant (QA)",
sourceText,
),
).toBe(5);
});
});
describe("getMermaidSyntaxErrorGuidance", () => {
it("returns summary and likely causes for Mermaid parse errors", () => {
const message = `Parse error on line 6:
... Control Protocol)"]x
----------------------^`;
const source = `graph TD
subgraph Layers["X"]
L3_TCP["TCP (Transmission Control Protocol)"]x`;
expect(getMermaidSyntaxErrorGuidance(message, source)).toEqual({
summary: "Syntax error near line 6.",
likelyCauses: expect.arrayContaining([
"A block is missing an `end` statement.",
]),
});
});
it("returns null for non-parse errors", () => {
expect(
getMermaidSyntaxErrorGuidance("Network error", "graph TD"),
).toBeNull();
});
});
@@ -0,0 +1,133 @@
const MERMAID_SYNTAX_ERROR_LINE = /(?:Parse|Lexical) error on line (\d+)[.:]/i;
const MERMAID_INACTIVE_PARTICIPANT_ERROR =
/Trying to inactivate an inactive participant \((.+)\)/i;
const MERMAID_CARET_LINE = /^\s*-+\^\s*$/;
export const isMermaidParseSyntaxError = (message: string) =>
MERMAID_SYNTAX_ERROR_LINE.test(message);
export const isMermaidAutoFixableError = (message: string) =>
isMermaidParseSyntaxError(message) ||
MERMAID_INACTIVE_PARTICIPANT_ERROR.test(message);
export const isMermaidCaretLine = (line: string) =>
MERMAID_CARET_LINE.test(line);
export const getMermaidInactiveParticipant = (
message: string,
): string | null => {
const match = message.match(MERMAID_INACTIVE_PARTICIPANT_ERROR);
if (!match?.[1]) {
return null;
}
return match[1].trim();
};
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const getInactiveParticipantLineNumber = (
message: string,
sourceText: string,
): number | null => {
const participant = getMermaidInactiveParticipant(message);
if (!participant) {
return null;
}
const deactivatePattern = new RegExp(
`^\\s*deactivate\\s+${escapeRegExp(participant)}(?:\\s+%%.*)?\\s*$`,
);
const lines = sourceText.split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index--) {
if (deactivatePattern.test(lines[index])) {
return index + 1;
}
}
return null;
};
export const getMermaidErrorLineNumber = (
message: string,
sourceText?: string,
): number | null => {
const match = message.match(MERMAID_SYNTAX_ERROR_LINE);
if (!match) {
if (!sourceText) {
return null;
}
return getInactiveParticipantLineNumber(message, sourceText);
}
return Number.parseInt(match[1], 10);
};
const countMatches = (text: string, re: RegExp) =>
(text.match(re) || []).length;
export const getMermaidSyntaxErrorGuidance = (
message: string,
sourceText?: string,
): { summary: string; likelyCauses: string[] } | null => {
if (!isMermaidParseSyntaxError(message)) {
return null;
}
const errorLine = getMermaidErrorLineNumber(message, sourceText);
const summary = errorLine
? `Syntax error near line ${errorLine}.`
: "Syntax error in Mermaid diagram.";
const likelyCauses: string[] = [];
if (sourceText) {
const openBrackets = countMatches(sourceText, /\[/g);
const closeBrackets = countMatches(sourceText, /\]/g);
if (openBrackets !== closeBrackets) {
likelyCauses.push("Unbalanced square brackets in a node label.");
}
const openParens = countMatches(sourceText, /\(/g);
const closeParens = countMatches(sourceText, /\)/g);
if (openParens !== closeParens) {
likelyCauses.push("Unbalanced parentheses in a node shape.");
}
const openBraces = countMatches(sourceText, /\{/g);
const closeBraces = countMatches(sourceText, /\}/g);
if (openBraces !== closeBraces) {
likelyCauses.push("Unbalanced braces in a decision node.");
}
const subgraphCount = countMatches(sourceText, /^\s*subgraph\b/gm);
const endCount = countMatches(sourceText, /^\s*end\s*$/gm);
if (subgraphCount > endCount) {
likelyCauses.push("A block is missing an `end` statement.");
}
}
if (/got 'NODE_STRING'/.test(message) || /got 'PS'/.test(message)) {
likelyCauses.push(
"An extra character/token may appear after a node or label definition.",
);
}
if (likelyCauses.length === 0) {
likelyCauses.push(
"A node or edge line is malformed (missing/extra delimiters).",
);
likelyCauses.push("A block (`subgraph`, `class`, etc.) may be incomplete.");
}
return {
summary,
likelyCauses: [...new Set(likelyCauses)],
};
};
export const formatMermaidParseErrorMessage = (message: string) => {
if (!isMermaidParseSyntaxError(message)) {
return message;
}
return message.replace(/\n\s*Expecting[\s\S]*$/, "").trimEnd();
};
+31 -15
View File
@@ -1,35 +1,51 @@
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.Toast { .Toast {
$closeButtonSize: 1.2rem; $closeButtonSize: 1.2rem;
$closeButtonPadding: 0.4rem; $closeButtonPadding: 0.4rem;
animation: fade-in 0.5s; animation: Toast-fade-in 0.5s;
background-color: var(--button-gray-1); min-width: 220px;
border-radius: 4px; max-width: min(360px, calc(100vw - 32px));
bottom: 10px; border-radius: var(--border-radius-lg);
border: 1px solid var(--default-border-color);
background-color: var(--island-bg-color);
color: var(--text-primary-color);
padding: 0.5rem 0.75rem;
box-shadow: 0 0 0 1px var(--color-surface-lowest);
box-sizing: border-box; box-sizing: border-box;
cursor: default; cursor: default;
left: 50%; pointer-events: none;
margin-left: -150px;
padding: 4px 0;
position: absolute;
text-align: center;
width: 300px;
z-index: 999999;
.Toast__message { .Toast__message {
font-family: var(--ui-font);
font-size: 0.75rem;
line-height: 1.25rem;
text-align: center;
padding: 0 $closeButtonSize + ($closeButtonPadding); padding: 0 $closeButtonSize + ($closeButtonPadding);
color: var(--popup-text-color);
white-space: pre-wrap; white-space: pre-wrap;
} }
.Toast__progress-bar {
margin-top: 0.35rem;
width: 100%;
height: 4px;
border-radius: 999px;
background-color: var(--button-gray-2);
overflow: hidden;
}
.Toast__progress-bar-fill {
height: 100%;
border-radius: inherit;
background-color: var(--color-primary);
}
.close { .close {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
padding: $closeButtonPadding; padding: $closeButtonPadding;
pointer-events: auto;
.ToolIcon__icon { .ToolIcon__icon {
width: $closeButtonSize; width: $closeButtonSize;
@@ -38,7 +54,7 @@
} }
} }
@keyframes fade-in { @keyframes Toast-fade-in {
from { from {
opacity: 0; opacity: 0;
} }
+18 -4
View File
@@ -5,11 +5,22 @@ import { ToolButton } from "./ToolButton";
import "./Toast.scss"; import "./Toast.scss";
import type { CSSProperties } from "react"; import type { CSSProperties, ReactNode } from "react";
const DEFAULT_TOAST_TIMEOUT = 5000; const DEFAULT_TOAST_TIMEOUT = 5000;
export const Toast = ({ const ProgressBar = ({ progress }: { progress: number }) => (
<div className="Toast__progress-bar">
<div
className="Toast__progress-bar-fill"
style={{
width: `${Math.min(5, Math.round(progress * 100))}%`,
}}
/>
</div>
);
const ToastComponent = ({
message, message,
onClose, onClose,
closable = false, closable = false,
@@ -17,7 +28,7 @@ export const Toast = ({
duration = DEFAULT_TOAST_TIMEOUT, duration = DEFAULT_TOAST_TIMEOUT,
style, style,
}: { }: {
message: string; message: ReactNode;
onClose: () => void; onClose: () => void;
closable?: boolean; closable?: boolean;
duration?: number; duration?: number;
@@ -47,11 +58,12 @@ export const Toast = ({
return ( return (
<div <div
className="Toast" className="Toast"
role="status"
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
style={style} style={style}
> >
<p className="Toast__message">{message}</p> <div className="Toast__message">{message}</div>
{closable && ( {closable && (
<ToolButton <ToolButton
icon={CloseIcon} icon={CloseIcon}
@@ -64,3 +76,5 @@ export const Toast = ({
</div> </div>
); );
}; };
export const Toast = Object.assign(ToastComponent, { ProgressBar });
@@ -6,7 +6,6 @@ import {
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
type EditorInterface, type EditorInterface,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
import type { import type {
InteractiveCanvasRenderConfig, InteractiveCanvasRenderConfig,
@@ -24,6 +23,8 @@ import type {
import { t } from "../../i18n"; import { t } from "../../i18n";
import { renderInteractiveScene } from "../../renderer/interactiveScene"; import { renderInteractiveScene } from "../../renderer/interactiveScene";
import { AnimationController } from "../../renderer/animation";
import type { import type {
AppClassProperties, AppClassProperties,
AppState, AppState,
@@ -53,6 +54,7 @@ type InteractiveCanvasProps = {
DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"], DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"],
undefined undefined
>; >;
onClick: Exclude<DOMAttributes<HTMLCanvasElement>["onClick"], undefined>;
onPointerMove: Exclude< onPointerMove: Exclude<
DOMAttributes<HTMLCanvasElement>["onPointerMove"], DOMAttributes<HTMLCanvasElement>["onPointerMove"],
undefined undefined
@@ -212,6 +214,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
height={props.appState.height * props.scale} height={props.appState.height * props.scale}
ref={props.handleCanvasRef} ref={props.handleCanvasRef}
onContextMenu={props.onContextMenu} onContextMenu={props.onContextMenu}
onClick={props.onClick}
onPointerMove={props.onPointerMove} onPointerMove={props.onPointerMove}
onPointerUp={props.onPointerUp} onPointerUp={props.onPointerUp}
onPointerCancel={props.onPointerCancel} onPointerCancel={props.onPointerCancel}
+135 -76
View File
@@ -69,6 +69,11 @@ const modifiedTablerIconProps: Opts = {
strokeLinejoin: "round", strokeLinejoin: "round",
} as const; } as const;
const arrowheadPreviewIconProps: Opts = {
width: 40,
height: 20,
} as const;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// tabler-icons: present // tabler-icons: present
@@ -1291,16 +1296,17 @@ export const ArrowheadNoneIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
transform={flip ? "translate(24, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
stroke="currentColor" stroke="currentColor"
opacity={0.3} opacity={0.3}
strokeWidth={2} strokeWidth={2}
fill="none"
strokeLinecap="round"
> >
<path d="M12 12l-9 0" /> <path d="M7,11 H19" />
<path d="M21 9l-6 6" /> <path d="M25,6 L33,16 M33,6 L25,16" />
<path d="M21 15l-6 -6" />
</g>, </g>,
tablerIconProps, arrowheadPreviewIconProps,
), ),
); );
@@ -1312,57 +1318,12 @@ export const ArrowheadArrowIcon = React.memo(
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={2}
fill="none" fill="none"
strokeLinecap="round"
strokeLinejoin="round"
> >
<path d="M34 10H6M34 10L27 5M34 10L27 15" /> <path d="M7,11 H33 M23,5 L33,11 L23,17" />
<path d="M27.5 5L34.5 10L27.5 15" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
),
);
export const ArrowheadCircleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M32 10L6 10" strokeWidth={2} />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadCircleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
>
<path d="M26 10L6 10" />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadBarIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
<path
d="M34 10H5.99996M34 10L34 5M34 10L34 15"
stroke="currentColor"
strokeWidth={2}
fill="none"
/>
</g>,
{ width: 40, height: 20 },
), ),
); );
@@ -1373,11 +1334,12 @@ export const ArrowheadTriangleIcon = React.memo(
stroke="currentColor" stroke="currentColor"
fill="currentColor" fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round"
> >
<path d="M32 10L6 10" strokeWidth={2} /> <path d="M7,11 H23" strokeWidth={2} strokeLinecap="round" />
<path d="M27.5 5.5L34.5 10L27.5 14.5L27.5 5.5" /> <path d="M23,5 L35,11 L23,17 Z" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
@@ -1390,12 +1352,43 @@ export const ArrowheadTriangleOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2} strokeWidth={2}
strokeLinejoin="round" strokeLinejoin="round"
strokeLinecap="round"
> >
<path d="M6,9.5H27" /> <path d="M7,11 H23" />
<path d="M27,5L34,10L27,14Z" fill="none" /> <path d="M23,5 L35,11 L23,17 Z" />
</g>, </g>,
arrowheadPreviewIconProps,
),
);
{ width: 40, height: 20 }, export const ArrowheadCircleIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M7,11 H25" strokeWidth={2} strokeLinecap="round" />
<circle cx="29" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCircleOutlineIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeWidth={2}
>
<path d="M7,11 H25" strokeLinecap="round" />
<circle cx="29" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
), ),
); );
@@ -1407,12 +1400,11 @@ export const ArrowheadDiamondIcon = React.memo(
fill="currentColor" fill="currentColor"
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2}
> >
<path d="M6,9.5H20" /> <path d="M7,11 H21" strokeWidth={2} strokeLinecap="round" />
<path d="M27,5L34,10L27,14L20,9.5Z" /> <path d="M21,11 L28,5 L35,11 L28,17 Z" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
@@ -1425,15 +1417,32 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""} transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
strokeLinecap="round"
> >
<path d="M6,9.5H20" /> <path d="M7,11 H21" />
<path d="M27,5L34,10L27,14L20,9.5Z" /> <path d="M21,11 L28,5 L35,11 L28,17 Z" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
export const ArrowheadCrowfootIcon = React.memo( export const ArrowheadBarIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
>
<path d="M11,11 H31 M31,5 V17" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1443,13 +1452,13 @@ export const ArrowheadCrowfootIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" /> <path d="M35,11 H7 M15,5 V17" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
export const ArrowheadCrowfootOneIcon = React.memo( export const ArrowheadCardinalityManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1459,13 +1468,13 @@ export const ArrowheadCrowfootOneIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M34,10 H6 M15,10 L15,15 L15,5" /> <path d="M35,11 H7 M15,11 L7,5 M15,11 L7,17" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
), ),
); );
export const ArrowheadCrowfootOneOrManyIcon = React.memo( export const ArrowheadCardinalityOneOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) => ({ flip = false }: { flip?: boolean }) =>
createIcon( createIcon(
<g <g
@@ -1475,9 +1484,59 @@ export const ArrowheadCrowfootOneOrManyIcon = React.memo(
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
> >
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" /> <path d="M35,11 H7 M23,5 V17 M15,11 L7,5 M15,11 L7,17" />
</g>, </g>,
{ width: 40, height: 20 }, arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityExactlyOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H7 M15,5 V17 M7,5 V17" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityZeroOrOneIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H19 M11,11 H7 M7,5 V17" />
<circle cx="15" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
),
);
export const ArrowheadCardinalityZeroOrManyIcon = React.memo(
({ flip = false }: { flip?: boolean }) =>
createIcon(
<g
stroke="currentColor"
fill="none"
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M35,11 H27 M19,11 H7 M15,11 L7,5 M15,11 L7,17" />
<circle cx="23" cy="11" r="4" />
</g>,
arrowheadPreviewIconProps,
), ),
); );
@@ -469,7 +469,6 @@ const PreferencesToggleMidpointSnappingItem = () => {
return ( return (
<DropdownMenuItemCheckbox <DropdownMenuItemCheckbox
checked={appState.isMidpointSnappingEnabled} checked={appState.isMidpointSnappingEnabled}
disabled={appState.bindingPreference === "disabled"}
onSelect={(event) => { onSelect={(event) => {
actionManager.executeAction(actionToggleMidpointSnapping); actionManager.executeAction(actionToggleMidpointSnapping);
event.preventDefault(); event.preventDefault();
+20
View File
@@ -500,6 +500,26 @@ body.excalidraw-cursor-resize * {
} }
} }
.floating-status-stack {
position: absolute;
left: 50%;
bottom: 30px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
pointer-events: none;
.scroll-back-to-content {
position: static;
left: auto;
bottom: auto;
transform: none;
pointer-events: var(--ui-pointerEvents);
}
}
.help-icon { .help-icon {
@include outlineButtonStyles; @include outlineButtonStyles;
@include filledButtonOnCanvas; @include filledButtonOnCanvas;
+4 -6
View File
@@ -34,9 +34,10 @@
--popup-text-color: #000; --popup-text-color: #000;
--popup-text-inverted-color: #fff; --popup-text-inverted-color: #fff;
--select-highlight-color: #{$color-blue-5}; --select-highlight-color: #{$color-blue-5};
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17), --shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08), 0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgba(0, 0, 0, 0.05);
0px 7px 14px 0px rgba(0, 0, 0, 0.05); --shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08), 0px 7px 14px 0px rgb(0 0 0 / 18%);
--button-hover-bg: var(--color-surface-high); --button-hover-bg: var(--color-surface-high);
--button-active-bg: var(--color-surface-high); --button-active-bg: var(--color-surface-high);
@@ -210,9 +211,6 @@
--popup-text-color: #{$color-gray-4}; --popup-text-color: #{$color-gray-4};
--popup-text-inverted-color: #2c2c2c; --popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$color-blue-4}; --select-highlight-color: #{$color-blue-4};
--shadow-island: 0px 0px 0.9310142993927002px 0px rgba(0, 0, 0, 0.17),
0px 0px 3.1270833015441895px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07), --modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198), 0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
+10 -9
View File
@@ -27,7 +27,6 @@ import {
import type { AppState, DataURL, LibraryItem } from "../types"; import type { AppState, DataURL, LibraryItem } from "../types";
import type { FileSystemHandle } from "browser-fs-access";
import type { ImportedLibraryData } from "./types"; import type { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File): Promise<string> => { const parseFileContents = async (blob: Blob | File): Promise<string> => {
@@ -104,7 +103,7 @@ export const getMimeType = (blob: Blob | string): string => {
return ""; return "";
}; };
export const getFileHandleType = (handle: FileSystemHandle | null) => { export const getFileHandleType = (handle: FileSystemFileHandle | null) => {
if (!handle) { if (!handle) {
return null; return null;
} }
@@ -118,7 +117,9 @@ export const isImageFileHandleType = (
return type === "png" || type === "svg"; return type === "png" || type === "svg";
}; };
export const isImageFileHandle = (handle: FileSystemHandle | null) => { export const isImageFileHandle = (
handle: FileSystemFileHandle | null,
): handle is FileSystemFileHandle => {
const type = getFileHandleType(handle); const type = getFileHandleType(handle);
return type === "png" || type === "svg"; return type === "png" || type === "svg";
}; };
@@ -139,8 +140,8 @@ export const loadSceneOrLibraryFromBlob = async (
/** @see restore.localAppState */ /** @see restore.localAppState */
localAppState: AppState | null, localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null, localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */ /** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null, fileHandle?: FileSystemFileHandle | null,
) => { ) => {
const contents = await parseFileContents(blob); const contents = await parseFileContents(blob);
let data; let data;
@@ -198,8 +199,8 @@ export const loadFromBlob = async (
/** @see restore.localAppState */ /** @see restore.localAppState */
localAppState: AppState | null, localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null, localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */ /** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null, fileHandle?: FileSystemFileHandle | null,
) => { ) => {
const ret = await loadSceneOrLibraryFromBlob( const ret = await loadSceneOrLibraryFromBlob(
blob, blob,
@@ -392,7 +393,7 @@ export const ImageURLToFile = async (
export const getFileHandle = async ( export const getFileHandle = async (
event: DragEvent | React.DragEvent | DataTransferItem, event: DragEvent | React.DragEvent | DataTransferItem,
): Promise<FileSystemHandle | null> => { ): Promise<FileSystemFileHandle | null> => {
if (nativeFileSystemSupported) { if (nativeFileSystemSupported) {
try { try {
const dataTransferItem = const dataTransferItem =
@@ -400,7 +401,7 @@ export const getFileHandle = async (
? event ? event
: (event as DragEvent).dataTransfer?.items?.[0]; : (event as DragEvent).dataTransfer?.items?.[0];
const handle: FileSystemHandle | null = const handle: FileSystemFileHandle | null =
(await (dataTransferItem as any).getAsFileSystemHandle()) || null; (await (dataTransferItem as any).getAsFileSystemHandle()) || null;
return handle; return handle;
+4 -44
View File
@@ -4,18 +4,12 @@ import {
supported as nativeFileSystemSupported, supported as nativeFileSystemSupported,
} from "browser-fs-access"; } from "browser-fs-access";
import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common"; import { MIME_TYPES } from "@excalidraw/common";
import { AbortError } from "../errors";
import { normalizeFile } from "./blob"; import { normalizeFile } from "./blob";
import type { FileSystemHandle } from "browser-fs-access";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 5000;
export const fileOpen = async <M extends boolean | undefined = false>(opts: { export const fileOpen = async <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[]; extensions?: FILE_EXTENSION[];
description: string; description: string;
@@ -42,40 +36,6 @@ export const fileOpen = async <M extends boolean | undefined = false>(opts: {
extensions, extensions,
mimeTypes, mimeTypes,
multiple: opts.multiple ?? false, multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener(EVENT.KEYUP, scheduleRejection);
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (input.files?.length) {
const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType);
}
};
requestAnimationFrame(() => {
window.addEventListener(EVENT.FOCUS, focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
scheduleRejection.cancel();
window.removeEventListener(EVENT.FOCUS, focusHandler);
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new AbortError());
}
};
},
}); });
if (Array.isArray(files)) { if (Array.isArray(files)) {
@@ -95,8 +55,8 @@ export const fileSave = (
extension: FILE_EXTENSION; extension: FILE_EXTENSION;
mimeTypes?: string[]; mimeTypes?: string[];
description: string; description: string;
/** existing FileSystemHandle */ /** existing FileSystemFileHandle */
fileHandle?: FileSystemHandle | null; fileHandle?: FileSystemFileHandle | null;
}, },
) => { ) => {
return _fileSave( return _fileSave(
@@ -108,8 +68,8 @@ export const fileSave = (
mimeTypes: opts.mimeTypes, mimeTypes: opts.mimeTypes,
}, },
opts.fileHandle, opts.fileHandle,
false,
); );
}; };
export { nativeFileSystemSupported }; export { nativeFileSystemSupported };
export type { FileSystemHandle };
+1 -3
View File
@@ -33,8 +33,6 @@ import { canvasToBlob } from "./blob";
import { fileSave } from "./filesystem"; import { fileSave } from "./filesystem";
import { serializeAsJSON } from "./json"; import { serializeAsJSON } from "./json";
import type { FileSystemHandle } from "./filesystem";
import type { ExportType } from "../scene/types"; import type { ExportType } from "../scene/types";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles } from "../types";
@@ -110,7 +108,7 @@ export const exportCanvas = async (
viewBackgroundColor: string; viewBackgroundColor: string;
/** filename, if applicable */ /** filename, if applicable */
name?: string; name?: string;
fileHandle?: FileSystemHandle | null; fileHandle?: FileSystemFileHandle | null;
exportingFrame: ExcalidrawFrameLikeElement | null; exportingFrame: ExcalidrawFrameLikeElement | null;
}, },
) => { ) => {
+27 -18
View File
@@ -1,12 +1,13 @@
import { import {
DEFAULT_FILENAME,
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
getExportSource, getExportSource,
MIME_TYPES, MIME_TYPES,
VERSIONS, VERSIONS,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement, NonDeleted } from "@excalidraw/element/types";
import type { MaybePromise } from "@excalidraw/common/utility-types";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
@@ -21,6 +22,12 @@ import type {
ImportedLibraryData, ImportedLibraryData,
} from "./types"; } from "./types";
export type JSONExportData = {
elements: readonly NonDeleted<ExcalidrawElement>[];
appState: AppState;
files: BinaryFiles;
};
/** /**
* Strips out files which are only referenced by deleted elements * Strips out files which are only referenced by deleted elements
*/ */
@@ -67,27 +74,29 @@ export const serializeAsJSON = (
return JSON.stringify(data, null, 2); return JSON.stringify(data, null, 2);
}; };
export const saveAsJSON = async ( export const saveAsJSON = async ({
elements: readonly ExcalidrawElement[], data,
appState: AppState, filename,
files: BinaryFiles, fileHandle,
/** filename */ }: {
name: string = appState.name || DEFAULT_FILENAME, data: MaybePromise<JSONExportData>;
) => { filename: string;
const serialized = serializeAsJSON(elements, appState, files, "local"); fileHandle: AppState["fileHandle"];
const blob = new Blob([serialized], { }) => {
type: MIME_TYPES.excalidraw, const blob = Promise.resolve(data).then(({ elements, appState, files }) => {
const serialized = serializeAsJSON(elements, appState, files, "local");
return new Blob([serialized], {
type: MIME_TYPES.excalidraw,
});
}); });
const fileHandle = await fileSave(blob, { const savedFileHandle = await fileSave(blob, {
name, name: filename,
extension: "excalidraw", extension: "excalidraw",
description: "Excalidraw file", description: "Excalidraw file",
fileHandle: isImageFileHandle(appState.fileHandle) fileHandle: isImageFileHandle(fileHandle) ? null : fileHandle,
? null
: appState.fileHandle,
}); });
return { fileHandle }; return { fileHandle: savedFileHandle };
}; };
export const loadFromJSON = async ( export const loadFromJSON = async (
+17 -8
View File
@@ -1,26 +1,35 @@
import type { MaybePromise } from "@excalidraw/common/utility-types";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getFileHandleType, isImageFileHandleType } from "./blob"; import { getFileHandleType, isImageFileHandleType } from "./blob";
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { exportCanvas, prepareElementsForExport } from "."; import { exportCanvas, prepareElementsForExport } from ".";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles } from "../types";
export const resaveAsImageWithScene = async ( export const resaveAsImageWithScene = async (
elements: readonly ExcalidrawElement[], data: MaybePromise<{
appState: AppState, elements: readonly ExcalidrawElement[];
files: BinaryFiles, appState: AppState;
name: string, files: BinaryFiles;
}>,
fileHandle: FileSystemFileHandle,
filename: string,
) => { ) => {
const { exportBackground, viewBackgroundColor, fileHandle } = appState;
const fileHandleType = getFileHandleType(fileHandle); const fileHandleType = getFileHandleType(fileHandle);
if (!fileHandle || !isImageFileHandleType(fileHandleType)) { if (!isImageFileHandleType(fileHandleType)) {
throw new Error( throw new Error(
"fileHandle should exist and should be of type svg or png when resaving", "fileHandle should exist and should be of type svg or png when resaving",
); );
} }
let { elements, appState, files } = await data;
const { exportBackground, viewBackgroundColor } = appState;
appState = { appState = {
...appState, ...appState,
exportEmbedScene: true, exportEmbedScene: true,
@@ -35,7 +44,7 @@ export const resaveAsImageWithScene = async (
await exportCanvas(fileHandleType, exportedElements, appState, files, { await exportCanvas(fileHandleType, exportedElements, appState, files, {
exportBackground, exportBackground,
viewBackgroundColor, viewBackgroundColor,
name, name: filename,
fileHandle, fileHandle,
exportingFrame, exportingFrame,
}); });
+8 -2
View File
@@ -22,6 +22,7 @@ import {
import { import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getNonDeletedElements, getNonDeletedElements,
normalizeArrowhead,
isPointInElement, isPointInElement,
isValidPolygon, isValidPolygon,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
@@ -426,7 +427,8 @@ export const restoreElement = (
// @ts-ignore LEGACY type // @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough // eslint-disable-next-line no-fallthrough
case "draw": case "draw":
const { startArrowhead = null, endArrowhead = null } = element; const startArrowhead = normalizeArrowhead(element.startArrowhead);
const endArrowhead = normalizeArrowhead(element.endArrowhead);
let x = element.x; let x = element.x;
let y = element.y; let y = element.y;
let points = // migrate old arrow model to new one let points = // migrate old arrow model to new one
@@ -458,7 +460,11 @@ export const restoreElement = (
...getSizeFromPoints(points), ...getSizeFromPoints(points),
}); });
case "arrow": { case "arrow": {
const { startArrowhead = null, endArrowhead = "arrow" } = element; const startArrowhead = normalizeArrowhead(element.startArrowhead);
const endArrowhead =
element.endArrowhead === undefined
? "arrow"
: normalizeArrowhead(element.endArrowhead);
const x: number | undefined = element.x; const x: number | undefined = element.x;
const y: number | undefined = element.y; const y: number | undefined = element.y;
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
+3 -1
View File
@@ -4,6 +4,8 @@ interface Window {
EXCALIDRAW_ASSET_PATH: string | string[] | undefined; EXCALIDRAW_ASSET_PATH: string | string[] | undefined;
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined; EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
DEBUG_FRACTIONAL_INDICES: boolean | undefined; DEBUG_FRACTIONAL_INDICES: boolean | undefined;
EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENTS: boolean | undefined;
EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW: unknown;
EXCALIDRAW_EXPORT_SOURCE: string; EXCALIDRAW_EXPORT_SOURCE: string;
gtag: Function; gtag: Function;
sa_event: Function; sa_event: Function;
@@ -52,7 +54,7 @@ declare module "png-chunks-extract" {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
interface Blob { interface Blob {
handle?: import("browser-fs-acces").FileSystemHandle; handle?: FileSystemFileHandle;
name?: string; name?: string;
} }
@@ -0,0 +1,172 @@
import { useEffect, useRef, useState } from "react";
import { useExcalidrawAPI } from "../components/App";
import { getDefaultAppState } from "../appState";
import type { AppState } from "../types";
type AppStateSelector =
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown);
const getSelectedValue = (appState: AppState, selector: AppStateSelector) => {
if (typeof selector === "function") {
return selector(appState);
}
if (Array.isArray(selector)) {
return appState;
}
return appState[selector];
};
const getLatestValue = (
api: ReturnType<typeof useExcalidrawAPI>,
selector: AppStateSelector,
_internal: boolean,
) => {
if (api?.isDestroyed) {
return;
}
let appState = api?.getAppState();
if (!appState) {
if (!_internal) {
return undefined;
}
console.warn(
"useAppStateValue: excalidrawAPI not defined yet for internal component while it should always be defined. Are you sure you're rendering inside of <Excalidraw/> component tree?",
);
// fall back in case there's a bug so we don't break the app
// (internal components using this internal useAppStateValue expect
// non-undefined values on init)
appState = Object.assign(
{ width: 0, height: 0, offsetLeft: 0, offsetTop: 0 },
getDefaultAppState(),
);
}
return getSelectedValue(appState, selector);
};
/**
* Subscribes to specific appState changes. The component re-renders
* only when the specified prop(s) change not on every appState update.
*
* Works both inside and outside the <Excalidraw> tree, as long as
* ExcalidrawAPIContext.Provider is an ancestor (automatically provided
* inside <Excalidraw>, or manually by the host app).
*
* Returns the narrowed value depending on prop form:
* - `keyof AppState` `AppState[K]`
* - `(keyof AppState)[]` whole `AppState`
* - selector function selector's return type `T`
*
* If excalidrawAPI is not ready yet (host apps), hook is rerendered with latest
* value once available.
*/
export function useAppStateValue<K extends keyof AppState>(
prop: K,
_internal?: boolean,
): AppState[K];
export function useAppStateValue(
props: (keyof AppState)[],
_internal?: boolean,
): AppState;
export function useAppStateValue<T>(
selector: (appState: AppState) => T,
_internal?: boolean,
): T;
export function useAppStateValue(
selector: AppStateSelector,
_internal: boolean = true,
): unknown {
const api = useExcalidrawAPI();
const [, rerender] = useState(0);
const stateRef = useRef<{
selector: AppStateSelector;
isInitialized: boolean;
latestValue: unknown;
} | null>(null);
if (!stateRef.current) {
stateRef.current = {
selector,
isInitialized: !!api,
latestValue: getLatestValue(api, selector, _internal),
};
}
stateRef.current.selector = selector;
if (!stateRef.current.isInitialized && api && !api.isDestroyed) {
stateRef.current.isInitialized = true;
stateRef.current.latestValue = getLatestValue(api, selector, _internal);
}
useEffect(() => {
const currentStateRef = stateRef.current;
if (!api || api.isDestroyed || !currentStateRef) {
return;
}
return api.onStateChange(currentStateRef.selector, (newValue: any) => {
currentStateRef.latestValue = newValue;
rerender((value) => value + 1);
});
}, [api]);
return stateRef.current.latestValue;
}
/**
* Subscribes to specific appState changes without causing component rerenders.
*
* The callback is called on every matching change, but also on initial render
* so you can initialize your state.
*/
export function useOnAppStateChange<K extends keyof AppState>(
prop: K,
callback: (value: AppState[K], appState: AppState) => void,
): undefined;
export function useOnAppStateChange(
props: (keyof AppState)[],
callback: (props: AppState, appState: AppState) => void,
): undefined;
export function useOnAppStateChange<T>(
selector: (appState: AppState) => T,
callback: (value: T, appState: AppState) => void,
): undefined;
export function useOnAppStateChange(
selector: AppStateSelector,
callback: (value: any, appState: AppState) => void,
): undefined {
const api = useExcalidrawAPI();
const stateRef = useRef({
selector,
callback,
});
stateRef.current.selector = selector;
stateRef.current.callback = callback;
useEffect(() => {
if (!api || api.isDestroyed) {
return;
}
stateRef.current.callback(
getLatestValue(api, stateRef.current.selector, true),
api.getAppState(),
);
return api.onStateChange(
stateRef.current.selector,
(newValue: any, state: AppState) => {
stateRef.current.callback(newValue, state);
},
);
}, [api]);
return undefined;
}
+104 -6
View File
@@ -1,14 +1,27 @@
import React, { useEffect } from "react"; import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common"; import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
import App from "./components/App"; import App, {
ExcalidrawAPIContext,
ExcalidrawAPISetContext,
} from "./components/App";
import { InitializeApp } from "./components/InitializeApp"; import { InitializeApp } from "./components/InitializeApp";
import Footer from "./components/footer/FooterCenter"; import Footer from "./components/footer/FooterCenter";
import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollaborationTrigger"; import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollaborationTrigger";
import MainMenu from "./components/main-menu/MainMenu"; import MainMenu from "./components/main-menu/MainMenu";
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen"; import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
import { defaultLang } from "./i18n"; import { defaultLang } from "./i18n";
import {
useAppStateValue as _useAppStateValue,
useOnAppStateChange as _useOnAppStateChange,
} from "./hooks/useAppStateValue";
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai"; import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
import polyfill from "./polyfill"; import polyfill from "./polyfill";
@@ -16,16 +29,45 @@ import "./css/app.scss";
import "./css/styles.scss"; import "./css/styles.scss";
import "./fonts/fonts.css"; import "./fonts/fonts.css";
import type { AppProps, ExcalidrawProps } from "./types"; import type {
AppProps,
AppState,
ExcalidrawImperativeAPI,
ExcalidrawProps,
} from "./types";
polyfill(); polyfill();
/**
* Stateless provider that allows `useExcalidrawAPI()` (and hooks built
* on it, such as `useAppStateValue()` and `useOnAppStateChange()`) to work
* outside the <Excalidraw> component tree.
*/
export const ExcalidrawAPIProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null);
return (
<ExcalidrawAPIContext.Provider value={api}>
<ExcalidrawAPISetContext.Provider value={setApi}>
{children}
</ExcalidrawAPISetContext.Provider>
</ExcalidrawAPIContext.Provider>
);
};
const ExcalidrawBase = (props: ExcalidrawProps) => { const ExcalidrawBase = (props: ExcalidrawProps) => {
const { const {
onExport,
onChange, onChange,
onIncrement, onIncrement,
initialData, initialData,
excalidrawAPI, onExcalidrawAPI,
onMount,
onUnmount,
onInitialize,
isCollaborating = false, isCollaborating = false,
onPointerUpdate, onPointerUpdate,
renderTopLeftUI, renderTopLeftUI,
@@ -86,6 +128,19 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
UIOptions.canvasActions.toggleTheme = true; UIOptions.canvasActions.toggleTheme = true;
} }
const setExcalidrawAPI = useContext(ExcalidrawAPISetContext);
const onExcalidrawAPIRef = useRef(onExcalidrawAPI);
onExcalidrawAPIRef.current = onExcalidrawAPI;
const handleExcalidrawAPI = useCallback(
(api: ExcalidrawImperativeAPI | null) => {
setExcalidrawAPI?.(api);
onExcalidrawAPIRef.current?.(api);
},
[setExcalidrawAPI],
);
useEffect(() => { useEffect(() => {
const importPolyfill = async () => { const importPolyfill = async () => {
//@ts-ignore //@ts-ignore
@@ -115,10 +170,14 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
<EditorJotaiProvider store={editorJotaiStore}> <EditorJotaiProvider store={editorJotaiStore}>
<InitializeApp langCode={langCode} theme={theme}> <InitializeApp langCode={langCode} theme={theme}>
<App <App
onExport={onExport}
onChange={onChange} onChange={onChange}
onIncrement={onIncrement} onIncrement={onIncrement}
initialData={initialData} initialData={initialData}
excalidrawAPI={excalidrawAPI} onExcalidrawAPI={handleExcalidrawAPI}
onMount={onMount}
onUnmount={onUnmount}
onInitialize={onInitialize}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate} onPointerUpdate={onPointerUpdate}
renderTopLeftUI={renderTopLeftUI} renderTopLeftUI={renderTopLeftUI}
@@ -267,6 +326,7 @@ export {
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
getFormFactor, getFormFactor,
throttleRAF,
} from "@excalidraw/common"; } from "@excalidraw/common";
export { export {
@@ -284,7 +344,13 @@ export { Button } from "./components/Button";
export { Footer }; export { Footer };
export { MainMenu }; export { MainMenu };
export { Ellipsify } from "./components/Ellipsify"; export { Ellipsify } from "./components/Ellipsify";
export { useEditorInterface, useStylesPanelMode } from "./components/App"; export {
useEditorInterface,
useStylesPanelMode,
useExcalidrawAPI,
ExcalidrawAPIContext,
} from "./components/App";
export { WelcomeScreen }; export { WelcomeScreen };
export { LiveCollaborationTrigger }; export { LiveCollaborationTrigger };
export { Stats } from "./components/Stats"; export { Stats } from "./components/Stats";
@@ -325,3 +391,35 @@ export {
tryParseSpreadsheet, tryParseSpreadsheet,
isSpreadsheetValidForChartType, isSpreadsheetValidForChartType,
} from "./charts"; } from "./charts";
// -----------------------------------------------------------------------------
// useExcalidrawStateValue() wrapper for host apps for the return type to reflect the
// the potentially `undefined` value for initial render before the excalidrawAPI
// is ready.
//
/**
* hook that subscribes to specific appState prop(s)
*
* @param prop - appState prop(s) to subscribe to, or a selector function.
* NOTE `prop/selector` is memoized and will not change after initial render
*/
export function useExcalidrawStateValue<K extends keyof AppState>(
prop: K,
): AppState[K] | undefined;
export function useExcalidrawStateValue<T extends keyof AppState>(
props: T[],
): AppState | undefined;
export function useExcalidrawStateValue<T>(
selector: (appState: AppState) => T,
): T | undefined;
export function useExcalidrawStateValue(
selector:
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown),
) {
return _useAppStateValue(selector as any, false);
}
// -----------------------------------------------------------------------------
export { _useOnAppStateChange as useOnExcalidrawStateChange };
+15 -3
View File
@@ -53,7 +53,14 @@
"arrowhead_crowfoot_many": "Crow's foot (many)", "arrowhead_crowfoot_many": "Crow's foot (many)",
"arrowhead_crowfoot_one": "Crow's foot (one)", "arrowhead_crowfoot_one": "Crow's foot (one)",
"arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)", "arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)",
"arrowhead_cardinality_one": "Cardinality (one)",
"arrowhead_cardinality_many": "Cardinality (many)",
"arrowhead_cardinality_one_or_many": "Cardinality (one or many)",
"arrowhead_cardinality_exactly_one": "Cardinality (exactly one)",
"arrowhead_cardinality_zero_or_one": "Cardinality (zero or one)",
"arrowhead_cardinality_zero_or_many": "Cardinality (zero or many)",
"more_options": "More options", "more_options": "More options",
"cardinality": "Cardinality",
"arrowtypes": "Arrow type", "arrowtypes": "Arrow type",
"arrowtype_sharp": "Sharp arrow", "arrowtype_sharp": "Sharp arrow",
"arrowtype_round": "Curved arrow", "arrowtype_round": "Curved arrow",
@@ -403,6 +410,10 @@
"errorDialog": { "errorDialog": {
"title": "Error" "title": "Error"
}, },
"progressDialog": {
"title": "Saving",
"defaultMessage": "Preparing to save..."
},
"exportDialog": { "exportDialog": {
"disk_title": "Save to disk", "disk_title": "Save to disk",
"disk_details": "Export the scene data to a file from which you can import later.", "disk_details": "Export the scene data to a file from which you can import later.",
@@ -620,11 +631,12 @@
"mermaid": { "mermaid": {
"title": "Mermaid to Excalidraw", "title": "Mermaid to Excalidraw",
"button": "Insert", "button": "Insert",
"description": "Currently only <flowchartLink>Flowchart</flowchartLink>,<sequenceLink> Sequence, </sequenceLink> and <classLink>Class </classLink>Diagrams are supported. The other types will be rendered as image in Excalidraw.", "description": "Currently only <flowchartLink>Flowchart</flowchartLink>, <sequenceLink>Sequence</sequenceLink>, <classLink>Class</classLink>, and <erdLink>Entity Relationship</erdLink> Diagrams are supported. The other types will be rendered as image in Excalidraw.",
"syntax": "Mermaid Syntax", "syntax": "Mermaid Syntax",
"preview": "Preview", "preview": "Preview",
"label": "Mermaid", "label": "Mermaid",
"inputPlaceholder": "Write Mermaid diagram defintion here..." "inputPlaceholder": "Write Mermaid diagram defintion here...",
"autoFixAvailable": "Auto-fix is available"
}, },
"ttd": { "ttd": {
"error": "Error!" "error": "Error!"
@@ -649,7 +661,7 @@
"placeholder": { "placeholder": {
"title": "Let's design your diagram", "title": "Let's design your diagram",
"description": "Describe the diagram you want to create, and we'll generate it for you.", "description": "Describe the diagram you want to create, and we'll generate it for you.",
"hint": "At the moment we know Flowchart, Sequence, and Class diagrams." "hint": "At the moment we know Flowchart, Sequence, Class, and Entity Relationship diagrams."
}, },
"preview": "Preview", "preview": "Preview",
"insert": "Insert", "insert": "Insert",
+8 -3
View File
@@ -79,14 +79,18 @@
}, },
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "6.0.2", "@braintree/sanitize-url": "6.0.2",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0",
"@excalidraw/common": "0.18.0", "@excalidraw/common": "0.18.0",
"@excalidraw/element": "0.18.0", "@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "2.0.0-rfc3", "@excalidraw/mermaid-to-excalidraw": "2.1.1",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"radix-ui": "1.4.3", "browser-fs-access": "0.38.0",
"browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
@@ -107,6 +111,7 @@
"png-chunks-extract": "1.0.0", "png-chunks-extract": "1.0.0",
"points-on-curve": "1.0.1", "points-on-curve": "1.0.1",
"pwacompat": "2.0.17", "pwacompat": "2.0.17",
"radix-ui": "1.4.3",
"roughjs": "4.6.4", "roughjs": "4.6.4",
"sass": "1.51.0", "sass": "1.51.0",
"tunnel-rat": "0.1.2" "tunnel-rat": "0.1.2"
@@ -41,6 +41,8 @@ import {
maxBindingDistance_simple, maxBindingDistance_simple,
isTextElement, isTextElement,
LinearElementEditor, LinearElementEditor,
getActiveTextElement,
getSimpleArrowCurveDebugData,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { renderSelectionElement } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element";
@@ -58,6 +60,8 @@ import {
isFocusPointVisible, isFocusPointVisible,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { EditorInterface } from "@excalidraw/common";
import type { import type {
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
@@ -86,6 +90,10 @@ import {
} from "../scene/scrollbars"; } from "../scene/scrollbars";
import { getClientColor, renderRemoteCursors } from "../clients"; import { getClientColor, renderRemoteCursors } from "../clients";
import {
getTextAutoResizeHandle,
getTextBoxPadding,
} from "../textAutoResizeHandle";
import { import {
bootstrapCanvas, bootstrapCanvas,
@@ -1194,6 +1202,105 @@ const renderLinearPointHandles = (
context.restore(); context.restore();
}; };
const isSimpleArrowTangentDebugEnabled = () =>
window.EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENTS === true;
const renderSimpleArrowTangentOverlay = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
element: NonDeleted<ExcalidrawArrowElement>,
elementsMap: RenderableElementsMap,
) => {
const points = LinearElementEditor.getPointsGlobalCoordinates(
element,
elementsMap,
);
const debugData = getSimpleArrowCurveDebugData(points);
window.EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW = debugData;
context.save();
context.translate(appState.scrollX, appState.scrollY);
context.lineWidth = 1 / appState.zoom.value;
context.setLineDash([6 / appState.zoom.value, 4 / appState.zoom.value]);
context.strokeStyle = "rgba(134, 142, 150, 0.75)";
for (const segment of debugData.segments) {
context.beginPath();
context.moveTo(segment.start[0], segment.start[1]);
context.lineTo(segment.baseCp1[0], segment.baseCp1[1]);
context.lineTo(segment.baseCp2[0], segment.baseCp2[1]);
context.lineTo(segment.end[0], segment.end[1]);
context.stroke();
}
context.setLineDash([]);
for (const segment of debugData.segments) {
const strokeStyle = segment.overshootsBaseline
? "rgba(245, 159, 0, 0.9)"
: "rgba(94, 90, 216, 0.85)";
context.strokeStyle = strokeStyle;
context.fillStyle = "rgba(255, 255, 255, 0.95)";
context.beginPath();
context.moveTo(segment.start[0], segment.start[1]);
context.lineTo(segment.cp1[0], segment.cp1[1]);
context.lineTo(segment.cp2[0], segment.cp2[1]);
context.lineTo(segment.end[0], segment.end[1]);
context.stroke();
fillCircle(
context,
segment.cp1[0],
segment.cp1[1],
4 / appState.zoom.value,
true,
true,
);
fillCircle(
context,
segment.cp2[0],
segment.cp2[1],
4 / appState.zoom.value,
true,
true,
);
}
context.strokeStyle = "rgba(201, 42, 42, 0.85)";
context.fillStyle = "rgba(201, 42, 42, 0.95)";
for (const tangent of debugData.tangents) {
if (!tangent.isAdjusted) {
continue;
}
const handle = pointFrom<GlobalPoint>(
tangent.point[0] + tangent.scaled[0] / 3,
tangent.point[1] + tangent.scaled[1] / 3,
);
context.beginPath();
context.moveTo(tangent.point[0], tangent.point[1]);
context.lineTo(handle[0], handle[1]);
context.stroke();
fillCircle(
context,
tangent.point[0],
tangent.point[1],
3 / appState.zoom.value,
true,
true,
);
}
context.restore();
};
const renderFocusPointConnectionLine = ( const renderFocusPointConnectionLine = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
@@ -1489,21 +1596,58 @@ const renderTextBox = (
selectionColor: InteractiveCanvasRenderConfig["selectionColor"], selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => { ) => {
context.save(); context.save();
const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; const padding = getTextBoxPadding(appState.zoom.value);
const width = text.width + padding * 2; const width = text.width + padding * 2;
const height = text.height + padding * 2; const height = text.height + padding * 2;
const cx = text.x + width / 2; const cx = text.x + text.width / 2;
const cy = text.y + height / 2; const cy = text.y + text.height / 2;
const shiftX = -(width / 2 + padding); const shiftX = -(text.width / 2 + padding);
const shiftY = -(height / 2 + padding); const shiftY = -(text.height / 2 + padding);
context.translate(cx + appState.scrollX, cy + appState.scrollY); context.translate(cx + appState.scrollX, cy + appState.scrollY);
context.rotate(text.angle); context.rotate(text.angle);
context.lineWidth = 1 / appState.zoom.value; context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = selectionColor; context.strokeStyle = selectionColor;
context.globalAlpha = 0.5;
context.setLineDash([6 / appState.zoom.value, 4 / appState.zoom.value]);
context.strokeRect(shiftX, shiftY, width, height); context.strokeRect(shiftX, shiftY, width, height);
context.restore(); context.restore();
}; };
const renderResetAutoResizeHandle = (
text: NonDeleted<ExcalidrawTextElement>,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
formFactor: EditorInterface["formFactor"],
) => {
const autoResizeHandle = getTextAutoResizeHandle(
text,
appState.zoom.value,
formFactor,
);
if (!autoResizeHandle) {
return;
}
context.save();
context.globalAlpha = 0.5;
context.lineWidth = 1.5 / appState.zoom.value;
context.lineCap = "round";
context.strokeStyle = selectionColor;
context.beginPath();
context.moveTo(
autoResizeHandle.start[0] + appState.scrollX,
autoResizeHandle.start[1] + appState.scrollY,
);
context.lineTo(
autoResizeHandle.end[0] + appState.scrollX,
autoResizeHandle.end[1] + appState.scrollY,
);
context.stroke();
context.restore();
};
const _renderInteractiveScene = ({ const _renderInteractiveScene = ({
app, app,
canvas, canvas,
@@ -1584,10 +1728,19 @@ const _renderInteractiveScene = ({
} }
} }
if ( const activeTextElement = getActiveTextElement(selectedElements, appState);
appState.editingTextElement &&
isTextElement(appState.editingTextElement) if (activeTextElement && !activeTextElement.autoResize) {
) { renderResetAutoResizeHandle(
activeTextElement,
context,
appState,
renderConfig.selectionColor,
editorInterface.formFactor,
);
}
if (appState.editingTextElement) {
const textElement = allElementsMap.get(appState.editingTextElement.id) as const textElement = allElementsMap.get(appState.editingTextElement.id) as
| ExcalidrawTextElement | ExcalidrawTextElement
| undefined; | undefined;
@@ -1669,6 +1822,13 @@ const _renderInteractiveScene = ({
const selectedLinearElement = const selectedLinearElement =
linearState && linearState &&
LinearElementEditor.getElement(linearState.elementId, allElementsMap); LinearElementEditor.getElement(linearState.elementId, allElementsMap);
const selectedRoundedArrow =
selectedElements.length === 1 &&
isArrowElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0]) &&
!!selectedElements[0].roundness
? (selectedElements[0] as NonDeleted<ExcalidrawArrowElement>)
: null;
// Arrows have a different highlight behavior when // Arrows have a different highlight behavior when
// they are the only selected element // they are the only selected element
if (selectedLinearElement) { if (selectedLinearElement) {
@@ -1705,6 +1865,17 @@ const _renderInteractiveScene = ({
} }
} }
if (selectedRoundedArrow && isSimpleArrowTangentDebugEnabled()) {
renderSimpleArrowTangentOverlay(
context,
appState,
selectedRoundedArrow,
elementsMap,
);
} else {
window.EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW = undefined;
}
// Paint selected elements // Paint selected elements
if ( if (
!appState.multiElement && !appState.multiElement &&
@@ -88,7 +88,6 @@ export const renderNewElementSceneThrottled = throttleRAF(
(config: NewElementSceneRenderConfig) => { (config: NewElementSceneRenderConfig) => {
_renderNewElementScene(config); _renderNewElementScene(config);
}, },
{ trailing: true },
); );
export const renderNewElementScene = ( export const renderNewElementScene = (
@@ -483,7 +483,6 @@ export const renderStaticSceneThrottled = throttleRAF(
(config: StaticSceneRenderConfig) => { (config: StaticSceneRenderConfig) => {
_renderStaticScene(config); _renderStaticScene(config);
}, },
{ trailing: true },
); );
/** /**
@@ -1,5 +1,4 @@
import React from "react"; import { expect, vi } from "vitest";
import { expect } from "vitest";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
@@ -7,6 +6,13 @@ import { mockMermaidToExcalidraw } from "./helpers/mocks";
import { getTextEditor, updateTextEditor } from "./queries/dom"; import { getTextEditor, updateTextEditor } from "./queries/dom";
import { render, waitFor } from "./test-utils"; import { render, waitFor } from "./test-utils";
// Mock CodeMirror deps so the dynamic import of CodeMirrorEditor fails,
// causing TTDDialogInput to fall back to <textarea> in tests.
vi.mock("@codemirror/view", () => ({}));
vi.mock("@codemirror/state", () => ({}));
vi.mock("@codemirror/language", () => ({}));
vi.mock("@lezer/highlight", () => ({}));
mockMermaidToExcalidraw({ mockMermaidToExcalidraw({
mockRef: true, mockRef: true,
parseMermaidToExcalidraw: async (definition) => { parseMermaidToExcalidraw: async (definition) => {
@@ -1,13 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = ` exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" rel="noreferrer">Flowchart</a>, <a href="https://mermaid.js.org/syntax/sequenceDiagram.html" target="_blank" rel="noreferrer">Sequence</a>, <a href="https://mermaid.js.org/syntax/classDiagram.html" target="_blank" rel="noreferrer">Class</a>, and <a href="https://mermaid.js.org/syntax/entityRelationshipDiagram.html" target="_blank" rel="noreferrer">Entity Relationship</a> Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"`;
"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1520px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r4:-trigger-mermaid" id="radix-:r4:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><textarea class="ttd-dialog-input" placeholder="Write Mermaid diagram defintion here...">flowchart TD
A[Christmas] --&gt;|Get money| B(Go shopping)
B --&gt; C{Let me think}
C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone]
C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="justify-content: flex-start;"></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-output-wrapper "><div class="ttd-dialog-output-canvas-container"><div class="ttd-dialog-output-canvas-content"><canvas width="89" height="158" dir="ltr"></canvas></div></div></div><div class="ttd-dialog-panel-button-container" style="justify-content: flex-start;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
`;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = ` exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
"flowchart TD "flowchart TD
@@ -2,7 +2,7 @@
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = ` exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
<div <div
aria-labelledby="radix-:r7t:" aria-labelledby="radix-:r85:"
aria-orientation="vertical" aria-orientation="vertical"
class="dropdown-menu main-menu" class="dropdown-menu main-menu"
data-align="start" data-align="start"
@@ -12,7 +12,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
data-state="open" data-state="open"
data-testid="dropdown-menu" data-testid="dropdown-menu"
dir="ltr" dir="ltr"
id="radix-:r7u:" id="radix-:r86:"
role="menu" role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;" style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
tabindex="-1" tabindex="-1"
@@ -224,7 +224,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 29, "version": 28,
"width": "94.00000", "width": "94.00000",
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -350,7 +350,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 28, "version": 27,
"width": "88.00000", "width": "88.00000",
}, },
"inserted": { "inserted": {
@@ -381,7 +381,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 25, "version": 24,
"width": "88.00000", "width": "88.00000",
}, },
}, },
@@ -437,7 +437,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
], ],
"startBinding": null, "startBinding": null,
"version": 29, "version": 28,
"width": "94.00000", "width": "94.00000",
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -462,7 +462,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 28, "version": 27,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "7.20923", "y": "7.20923",
@@ -1360,9 +1360,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 8, "version": 7,
"width": 88, "width": 88,
"x": 6, "x": "6.00000",
"y": "2.00947", "y": "2.00947",
} }
`; `;
@@ -1537,12 +1537,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 8, "version": 7,
}, },
"inserted": { "inserted": {
"endBinding": null, "endBinding": null,
"startBinding": null, "startBinding": null,
"version": 7, "version": 6,
}, },
}, },
}, },
@@ -1722,7 +1722,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"updated": 1, "updated": 1,
"version": 8, "version": 8,
"width": 88, "width": 88,
"x": 6, "x": "6.00000",
"y": "38.80379", "y": "38.80379",
} }
`; `;
@@ -1867,7 +1867,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"version": 8, "version": 8,
"width": 88, "width": 88,
"x": 6, "x": "6.00000",
"y": "38.80379", "y": "38.80379",
}, },
"inserted": { "inserted": {
@@ -2416,7 +2416,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 11,
"width": 488, "width": 488,
"x": 6, "x": 6,
"y": "-5.39000", "y": "-5.39000",
@@ -2581,7 +2581,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 12, "version": 11,
"width": 488, "width": 488,
"x": 6, "x": 6,
"y": "-5.39000", "y": "-5.39000",
@@ -16254,7 +16254,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
@@ -16307,7 +16307,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 10, "version": 11,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@@ -16327,7 +16327,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 8, "version": 9,
}, },
}, },
}, },
@@ -16669,14 +16669,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 7, "version": 8,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00880", "y": "0.00880",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 6, "version": 7,
}, },
}, },
}, },
@@ -17002,7 +17002,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
@@ -17307,14 +17307,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 10, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 8, "version": 9,
}, },
}, },
}, },
@@ -17648,7 +17648,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
@@ -17953,14 +17953,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 10, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 8, "version": 9,
}, },
}, },
}, },
@@ -18292,7 +18292,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
@@ -18361,7 +18361,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 10, "version": 11,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@@ -18373,7 +18373,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"mode": "orbit", "mode": "orbit",
}, },
"startBinding": null, "startBinding": null,
"version": 8, "version": 9,
}, },
}, },
"id2": { "id2": {
@@ -18683,14 +18683,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 7, "version": 8,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00880", "y": "0.00880",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 6, "version": 7,
}, },
}, },
}, },
@@ -19044,7 +19044,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 12,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
@@ -19124,12 +19124,12 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 11, "version": 12,
}, },
"inserted": { "inserted": {
"endBinding": null, "endBinding": null,
"startBinding": null, "startBinding": null,
"version": 9, "version": 10,
}, },
}, },
}, },
@@ -19431,14 +19431,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 7, "version": 8,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00880", "y": "0.00880",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 6, "version": 7,
}, },
}, },
}, },
@@ -0,0 +1,111 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { getDefaultAppState } from "../appState";
import { ExcalidrawAPIContext } from "../components/App";
import { AppStateObserver } from "../components/AppStateObserver";
import {
useAppStateValue,
useOnAppStateChange,
} from "../hooks/useAppStateValue";
import type { AppState, ExcalidrawImperativeAPI } from "../types";
const createAppState = (): AppState => ({
...getDefaultAppState(),
width: 0,
height: 0,
offsetLeft: 0,
offsetTop: 0,
});
const createMockAPI = (initialState: AppState) => {
let state = initialState;
const observer = new AppStateObserver(() => state);
return {
api: {
isDestroyed: false,
getAppState: () => state,
onStateChange: observer.onStateChange,
} as Pick<
ExcalidrawImperativeAPI,
"isDestroyed" | "getAppState" | "onStateChange"
> as ExcalidrawImperativeAPI,
updateAppState: (partial: Partial<AppState>) => {
const prevState = state;
state = { ...state, ...partial };
observer.flush(prevState);
},
};
};
describe("app state hooks", () => {
afterEach(() => {
cleanup();
});
it("useAppStateValue rerenders when the selected value changes", () => {
const renderSpy = vi.fn();
const { api, updateAppState } = createMockAPI(createAppState());
const ValueConsumer = () => {
const value = useAppStateValue("viewModeEnabled");
renderSpy(value);
return <div data-testid="value">{String(value)}</div>;
};
render(
<ExcalidrawAPIContext.Provider value={api}>
<ValueConsumer />
</ExcalidrawAPIContext.Provider>,
);
expect(screen.getByTestId("value").textContent).toBe("false");
expect(renderSpy).toHaveBeenCalledTimes(1);
act(() => {
updateAppState({ viewModeEnabled: true });
});
expect(screen.getByTestId("value").textContent).toBe("true");
expect(renderSpy).toHaveBeenCalledTimes(2);
});
it("useOnAppStateChange notifies without rerendering", () => {
const renderSpy = vi.fn();
const callback = vi.fn();
const { api, updateAppState } = createMockAPI(createAppState());
const ChangeConsumer = () => {
const value = useOnAppStateChange("viewModeEnabled", callback);
renderSpy(value);
return <div data-testid="value">{String(value)}</div>;
};
render(
<ExcalidrawAPIContext.Provider value={api}>
<ChangeConsumer />
</ExcalidrawAPIContext.Provider>,
);
expect(screen.getByTestId("value").textContent).toBe("undefined");
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
false,
expect.objectContaining({ viewModeEnabled: false }),
);
act(() => {
updateAppState({ viewModeEnabled: true });
});
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ viewModeEnabled: true }),
);
});
});
+4 -1
View File
@@ -28,7 +28,9 @@ const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
vi.mock("@excalidraw/common", async (importOriginal) => { vi.mock("@excalidraw/common", async (importOriginal) => {
const module: any = await importOriginal(); const module = await importOriginal<typeof import("@excalidraw/common")>();
const { mockThrottleRAF } = await import("./helpers/mocks");
return { return {
__esmodule: true, __esmodule: true,
...module, ...module,
@@ -37,6 +39,7 @@ vi.mock("@excalidraw/common", async (importOriginal) => {
...module.KEYS, ...module.KEYS,
CTRL_OR_CMD: "ctrlKey", CTRL_OR_CMD: "ctrlKey",
}, },
throttleRAF: mockThrottleRAF,
}; };
}); });
@@ -200,6 +200,26 @@ describe("restoreElements", () => {
}); });
}); });
it("should normalize legacy crowfoot arrowheads on restore", () => {
const arrowElement = API.createElement({
type: "arrow",
});
const restoredArrow = restore.restoreElements(
[
{
...arrowElement,
startArrowhead: "crowfoot_one",
endArrowhead: "crowfoot_one_or_many",
} as any,
],
null,
)[0] as ExcalidrawLinearElement;
expect(restoredArrow.startArrowhead).toBe("cardinality_one");
expect(restoredArrow.endArrowhead).toBe("cardinality_one_or_many");
});
it("should strip element if restore fails", () => { it("should strip element if restore fails", () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
type: "rectangle", type: "rectangle",
+50 -1
View File
@@ -6,8 +6,16 @@ import { THEME } from "@excalidraw/common";
import { t } from "../i18n"; import { t } from "../i18n";
import { Excalidraw, Footer, MainMenu } from "../index"; import { Excalidraw, Footer, MainMenu } from "../index";
import { actionExportWithDarkMode } from "../actions/actionExport";
import { fireEvent, GlobalTestState, toggleMenu, render } from "./test-utils"; import {
act,
fireEvent,
GlobalTestState,
toggleMenu,
render,
waitFor,
} from "./test-utils";
const { h } = window; const { h } = window;
@@ -304,6 +312,47 @@ describe("<Excalidraw/>", () => {
const darkModeToggle = queryByTestId(container, "toggle-dark-mode"); const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBe(null); expect(darkModeToggle).toBe(null);
}); });
it("should sync export theme with the UI theme when there is no session override", async () => {
await render(<Excalidraw theme={THEME.DARK} />);
expect(h.state.exportWithDarkMode).toBe(true);
act(() => {
h.setState({ exportWithDarkMode: false });
});
await waitFor(() => {
expect(h.state.exportWithDarkMode).toBe(true);
});
});
it("should keep the export theme override for the current session", async () => {
await render(<Excalidraw theme={THEME.LIGHT} />);
act(() => {
(h.app as any).actionManager.executeAction(
actionExportWithDarkMode,
"ui",
true,
);
});
expect(h.app.sessionExportThemeOverride).toBe(THEME.DARK);
expect(h.state.exportWithDarkMode).toBe(true);
act(() => {
h.setState({ theme: THEME.DARK });
});
act(() => {
h.setState({ theme: THEME.LIGHT });
});
await waitFor(() => {
expect(h.state.exportWithDarkMode).toBe(true);
});
});
}); });
describe("Test name prop", () => { describe("Test name prop", () => {
@@ -3,6 +3,25 @@ import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import type { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw"; import type { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
import type { throttleRAF as throttleRAFType } from "@excalidraw/common";
type ThrottledFn<T extends unknown[]> = ((...args: T) => void) & {
flush: () => void;
cancel: () => void;
};
export const mockThrottleRAF: typeof throttleRAFType = <T extends unknown[]>(
fn: (...args: T) => void,
) => {
const ret = ((...args: T) => {
fn(...args);
}) as ThrottledFn<T>;
ret.flush = () => {};
ret.cancel = () => {};
return ret;
};
export const mockMermaidToExcalidraw = (opts: { export const mockMermaidToExcalidraw = (opts: {
parseMermaidToExcalidraw: typeof parseMermaidToExcalidraw; parseMermaidToExcalidraw: typeof parseMermaidToExcalidraw;
+12 -2
View File
@@ -4,6 +4,7 @@ import {
elementCenterPoint, elementCenterPoint,
getCommonBounds, getCommonBounds,
getElementPointsCoords, getElementPointsCoords,
getLineHeightInPx,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { cropElement } from "@excalidraw/element"; import { cropElement } from "@excalidraw/element";
import { import {
@@ -20,7 +21,7 @@ import {
isTextElement, isTextElement,
isFrameLikeElement, isFrameLikeElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, arrayToMap } from "@excalidraw/common"; import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -516,8 +517,17 @@ export class UI {
UI.clickTool(type); UI.clickTool(type);
if (type === "text") { if (type === "text") {
const clickY = h.state.gridModeEnabled
? y
: y +
getLineHeightInPx(
h.state.currentItemFontSize,
getLineHeight(h.state.currentItemFontFamily),
) /
2;
mouse.reset(); mouse.reset();
mouse.click(x, y); mouse.click(x, clickY);
} else if ((type === "line" || type === "arrow") && points.length > 2) { } else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => { points.forEach((point) => {
mouse.reset(); mouse.reset();
@@ -6,7 +6,7 @@ import { resolvablePromise } from "@excalidraw/common";
import { Excalidraw, CaptureUpdateAction } from "../../index"; import { Excalidraw, CaptureUpdateAction } from "../../index";
import { API } from "../helpers/api"; import { API } from "../helpers/api";
import { Pointer } from "../helpers/ui"; import { Pointer } from "../helpers/ui";
import { render } from "../test-utils"; import { render, unmountComponent } from "../test-utils";
import type { ExcalidrawImperativeAPI } from "../../types"; import type { ExcalidrawImperativeAPI } from "../../types";
@@ -21,12 +21,93 @@ describe("event callbacks", () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>(); const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
await render( await render(
<Excalidraw <Excalidraw
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)} onExcalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
/>, />,
); );
excalidrawAPI = await excalidrawAPIPromise; excalidrawAPI = await excalidrawAPIPromise;
}); });
it("should resolve editor:mount/editor:initialize when subscribed before mount", async () => {
unmountComponent();
const lifecyclePromise = resolvablePromise<{
api: ExcalidrawImperativeAPI;
mount: Promise<{
excalidrawAPI: ExcalidrawImperativeAPI;
container: HTMLDivElement | null;
}>;
initialize: Promise<ExcalidrawImperativeAPI>;
}>();
await render(
<Excalidraw
onExcalidrawAPI={(api) => {
if (api) {
lifecyclePromise.resolve({
api,
mount: api.onEvent("editor:mount"),
initialize: api.onEvent("editor:initialize"),
});
}
}}
/>,
);
const { api, mount, initialize } = await lifecyclePromise;
await expect(mount).resolves.toEqual({
excalidrawAPI: api,
container: expect.any(HTMLDivElement),
});
await expect(initialize).resolves.toBe(api);
});
it("should replay editor:mount/editor:initialize to late subscribers", async () => {
const onMount = vi.fn();
const onInitialize = vi.fn();
excalidrawAPI.onEvent("editor:mount", onMount);
excalidrawAPI.onEvent("editor:initialize", onInitialize);
await Promise.resolve();
expect(onMount).toHaveBeenCalledTimes(1);
expect(onMount).toHaveBeenCalledWith({
excalidrawAPI,
container: expect.any(HTMLDivElement),
});
expect(onInitialize).toHaveBeenCalledTimes(1);
expect(onInitialize).toHaveBeenCalledWith(excalidrawAPI);
await expect(excalidrawAPI.onEvent("editor:mount")).resolves.toEqual({
excalidrawAPI,
container: expect.any(HTMLDivElement),
});
await expect(excalidrawAPI.onEvent("editor:initialize")).resolves.toBe(
excalidrawAPI,
);
});
it("should call onMount before onInitialize props", async () => {
unmountComponent();
const calls: string[] = [];
await render(
<Excalidraw
onMount={({ excalidrawAPI, container }) => {
expect(excalidrawAPI).toBeDefined();
expect(container).toBeInstanceOf(HTMLDivElement);
calls.push("mount");
}}
onInitialize={() => {
calls.push("initialize");
}}
/>,
);
expect(calls).toEqual(["mount", "initialize"]);
});
it("should trigger onChange on render", async () => { it("should trigger onChange on render", async () => {
const onChange = vi.fn(); const onChange = vi.fn();
+1 -1
View File
@@ -20,7 +20,7 @@ describe("setActiveTool()", () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>(); const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
await render( await render(
<Excalidraw <Excalidraw
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)} onExcalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
/>, />,
); );
excalidrawAPI = await excalidrawAPIPromise; excalidrawAPI = await excalidrawAPIPromise;
@@ -0,0 +1,88 @@
import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "@excalidraw/common";
import {
pointFrom,
pointRotateRads,
type GlobalPoint,
type Radians,
} from "@excalidraw/math";
import type { EditorInterface } from "@excalidraw/common";
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
const TEXT_AUTO_RESIZE_HANDLE_GAP = 12;
const TEXT_AUTO_RESIZE_HANDLE_LENGTH = 16;
const TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH = 10;
const TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT =
TEXT_AUTO_RESIZE_HANDLE_LENGTH + 2;
const MAX_HANDLE_HEIGHT_RATIO = 0.8;
export const getTextBoxPadding = (zoomValue: number) =>
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / zoomValue;
export const getTextAutoResizeHandle = (
textElement: ExcalidrawTextElement,
zoomValue: number,
formFactor: EditorInterface["formFactor"],
) => {
if (
formFactor !== "desktop" ||
TEXT_AUTO_RESIZE_HANDLE_LENGTH >
textElement.height * zoomValue * MAX_HANDLE_HEIGHT_RATIO
) {
return null;
}
const padding = getTextBoxPadding(zoomValue);
const gap = TEXT_AUTO_RESIZE_HANDLE_GAP / zoomValue;
const length = TEXT_AUTO_RESIZE_HANDLE_LENGTH / zoomValue;
const center = pointFrom(
textElement.x + textElement.width / 2,
textElement.y + textElement.height / 2,
);
const handleCenter = pointRotateRads(
pointFrom(center[0] + textElement.width / 2 + padding + gap, center[1]),
center,
textElement.angle,
);
return {
center: handleCenter,
start: pointRotateRads(
pointFrom(handleCenter[0], handleCenter[1] - length / 2),
handleCenter,
textElement.angle,
) as GlobalPoint,
end: pointRotateRads(
pointFrom(handleCenter[0], handleCenter[1] + length / 2),
handleCenter,
textElement.angle,
) as GlobalPoint,
hitboxWidth: TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH / zoomValue,
hitboxHeight: TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT / zoomValue,
};
};
export const isPointHittingTextAutoResizeHandle = (
point: Readonly<{ x: number; y: number }>,
textElement: ExcalidrawTextElement,
zoomValue: number,
formFactor: EditorInterface["formFactor"],
) => {
const handle = getTextAutoResizeHandle(textElement, zoomValue, formFactor);
if (!handle) {
return false;
}
const unrotatedPoint = pointRotateRads(
pointFrom(point.x, point.y),
handle.center,
-textElement.angle as Radians,
);
return (
Math.abs(unrotatedPoint[0] - handle.center[0]) <= handle.hitboxWidth / 2 &&
Math.abs(unrotatedPoint[1] - handle.center[1]) <= handle.hitboxHeight / 2
);
};

Some files were not shown because too many files have changed in this diff Show More