Compare commits

...

8 Commits

Author SHA1 Message Date
dwelle c9f62f20d5 debuggs 2026-03-09 22:04:43 +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
73 changed files with 3798 additions and 680 deletions
+22 -1
View File
@@ -39,5 +39,26 @@
"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"]
}
]
}
}
]
}
+5 -5
View File
@@ -3,14 +3,14 @@
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"@excalidraw/excalidraw": "*",
"browser-fs-access": "0.29.1"
"browser-fs-access": "0.38.0",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"vite": "5.0.12",
"typescript": "^5"
"typescript": "^5",
"vite": "5.0.12"
},
"scripts": {
"start": "vite",
-36
View File
@@ -4,8 +4,6 @@ import { unstable_batchedUpdates } from "react-dom";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500;
export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
reject: (error: Error) => void;
@@ -54,40 +52,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions,
mimeTypes,
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>;
};
+92 -20
View File
@@ -5,6 +5,8 @@ import {
CaptureUpdateAction,
reconcileElements,
useEditorInterface,
ExcalidrawAPIProvider,
useExcalidrawAPI,
} from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -34,7 +36,6 @@ import {
import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
import { t } from "@excalidraw/excalidraw/i18n";
import {
@@ -74,6 +75,7 @@ import type {
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types";
import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils";
@@ -114,6 +116,7 @@ import {
} from "./data";
import { updateStaleImageStatuses } from "./data/FileManager";
import { FileStatusStore } from "./data/fileStatusStore";
import {
importFromLocalStorage,
importUsernameFromLocalStorage,
@@ -369,6 +372,8 @@ const initializeScene = async (opts: {
};
const ExcalidrawWrapper = () => {
const excalidrawAPI = useExcalidrawAPI();
const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe();
@@ -399,9 +404,6 @@ const ExcalidrawWrapper = () => {
}, VERSION_TIMEOUT);
}, []);
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -433,18 +435,15 @@ const ExcalidrawWrapper = () => {
}
}, [excalidrawAPI]);
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
}
const loadImages = (
data: ResolutionType<typeof initializeScene>,
isInitialLoad = false,
) => {
if (!data.scene) {
// ---------------------------------------------------------------------------
// Hoisted loadImages
// ---------------------------------------------------------------------------
const loadImages = useCallback(
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
if (!data.scene || !excalidrawAPI) {
return;
}
if (collabAPI?.isCollaborating()) {
if (data.scene.elements) {
collabAPI
@@ -471,6 +470,12 @@ const ExcalidrawWrapper = () => {
}, [] as FileId[]) || [];
if (data.isExternalScene) {
if (fileIds.length) {
// Direct Firebase call (not through FileManager), so track manually
FileStatusStore.updateStatuses(
fileIds.map((id) => [id, "loading"]),
);
}
loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key,
@@ -482,12 +487,18 @@ const ExcalidrawWrapper = () => {
erroredFiles,
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) {
if (fileIds.length) {
LocalData.fileStorage
.getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => {
.then(async ({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles);
}
@@ -500,10 +511,19 @@ const ExcalidrawWrapper = () => {
}
// on fresh load, clear unused files from IDB (from previous
// 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) => {
loadImages(data, /* isInitialLoad */ true);
@@ -628,7 +648,7 @@ const ExcalidrawWrapper = () => {
false,
);
};
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => {
@@ -773,6 +793,56 @@ const ExcalidrawWrapper = () => {
[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
// cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard
@@ -839,8 +909,8 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange}
onExport={onExport}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
@@ -1206,7 +1276,9 @@ const ExcalidrawApp = () => {
return (
<TopErrorBoundary>
<Provider store={appJotaiStore}>
<ExcalidrawWrapper />
<ExcalidrawAPIProvider>
<ExcalidrawWrapper />
</ExcalidrawAPIProvider>
</Provider>
</TopErrorBoundary>
);
+2
View File
@@ -72,6 +72,7 @@ import {
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { FileStatusStore } from "../data/fileStatusStore";
import { LocalData } from "../data/LocalData";
import {
isSavedToFirebase,
@@ -149,6 +150,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
this.portal = new Portal(this);
this.fileManager = new FileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles: async (fileIds) => {
const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) {
@@ -414,7 +414,6 @@ export const debugRenderer = throttleRAF(
) => {
_debugRenderer(canvas, appState, elements, scale);
},
{ trailing: true },
);
export const loadSavedDebugState = () => {
+22
View File
@@ -40,10 +40,12 @@ export class FileManager {
private _getFiles;
private _saveFiles;
private _onFileStatusChange;
constructor({
getFiles,
saveFiles,
onFileStatusChange,
}: {
getFiles: (fileIds: FileId[]) => Promise<{
loadedFiles: BinaryFileData[];
@@ -53,9 +55,13 @@ export class FileManager {
savedFiles: Map<FileId, BinaryFileData>;
erroredFiles: Map<FileId, BinaryFileData>;
}>;
onFileStatusChange?: (
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
) => void;
}) {
this._getFiles = getFiles;
this._saveFiles = saveFiles;
this._onFileStatusChange = onFileStatusChange;
}
/**
@@ -146,6 +152,8 @@ export class FileManager {
this.fetchingFiles.set(id, true);
}
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
try {
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
@@ -156,6 +164,13 @@ export class FileManager {
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 };
} finally {
for (const id of ids) {
@@ -195,6 +210,13 @@ export class FileManager {
};
reset() {
if (this._onFileStatusChange && this.fetchingFiles.size) {
this._onFileStatusChange(
[...this.fetchingFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
);
}
this.fetchingFiles.clear();
this.savingFiles.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 { FileManager } from "./FileManager";
import { FileStatusStore } from "./fileStatusStore";
import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync";
@@ -166,6 +167,7 @@ export class LocalData {
// ---------------------------------------------------------------------------
static fileStorage = new LocalFileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles(ids) {
return getMany(ids, filesStore).then(
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")) {
return "mermaid-to-excalidraw";
}
if (id.includes("@codemirror/") || id.includes("@lezer/")) {
return "codemirror.chunk";
}
},
},
},
@@ -150,6 +154,11 @@ export default defineConfig(({ mode }) => {
"**/locales/**",
"service-worker.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: [
{
@@ -189,7 +198,7 @@ export default defineConfig(({ mode }) => {
},
},
{
urlPattern: new RegExp(".chunk-.+.js"),
urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"),
handler: "CacheFirst",
options: {
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 "./utils";
export * from "./emitter";
export * from "./appEventBus";
export * from "./editorInterface";
export * from "./versionedSnapshotStore";
export { Debug } from "../debug";
+89
View File
@@ -3,6 +3,12 @@ import {
mapFind,
reduceToCommonValue,
} 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("isTransparent()", () => {
@@ -79,4 +85,87 @@ describe("@excalidraw/common/utils", () => {
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 === "number" ||
target.type === "password" ||
target.type === "search"));
target.type === "search")) ||
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
export const getFontFamilyString = ({
fontFamily,
@@ -150,38 +151,27 @@ export const debounce = <T extends any[]>(
return ret;
};
// throttle callback to execute once per animation frame
export const throttleRAF = <T extends any[]>(
fn: (...args: T) => void,
opts?: { trailing?: boolean },
) => {
// throttle callback to execute once per animation frame using the latest args
export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
let timerId: number | null = null;
let lastArgs: T | null = null;
let lastArgsTrailing: T | null = null;
const scheduleFunc = (args: T) => {
const scheduleFunc = () => {
timerId = window.requestAnimationFrame(() => {
timerId = null;
fn(...args);
const args = lastArgs;
lastArgs = null;
if (lastArgsTrailing) {
lastArgs = lastArgsTrailing;
lastArgsTrailing = null;
scheduleFunc(lastArgs);
if (args) {
fn(...args);
}
});
};
const ret = (...args: T) => {
if (isTestEnv()) {
fn(...args);
return;
}
lastArgs = args;
if (timerId === null) {
scheduleFunc(lastArgs);
} else if (opts?.trailing) {
lastArgsTrailing = args;
scheduleFunc();
}
};
ret.flush = () => {
@@ -190,12 +180,12 @@ export const throttleRAF = <T extends any[]>(
timerId = null;
}
if (lastArgs) {
fn(...(lastArgsTrailing || lastArgs));
lastArgs = lastArgsTrailing = null;
fn(...lastArgs);
lastArgs = null;
}
};
ret.cancel = () => {
lastArgs = lastArgsTrailing = null;
lastArgs = null;
if (timerId !== null) {
cancelAnimationFrame(timerId);
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);
});
}
}
+86
View File
@@ -11,6 +11,92 @@ 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.
-->
## Unreleased
## Excalidraw API
### Breaking changes
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
### Features
- Added `onMount` and `onInitialize` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted, and `onInitialize` fires once the initial scene has loaded.
```tsx
<Excalidraw
onMount={({ excalidrawAPI, container }) => {
console.log(container);
excalidrawAPI.scrollToContent();
}}
onInitialize={(api) => {
api.refresh();
}}
/>
```
- Same events are also accessible imperatively through `api.onEvent(...)`.
```tsx
<Excalidraw
onExcalidrawAPI={(api) => {
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
excalidrawAPI.scrollToContent();
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)`.
- Exported `ExcalidrawAPIProvider`, `useExcalidrawAPI`, and `useAppStateValue` from the package entrypoint. The imperative API also now exposes `onStateChange`.
```tsx
<ExcalidrawAPIProvider>
<Excalidraw />
<Logger />
</ExcalidrawAPIProvider>;
function Logger() {
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
## 0.18.0 (2025-03-11)
@@ -30,7 +30,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { useStylesPanelMode } from "..";
import { useStylesPanelMode } from "../components/App";
import { register } from "./register";
@@ -27,7 +27,7 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
import { useStylesPanelMode } from "..";
import { useStylesPanelMode } from "../components/App";
import { register } from "./register";
+219 -37
View File
@@ -9,18 +9,20 @@ import { getNonDeletedElements } 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 { CheckboxItem } from "../components/CheckboxItem";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { ProjectName } from "../components/ProjectName";
import { Toast } from "../components/Toast";
import { ToolButton } from "../components/ToolButton";
import { Tooltip } from "../components/Tooltip";
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
import { loadFromJSON, saveAsJSON } from "../data";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
@@ -31,7 +33,15 @@ import "../components/ToolIcon.scss";
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"]>({
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({
name: "saveToActiveFile",
label: "buttons.save",
@@ -163,42 +310,62 @@ export const actionSaveToActiveFile = register({
);
},
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 {
const { fileHandle } = isImageFileHandle(appState.fileHandle)
const { fileHandle } = isImageFileHandle(previousFileHandle)
? await resaveAsImageWithScene(
elements,
appState,
app.files,
app.getName(),
exportedDataPromise,
previousFileHandle,
filename,
)
: await saveAsJSON(elements, appState, app.files, app.getName());
: await saveAsJSON({
data: exportedDataPromise,
filename,
fileHandle: previousFileHandle,
});
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
...appState,
fileHandle,
toast: fileHandleExists
? {
message: fileHandle?.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
)
: t("toast.fileSaved"),
}
: null,
toast: {
message:
previousFileHandle && fileHandle?.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
)
: t("toast.fileSaved"),
duration: 1500,
},
},
};
} catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") {
console.error(error);
} else {
console.warn(error);
}
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
return {
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
}
},
keyTest: (event) =>
@@ -212,36 +379,50 @@ export const actionSaveFileToDisk = register({
viewMode: true,
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
if (onExportInProgress) {
return false;
}
onExportInProgress = true;
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try {
const { fileHandle } = await saveAsJSON(
elements,
{
...appState,
fileHandle: null,
},
app.files,
app.getName(),
);
const { fileHandle: savedFileHandle } = await saveAsJSON({
data: exportedDataPromise,
filename: app.getName(),
fileHandle: null,
});
return {
captureUpdate: CaptureUpdateAction.EVENTUALLY,
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
...appState,
openDialog: null,
fileHandle,
fileHandle: savedFileHandle,
toast: { message: t("toast.fileSaved") },
},
};
} catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") {
console.error(error);
} else {
console.warn(error);
}
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
return {
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
}
},
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 }) => (
<ToolButton
type="button"
@@ -300,7 +481,8 @@ export const actionExportWithDarkMode = register<
name: "exportWithDarkMode",
label: "imageExportDialog.label.darkMode",
trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value) => {
perform: (_elements, appState, value, app) => {
app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT;
return {
appState: { ...appState, exportWithDarkMode: value },
captureUpdate: CaptureUpdateAction.EVENTUALLY,
@@ -18,7 +18,7 @@ import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n";
import { useStylesPanelMode } from "..";
import { useStylesPanelMode } from "../components/App";
import type { History } from "../history";
import type { AppClassProperties, AppState } from "../types";
+2 -4
View File
@@ -33,8 +33,6 @@ import {
normalizeFile,
} from "./data/blob";
import type { FileSystemHandle } from "./data/filesystem";
import type { BinaryFiles } from "./types";
type ElementsClipboard = {
@@ -369,7 +367,7 @@ type AllowedParsedDataTransferItem =
type: ValueOf<typeof IMAGE_MIME_TYPES>;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
fileHandle: FileSystemFileHandle | null;
}
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
@@ -378,7 +376,7 @@ type ParsedDataTransferItem =
type: string;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
fileHandle: FileSystemFileHandle | null;
}
| { 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,
} 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 { actionToggleShapeSwitch } from "../../actions/actionToggleShapeSwitch";
import { getShortcutKey } from "../../shortcut";
import {
actionClearCanvas,
actionLink,
@@ -1,7 +1,9 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import {
bumpVersion,
getLinearElementSubType,
mutateElement,
updateElbowArrowPoints,
} from "@excalidraw/element";
@@ -37,6 +39,8 @@ import {
isProdEnv,
mapFind,
reduceToCommonValue,
ROUNDNESS,
sceneCoordsToViewportCoords,
updateActiveTool,
} from "@excalidraw/common";
@@ -71,12 +75,6 @@ import type {
import type { Scene } from "@excalidraw/element";
import {
bumpVersion,
mutateElement,
ROUNDNESS,
sceneCoordsToViewportCoords,
} from "..";
import { trackEvent } from "../analytics";
import { atom } from "../editor-jotai";
@@ -59,6 +59,7 @@ type ImageExportModalProps = {
actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"];
name: string;
exportWithDarkMode: boolean;
};
const ImageExportModal = ({
@@ -68,6 +69,7 @@ const ImageExportModal = ({
actionManager,
onExportImage,
name,
exportWithDarkMode,
}: ImageExportModalProps) => {
const hasSelection = isSomeElementSelected(
elementsSnapshot,
@@ -79,15 +81,13 @@ const ImageExportModal = ({
const [exportWithBackground, setExportWithBackground] = useState(
appStateSnapshot.exportBackground,
);
const [exportDarkMode, setExportDarkMode] = useState(
appStateSnapshot.exportWithDarkMode,
);
const [embedScene, setEmbedScene] = useState(
appStateSnapshot.exportEmbedScene,
);
const [exportScale, setExportScale] = useState(appStateSnapshot.exportScale);
const previewRef = useRef<HTMLDivElement>(null);
const previewRenderRequestIdRef = useRef(0);
const [renderError, setRenderError] = useState<Error | null>(null);
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
@@ -99,7 +99,7 @@ const ImageExportModal = ({
}, [
projectName,
exportWithBackground,
exportDarkMode,
exportWithDarkMode,
exportScale,
embedScene,
resetCopyStatus,
@@ -122,13 +122,18 @@ const ImageExportModal = ({
return;
}
const requestId = ++previewRenderRequestIdRef.current;
const isStaleRequest = () => {
return requestId !== previewRenderRequestIdRef.current;
};
exportToCanvas({
elements: exportedElements,
appState: {
...appStateSnapshot,
name: projectName,
exportBackground: exportWithBackground,
exportWithDarkMode: exportDarkMode,
exportWithDarkMode,
exportScale,
exportEmbedScene: embedScene,
},
@@ -137,25 +142,41 @@ const ImageExportModal = ({
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
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);
// if converting to blob fails, there's some problem that will
// 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;
});
previewNode.replaceChildren(canvas);
})
.catch((error) => {
if (isStaleRequest()) {
return;
}
console.error(error);
setRenderError(error);
});
return () => {
previewRenderRequestIdRef.current += 1;
};
}, [
appStateSnapshot,
files,
@@ -163,7 +184,7 @@ const ImageExportModal = ({
exportingFrame,
projectName,
exportWithBackground,
exportDarkMode,
exportWithDarkMode,
exportScale,
embedScene,
]);
@@ -233,9 +254,8 @@ const ImageExportModal = ({
>
<Switch
name="exportDarkModeSwitch"
checked={exportDarkMode}
checked={exportWithDarkMode}
onChange={(checked) => {
setExportDarkMode(checked);
actionManager.executeAction(
actionExportWithDarkMode,
"ui",
@@ -399,6 +419,7 @@ export const ImageExportDialog = ({
actionManager={actionManager}
onExportImage={onExportImage}
name={name}
exportWithDarkMode={appState.exportWithDarkMode}
/>
</Dialog>
);
+25 -12
View File
@@ -60,6 +60,7 @@ import { ImageExportDialog } from "./ImageExportDialog";
import { Island } from "./Island";
import { JSONExportDialog } from "./JSONExportDialog";
import { LaserPointerButton } from "./LaserPointerButton";
import { Toast } from "./Toast";
import "./LayerUI.scss";
import "./Toolbar.scss";
@@ -605,18 +606,30 @@ const LayerUI = ({
showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen}
/>
{appState.scrolledOutside && (
<button
type="button"
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState),
}));
}}
>
{t("buttons.scrollBackToContent")}
</button>
{(appState.toast || appState.scrolledOutside) && (
<div className="floating-status-stack">
{appState.toast && (
<Toast
message={appState.toast.message}
onClose={() => setAppState({ toast: null })}
duration={appState.toast.duration}
closable={appState.toast.closable}
/>
)}
{!appState.toast && appState.scrolledOutside && (
<button
type="button"
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState),
}));
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
)}
</div>
{renderSidebars()}
@@ -472,9 +472,9 @@ export const MobileToolBar = ({
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</>
)}
@@ -11,7 +11,7 @@ import { rateLimitsAtom } from "../TTDContext";
import { ChatHistoryMenu } from "./ChatHistoryMenu";
import { ChatInterface } from ".";
import { ChatInterface } from "./ChatInterface";
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 { TTDDialogPanels } from "./TTDDialogPanels";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
import {
getMermaidErrorLineNumber,
isMermaidAutoFixableError,
} from "./utils/mermaidError";
import { getMermaidAutoFixCandidates } from "./utils/mermaidAutoFix";
import {
convertMermaidToExcalidraw,
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]";
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 = ({
mermaidToExcalidrawLib,
@@ -46,8 +72,16 @@ const MermaidToExcalidraw = ({
EditorLocalStorage.get<string>(EDITOR_LS_KEYS.MERMAID_TO_EXCALIDRAW) ||
MERMAID_EXAMPLE,
);
const deferredText = useDeferredValue(text.trim());
const deferredText = useDeferredValue(text);
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 data = useRef<{
@@ -61,7 +95,7 @@ const MermaidToExcalidraw = ({
useEffect(() => {
const doRender = async () => {
try {
if (!deferredText) {
if (!deferredText.trim()) {
resetPreview({ canvasRef, setError });
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 = () => {
insertToEditor({
app,
@@ -107,6 +223,13 @@ const MermaidToExcalidraw = ({
});
};
const onApplyAutoFix = () => {
if (!autoFixCandidate) {
return;
}
setText(autoFixCandidate);
};
return (
<>
<div className="ttd-dialog-desc">
@@ -130,7 +253,8 @@ const MermaidToExcalidraw = ({
<TTDDialogInput
input={text}
placeholder={t("mermaid.inputPlaceholder")}
onChange={(event) => setText(event.target.value)}
onChange={(value) => setText(value)}
errorLine={errorLine}
onKeyboardSubmit={() => {
onInsertToEditor();
}}
@@ -153,6 +277,9 @@ const MermaidToExcalidraw = ({
canvasRef={canvasRef}
loaded={mermaidToExcalidrawLib.loaded}
error={error}
sourceText={text}
autoFixAvailable={!!autoFixCandidate}
onApplyAutoFix={onApplyAutoFix}
/>
</TTDDialogPanel>
</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 {
display: flex;
flex-direction: column;
@@ -331,14 +374,55 @@ $fullScreenModalBreakpoint: 600px;
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 {
text-align: left;
font-weight: 400;
color: var(--color-gray-50);
word-break: break-word;
white-space: pre-wrap;
max-width: 100%;
max-width: 640px;
width: 100%;
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 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 {
input: string;
placeholder: string;
onChange: ChangeEventHandler<HTMLTextAreaElement>;
onChange: (value: string) => 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 = ({
input,
placeholder,
onChange,
onKeyboardSubmit,
errorLine,
}: TTDDialogInputProps) => {
const ref = useRef<HTMLTextAreaElement>(null);
const callbackRef = useRef(onKeyboardSubmit);
callbackRef.current = onKeyboardSubmit;
const [editorState, setEditorState] = useState<EditorState>({
type: "loading",
});
const [showSpinner, setShowSpinner] = useState(false);
const { theme } = useUIAppState();
// Lazy-load CodeMirror editor
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) {
return;
}
@@ -40,15 +96,42 @@ export const TTDDialogInput = ({
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}
}, []);
}, [editorState.type]);
return (
<textarea
className="ttd-dialog-input"
onChange={onChange}
value={input}
placeholder={placeholder}
ref={ref}
/>
);
if (editorState.type === "ready") {
const CodeMirrorEditor = editorState.component;
return (
<CodeMirrorEditor
value={input}
onChange={onChange}
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 { Button } from "../Button";
import Spinner from "../Spinner";
import { t } from "../../i18n";
import { alertTriangleIcon } from "../icons";
import {
formatMermaidParseErrorMessage,
getMermaidSyntaxErrorGuidance,
isMermaidCaretLine,
} from "./utils/mermaidError";
interface TTDDialogOutputProps {
error: Error | null;
canvasRef: React.RefObject<HTMLDivElement | null>;
loaded: boolean;
hideErrorDetails?: boolean;
sourceText?: string;
autoFixAvailable?: boolean;
onApplyAutoFix?: () => void;
}
export const TTDDialogOutput = ({
@@ -16,7 +26,24 @@ export const TTDDialogOutput = ({
canvasRef,
loaded,
hideErrorDetails,
sourceText,
autoFixAvailable,
onApplyAutoFix,
}: 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 (
<div
className={`ttd-dialog-output-wrapper ${
@@ -33,14 +60,48 @@ export const TTDDialogOutput = ({
<div className="ttd-dialog-output-error-icon">
{alertTriangleIcon}
</div>
<div className="ttd-dialog-output-error-title">
{t("ttd.error")}
</div>
{syntaxGuidance && (
<div className="ttd-dialog-output-error-summary">
<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">
{hideErrorDetails
? t("chat.errors.mermaidParseError")
: error.message}
{errorMessageLines.map((line, index) => (
<span
key={`error-line-${index}`}
className={
isMermaidCaretLine(line)
? "ttd-dialog-output-error-message__caret"
: undefined
}
>
{line}
{index < errorMessageLines.length - 1 ? "\n" : ""}
</span>
))}
</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>
)}
@@ -1,4 +1,4 @@
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
import { getShortcutKey } from "../../shortcut";
export const TTDDialogSubmitShortcut = () => {
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 {
NonDeletedExcalidrawElement,
@@ -6,11 +14,6 @@ import type {
} from "@excalidraw/element/types";
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import {
convertToExcalidrawElements,
exportToCanvas,
THEME,
} from "../../index";
import type { MermaidToExcalidrawLibProps } from "./types";
@@ -72,15 +75,23 @@ export const convertMermaidToExcalidraw = async ({
const api = await mermaidToExcalidrawLib.api;
try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
} catch (err: unknown) {
const originalParseError = err as Error;
if (!mermaidDefinition.includes('"')) {
return { success: false, error: originalParseError };
}
try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
} catch (err: unknown) {
ret = await api.parseMermaidToExcalidraw(
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;
@@ -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 {
LLMMessage,
TTTDDialog,
} from "@excalidraw/excalidraw/components/TTDDialog/types";
import type { LLMMessage, TTTDDialog } from "../types";
interface RateLimitInfo {
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 {
.Toast {
$closeButtonSize: 1.2rem;
$closeButtonPadding: 0.4rem;
animation: fade-in 0.5s;
background-color: var(--button-gray-1);
border-radius: 4px;
bottom: 10px;
animation: Toast-fade-in 0.5s;
min-width: 220px;
max-width: min(360px, calc(100vw - 32px));
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;
cursor: default;
left: 50%;
margin-left: -150px;
padding: 4px 0;
position: absolute;
text-align: center;
width: 300px;
z-index: 999999;
pointer-events: none;
.Toast__message {
font-family: var(--ui-font);
font-size: 0.75rem;
line-height: 1.25rem;
text-align: center;
padding: 0 $closeButtonSize + ($closeButtonPadding);
color: var(--popup-text-color);
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 {
position: absolute;
top: 0;
right: 0;
padding: $closeButtonPadding;
pointer-events: auto;
.ToolIcon__icon {
width: $closeButtonSize;
@@ -38,7 +54,7 @@
}
}
@keyframes fade-in {
@keyframes Toast-fade-in {
from {
opacity: 0;
}
+18 -4
View File
@@ -5,11 +5,22 @@ import { ToolButton } from "./ToolButton";
import "./Toast.scss";
import type { CSSProperties } from "react";
import type { CSSProperties, ReactNode } from "react";
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,
onClose,
closable = false,
@@ -17,7 +28,7 @@ export const Toast = ({
duration = DEFAULT_TOAST_TIMEOUT,
style,
}: {
message: string;
message: ReactNode;
onClose: () => void;
closable?: boolean;
duration?: number;
@@ -47,11 +58,12 @@ export const Toast = ({
return (
<div
className="Toast"
role="status"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={style}
>
<p className="Toast__message">{message}</p>
<div className="Toast__message">{message}</div>
{closable && (
<ToolButton
icon={CloseIcon}
@@ -64,3 +76,5 @@ export const Toast = ({
</div>
);
};
export const Toast = Object.assign(ToastComponent, { ProgressBar });
@@ -6,7 +6,6 @@ import {
sceneCoordsToViewportCoords,
type EditorInterface,
} from "@excalidraw/common";
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
import type {
InteractiveCanvasRenderConfig,
@@ -24,6 +23,8 @@ import type {
import { t } from "../../i18n";
import { renderInteractiveScene } from "../../renderer/interactiveScene";
import { AnimationController } from "../../renderer/animation";
import type {
AppClassProperties,
AppState,
@@ -469,7 +469,6 @@ const PreferencesToggleMidpointSnappingItem = () => {
return (
<DropdownMenuItemCheckbox
checked={appState.isMidpointSnappingEnabled}
disabled={appState.bindingPreference === "disabled"}
onSelect={(event) => {
actionManager.executeAction(actionToggleMidpointSnapping);
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 {
@include outlineButtonStyles;
@include filledButtonOnCanvas;
+10 -9
View File
@@ -27,7 +27,6 @@ import {
import type { AppState, DataURL, LibraryItem } from "../types";
import type { FileSystemHandle } from "browser-fs-access";
import type { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File): Promise<string> => {
@@ -104,7 +103,7 @@ export const getMimeType = (blob: Blob | string): string => {
return "";
};
export const getFileHandleType = (handle: FileSystemHandle | null) => {
export const getFileHandleType = (handle: FileSystemFileHandle | null) => {
if (!handle) {
return null;
}
@@ -118,7 +117,9 @@ export const isImageFileHandleType = (
return type === "png" || type === "svg";
};
export const isImageFileHandle = (handle: FileSystemHandle | null) => {
export const isImageFileHandle = (
handle: FileSystemFileHandle | null,
): handle is FileSystemFileHandle => {
const type = getFileHandleType(handle);
return type === "png" || type === "svg";
};
@@ -139,8 +140,8 @@ export const loadSceneOrLibraryFromBlob = async (
/** @see restore.localAppState */
localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null,
/** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemFileHandle | null,
) => {
const contents = await parseFileContents(blob);
let data;
@@ -198,8 +199,8 @@ export const loadFromBlob = async (
/** @see restore.localAppState */
localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null,
/** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemFileHandle | null,
) => {
const ret = await loadSceneOrLibraryFromBlob(
blob,
@@ -392,7 +393,7 @@ export const ImageURLToFile = async (
export const getFileHandle = async (
event: DragEvent | React.DragEvent | DataTransferItem,
): Promise<FileSystemHandle | null> => {
): Promise<FileSystemFileHandle | null> => {
if (nativeFileSystemSupported) {
try {
const dataTransferItem =
@@ -400,7 +401,7 @@ export const getFileHandle = async (
? event
: (event as DragEvent).dataTransfer?.items?.[0];
const handle: FileSystemHandle | null =
const handle: FileSystemFileHandle | null =
(await (dataTransferItem as any).getAsFileSystemHandle()) || null;
return handle;
+4 -44
View File
@@ -4,18 +4,12 @@ import {
supported as nativeFileSystemSupported,
} from "browser-fs-access";
import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
import { AbortError } from "../errors";
import { MIME_TYPES } from "@excalidraw/common";
import { normalizeFile } from "./blob";
import type { FileSystemHandle } from "browser-fs-access";
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: {
extensions?: FILE_EXTENSION[];
description: string;
@@ -42,40 +36,6 @@ export const fileOpen = async <M extends boolean | undefined = false>(opts: {
extensions,
mimeTypes,
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)) {
@@ -95,8 +55,8 @@ export const fileSave = (
extension: FILE_EXTENSION;
mimeTypes?: string[];
description: string;
/** existing FileSystemHandle */
fileHandle?: FileSystemHandle | null;
/** existing FileSystemFileHandle */
fileHandle?: FileSystemFileHandle | null;
},
) => {
return _fileSave(
@@ -108,8 +68,8 @@ export const fileSave = (
mimeTypes: opts.mimeTypes,
},
opts.fileHandle,
false,
);
};
export { nativeFileSystemSupported };
export type { FileSystemHandle };
+1 -3
View File
@@ -33,8 +33,6 @@ import { canvasToBlob } from "./blob";
import { fileSave } from "./filesystem";
import { serializeAsJSON } from "./json";
import type { FileSystemHandle } from "./filesystem";
import type { ExportType } from "../scene/types";
import type { AppState, BinaryFiles } from "../types";
@@ -110,7 +108,7 @@ export const exportCanvas = async (
viewBackgroundColor: string;
/** filename, if applicable */
name?: string;
fileHandle?: FileSystemHandle | null;
fileHandle?: FileSystemFileHandle | null;
exportingFrame: ExcalidrawFrameLikeElement | null;
},
) => {
+27 -18
View File
@@ -1,12 +1,13 @@
import {
DEFAULT_FILENAME,
EXPORT_DATA_TYPES,
getExportSource,
MIME_TYPES,
VERSIONS,
} 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";
@@ -21,6 +22,12 @@ import type {
ImportedLibraryData,
} from "./types";
export type JSONExportData = {
elements: readonly NonDeleted<ExcalidrawElement>[];
appState: AppState;
files: BinaryFiles;
};
/**
* Strips out files which are only referenced by deleted elements
*/
@@ -67,27 +74,29 @@ export const serializeAsJSON = (
return JSON.stringify(data, null, 2);
};
export const saveAsJSON = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
/** filename */
name: string = appState.name || DEFAULT_FILENAME,
) => {
const serialized = serializeAsJSON(elements, appState, files, "local");
const blob = new Blob([serialized], {
type: MIME_TYPES.excalidraw,
export const saveAsJSON = async ({
data,
filename,
fileHandle,
}: {
data: MaybePromise<JSONExportData>;
filename: string;
fileHandle: AppState["fileHandle"];
}) => {
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, {
name,
const savedFileHandle = await fileSave(blob, {
name: filename,
extension: "excalidraw",
description: "Excalidraw file",
fileHandle: isImageFileHandle(appState.fileHandle)
? null
: appState.fileHandle,
fileHandle: isImageFileHandle(fileHandle) ? null : fileHandle,
});
return { fileHandle };
return { fileHandle: savedFileHandle };
};
export const loadFromJSON = async (
+21 -8
View File
@@ -1,26 +1,39 @@
import type { MaybePromise } from "@excalidraw/common/utility-types";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getFileHandleType, isImageFileHandleType } from "./blob";
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { exportCanvas, prepareElementsForExport } from ".";
import type { AppState, BinaryFiles } from "../types";
export const resaveAsImageWithScene = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
name: string,
data: MaybePromise<{
elements: readonly ExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
}>,
fileHandle: FileSystemFileHandle,
filename: string,
) => {
const { exportBackground, viewBackgroundColor, fileHandle } = appState;
const fileHandleType = getFileHandleType(fileHandle);
if (!fileHandle || !isImageFileHandleType(fileHandleType)) {
if (Math.random() < 1) {
throw new Error("OLALALALA");
}
if (!isImageFileHandleType(fileHandleType)) {
throw new Error(
"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,
exportEmbedScene: true,
@@ -35,7 +48,7 @@ export const resaveAsImageWithScene = async (
await exportCanvas(fileHandleType, exportedElements, appState, files, {
exportBackground,
viewBackgroundColor,
name,
name: filename,
fileHandle,
exportingFrame,
});
+1 -1
View File
@@ -52,7 +52,7 @@ declare module "png-chunks-extract" {
// -----------------------------------------------------------------------------
interface Blob {
handle?: import("browser-fs-acces").FileSystemHandle;
handle?: FileSystemFileHandle;
name?: string;
}
@@ -0,0 +1,110 @@
import { useEffect, useRef, useState } from "react";
import { useExcalidrawAPI } from "../components/App";
import { getDefaultAppState } from "../appState";
import type { AppState } from "../types";
/**
* 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`
*
* Calls the optional callback with the latest value on every change (not called
* on initial render).
*
* 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,
callback?: (value: AppState[K], appState: AppState) => void,
_internal?: boolean,
): AppState[K];
export function useAppStateValue(
props: (keyof AppState)[],
callback?: (props: AppState, appState: AppState) => void,
_internal?: boolean,
): AppState;
export function useAppStateValue<T>(
selector: (appState: AppState) => T,
callback?: (value: T, appState: AppState) => void,
_internal?: boolean,
): T;
export function useAppStateValue(
selector:
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown),
callback?: (value: any, appState: AppState) => void,
_internal: boolean = true,
): unknown {
const api = useExcalidrawAPI();
const getLatestValue = () => {
let appState = api?.getAppState();
if (!appState) {
if (!_internal) {
return undefined;
}
console.warn(
"useAppStateValue: excalidrawAPI not defined yet for internal component in which case 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(),
);
}
if (typeof selector === "function") {
return selector(appState);
}
if (Array.isArray(selector)) {
return appState;
}
return appState[selector];
};
const [value, setValue] = useState<unknown>(getLatestValue);
const stateRef = useRef({
selector,
callback,
isInitialized: !!api,
latestValue: value,
});
stateRef.current.selector = selector;
stateRef.current.callback = callback;
if (!stateRef.current.isInitialized && api) {
stateRef.current.isInitialized = true;
stateRef.current.latestValue = getLatestValue();
}
useEffect(() => {
if (!api) {
return;
}
return api.onStateChange(
stateRef.current.selector,
(newValue: any, state: AppState) => {
stateRef.current.latestValue = newValue;
stateRef.current.callback?.(newValue, state);
setValue(newValue);
},
);
}, [api]);
return stateRef.current.latestValue;
}
+91 -6
View File
@@ -1,14 +1,18 @@
import React, { useEffect } from "react";
import React, { useCallback, useContext, useEffect, useState } from "react";
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 Footer from "./components/footer/FooterCenter";
import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollaborationTrigger";
import MainMenu from "./components/main-menu/MainMenu";
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
import { defaultLang } from "./i18n";
import { useAppStateValue as _useAppStateValue } from "./hooks/useAppStateValue";
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
import polyfill from "./polyfill";
@@ -16,16 +20,44 @@ import "./css/app.scss";
import "./css/styles.scss";
import "./fonts/fonts.css";
import type { AppProps, ExcalidrawProps } from "./types";
import type {
AppProps,
AppState,
ExcalidrawImperativeAPI,
ExcalidrawProps,
} from "./types";
polyfill();
/**
* Stateless provider that allows `useExcalidrawAPI()` (and hooks built
* on it, such as `useAppStateValue()`) 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 {
onExport,
onChange,
onIncrement,
initialData,
excalidrawAPI,
onExcalidrawAPI,
onMount,
onInitialize,
isCollaborating = false,
onPointerUpdate,
renderTopLeftUI,
@@ -86,6 +118,15 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
UIOptions.canvasActions.toggleTheme = true;
}
const setExcalidrawAPI = useContext(ExcalidrawAPISetContext);
const handleExcalidrawAPI = useCallback(
(api: ExcalidrawImperativeAPI) => {
setExcalidrawAPI?.(api);
onExcalidrawAPI?.(api);
},
[onExcalidrawAPI, setExcalidrawAPI],
);
useEffect(() => {
const importPolyfill = async () => {
//@ts-ignore
@@ -115,10 +156,13 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
<EditorJotaiProvider store={editorJotaiStore}>
<InitializeApp langCode={langCode} theme={theme}>
<App
onExport={onExport}
onChange={onChange}
onIncrement={onIncrement}
initialData={initialData}
excalidrawAPI={excalidrawAPI}
onExcalidrawAPI={handleExcalidrawAPI}
onMount={onMount}
onInitialize={onInitialize}
isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate}
renderTopLeftUI={renderTopLeftUI}
@@ -267,6 +311,7 @@ export {
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
getFormFactor,
throttleRAF,
} from "@excalidraw/common";
export {
@@ -284,7 +329,13 @@ export { Button } from "./components/Button";
export { Footer };
export { MainMenu };
export { Ellipsify } from "./components/Ellipsify";
export { useEditorInterface, useStylesPanelMode } from "./components/App";
export {
useEditorInterface,
useStylesPanelMode,
useExcalidrawAPI,
ExcalidrawAPIContext,
} from "./components/App";
export { WelcomeScreen };
export { LiveCollaborationTrigger };
export { Stats } from "./components/Stats";
@@ -325,3 +376,37 @@ export {
tryParseSpreadsheet,
isSpreadsheetValidForChartType,
} from "./charts";
// -----------------------------------------------------------------------------
// useAppStateValue() 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 useAppStateValue<K extends keyof AppState>(
prop: K,
callback?: (value: AppState[K], appState: AppState) => void,
): AppState[K] | undefined;
export function useAppStateValue<T extends keyof AppState>(
props: T[],
callback?: (values: AppState, appState: AppState) => void,
): AppState | undefined;
export function useAppStateValue<T>(
selector: (appState: AppState) => T,
callback?: (value: T, appState: AppState) => void,
): T | undefined;
export function useAppStateValue(
selector:
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown),
callback?: (value: any, appState: AppState) => void,
) {
return _useAppStateValue(selector as any, callback, false);
}
// -----------------------------------------------------------------------------
+6 -1
View File
@@ -403,6 +403,10 @@
"errorDialog": {
"title": "Error"
},
"progressDialog": {
"title": "Saving",
"defaultMessage": "Preparing to save..."
},
"exportDialog": {
"disk_title": "Save to disk",
"disk_details": "Export the scene data to a file from which you can import later.",
@@ -624,7 +628,8 @@
"syntax": "Mermaid Syntax",
"preview": "Preview",
"label": "Mermaid",
"inputPlaceholder": "Write Mermaid diagram defintion here..."
"inputPlaceholder": "Write Mermaid diagram defintion here...",
"autoFixAvailable": "Auto-fix is available"
},
"ttd": {
"error": "Error!"
+8 -3
View File
@@ -79,14 +79,18 @@
},
"dependencies": {
"@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/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "2.0.0-rfc3",
"@excalidraw/mermaid-to-excalidraw": "2.0.0-rc4",
"@excalidraw/random-username": "1.1.0",
"radix-ui": "1.4.3",
"browser-fs-access": "0.29.1",
"browser-fs-access": "0.38.0",
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1",
"cross-env": "7.0.3",
@@ -107,6 +111,7 @@
"png-chunks-extract": "1.0.0",
"points-on-curve": "1.0.1",
"pwacompat": "2.0.17",
"radix-ui": "1.4.3",
"roughjs": "4.6.4",
"sass": "1.51.0",
"tunnel-rat": "0.1.2"
@@ -88,7 +88,6 @@ export const renderNewElementSceneThrottled = throttleRAF(
(config: NewElementSceneRenderConfig) => {
_renderNewElementScene(config);
},
{ trailing: true },
);
export const renderNewElementScene = (
@@ -483,7 +483,6 @@ export const renderStaticSceneThrottled = throttleRAF(
(config: StaticSceneRenderConfig) => {
_renderStaticScene(config);
},
{ trailing: true },
);
/**
@@ -1,5 +1,4 @@
import React from "react";
import { expect } from "vitest";
import { expect, vi } from "vitest";
import { Excalidraw } from "../index";
@@ -7,6 +6,13 @@ import { mockMermaidToExcalidraw } from "./helpers/mocks";
import { getTextEditor, updateTextEditor } from "./queries/dom";
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({
mockRef: true,
parseMermaidToExcalidraw: async (definition) => {
@@ -1,13 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
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">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 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">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"><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`] = `
"flowchart TD
@@ -2,7 +2,7 @@
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
<div
aria-labelledby="radix-:r7t:"
aria-labelledby="radix-:r85:"
aria-orientation="vertical"
class="dropdown-menu main-menu"
data-align="start"
@@ -12,7 +12,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
data-state="open"
data-testid="dropdown-menu"
dir="ltr"
id="radix-:r7u:"
id="radix-:r86:"
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;"
tabindex="-1"
+4 -1
View File
@@ -28,7 +28,9 @@ const { h } = window;
const mouse = new Pointer("mouse");
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 {
__esmodule: true,
...module,
@@ -37,6 +39,7 @@ vi.mock("@excalidraw/common", async (importOriginal) => {
...module.KEYS,
CTRL_OR_CMD: "ctrlKey",
},
throttleRAF: mockThrottleRAF,
};
});
+50 -1
View File
@@ -6,8 +6,16 @@ import { THEME } from "@excalidraw/common";
import { t } from "../i18n";
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;
@@ -304,6 +312,47 @@ describe("<Excalidraw/>", () => {
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
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", () => {
@@ -3,6 +3,25 @@ import React from "react";
import { vi } from "vitest";
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: {
parseMermaidToExcalidraw: typeof parseMermaidToExcalidraw;
@@ -6,7 +6,7 @@ import { resolvablePromise } from "@excalidraw/common";
import { Excalidraw, CaptureUpdateAction } from "../../index";
import { API } from "../helpers/api";
import { Pointer } from "../helpers/ui";
import { render } from "../test-utils";
import { render, unmountComponent } from "../test-utils";
import type { ExcalidrawImperativeAPI } from "../../types";
@@ -21,12 +21,91 @@ describe("event callbacks", () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
await render(
<Excalidraw
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
onExcalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
/>,
);
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) =>
lifecyclePromise.resolve({
api: api as ExcalidrawImperativeAPI,
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 () => {
const onChange = vi.fn();
+1 -1
View File
@@ -20,7 +20,7 @@ describe("setActiveTool()", () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
await render(
<Excalidraw
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
onExcalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
/>,
);
excalidrawAPI = await excalidrawAPIPromise;
+77 -4
View File
@@ -53,7 +53,6 @@ import type { Spreadsheet } from "./charts";
import type { ClipboardData } from "./clipboard";
import type App from "./components/App";
import type Library from "./data/library";
import type { FileSystemHandle } from "./data/filesystem";
import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping";
import type { ImportedDataState } from "./data/types";
@@ -408,7 +407,11 @@ export interface AppState {
previousSelectedElementIds: { [id: string]: true };
selectedElementsAreBeingDragged: boolean;
shouldCacheIgnoreZoom: boolean;
toast: { message: string; closable?: boolean; duration?: number } | null;
toast: {
message: React.ReactNode;
closable?: boolean;
duration?: number;
} | null;
zenModeEnabled: boolean;
theme: Theme;
/** grid cell px size */
@@ -427,7 +430,7 @@ export interface AppState {
offsetTop: number;
offsetLeft: number;
fileHandle: FileSystemHandle | null;
fileHandle: FileSystemFileHandle | null;
collaborators: Map<SocketId, Collaborator>;
stats: {
open: boolean;
@@ -546,17 +549,39 @@ export type OnUserFollowedPayload = {
action: "FOLLOW" | "UNFOLLOW";
};
export type OnExportProgress = {
type: "progress";
message?: React.ReactNode;
/** 0-1 range */
progress?: number;
};
export interface ExcalidrawProps {
onChange?: (
elements: readonly OrderedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => void;
/**
* note: only subscribes if the props.onIncrement is defined on initial render
*/
onIncrement?: (event: DurableIncrement | EphemeralIncrement) => void;
initialData?:
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
| MaybePromise<ExcalidrawInitialDataState | null>;
excalidrawAPI?: (api: ExcalidrawImperativeAPI) => void;
/**
* Invoked as soon as the Excalidraw API is available
* NOTE editor is not yet mounted, and state is not yet initialized
*/
onExcalidrawAPI?: (api: ExcalidrawImperativeAPI) => void;
/**
* Invoked once the editor root is mounted.
*/
onMount?: (payload: ExcalidrawMountPayload) => void;
/**
* Invoked once the initial scene is loaded.
*/
onInitialize?: (api: ExcalidrawImperativeAPI) => void;
isCollaborating?: boolean;
onPointerUpdate?: (payload: {
pointer: { x: number; y: number; tool: "pointer" | "laser" };
@@ -641,6 +666,32 @@ export interface ExcalidrawProps {
aiEnabled?: boolean;
showDeprecatedFonts?: boolean;
renderScrollbars?: boolean;
/**
* Called before exporting to a file.
*
* Allows the host app to intercept and delay saving until async operations
* (e.g., images are loaded) complete.
*
* If Promise/AsyncGenerator is returned, a progress toast will be shown
* until the operation completes. Generator can yield progress updates.
*/
onExport?: (
/** type of export. Currently we only call for JSON exports or
* JSON-embedded PNG (which is also identified as `json` type here)*/
type: "json",
data: {
elements: readonly ExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
},
options: {
/** signal that gets aborted if user cancels the export (e.g. closes
* the native file picker dialog). In that case, you can either
* return immediately, or throw AbortError.
*/
signal: AbortSignal;
},
) => MaybePromise<void> | AsyncGenerator<OnExportProgress, void>;
}
export type SceneData = {
@@ -719,6 +770,8 @@ export type AppProps = Merge<
export type AppClassProperties = {
props: AppProps;
state: AppState;
api: App["api"];
sessionExportThemeOverride: App["sessionExportThemeOverride"];
interactiveCanvas: HTMLCanvasElement | null;
/** static canvas */
canvas: HTMLCanvasElement;
@@ -763,9 +816,13 @@ export type AppClassProperties = {
onPointerUpEmitter: App["onPointerUpEmitter"];
updateEditorAtom: App["updateEditorAtom"];
onPointerDownEmitter: App["onPointerDownEmitter"];
onEvent: App["onEvent"];
onStateChange: App["onStateChange"];
lastPointerMoveCoords: App["lastPointerMoveCoords"];
bindModeHandler: App["bindModeHandler"];
setAppState: App["setAppState"];
};
export type PointerDownState = Readonly<{
@@ -838,6 +895,20 @@ export type PointerDownState = Readonly<{
export type UnsubscribeCallback = () => void;
export type ExcalidrawMountPayload = {
excalidrawAPI: ExcalidrawImperativeAPI;
/*
*Excalidraw container.
* should never be null, but just to be safe
*/
container: HTMLDivElement | null;
};
export type ExcalidrawImperativeAPIEventMap = {
"editor:mount": [payload: ExcalidrawMountPayload];
"editor:initialize": [api: ExcalidrawImperativeAPI];
};
export interface ExcalidrawImperativeAPI {
updateScene: InstanceType<typeof App>["updateScene"];
applyDeltas: InstanceType<typeof App>["applyDeltas"];
@@ -904,6 +975,8 @@ export interface ExcalidrawImperativeAPI {
onUserFollow: (
callback: (payload: OnUserFollowedPayload) => void,
) => UnsubscribeCallback;
onStateChange: InstanceType<typeof App>["onStateChange"];
onEvent: InstanceType<typeof App>["onEvent"];
}
export type FrameNameBounds = {
+1 -1
View File
@@ -50,7 +50,7 @@
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.3.1",
"browser-fs-access": "0.29.1",
"browser-fs-access": "0.38.0",
"pako": "2.0.3",
"perfect-freehand": "1.2.0",
"png-chunk-text": "1.0.0",
+10
View File
@@ -6,9 +6,19 @@ import "@testing-library/jest-dom";
import { vi } from "vitest";
import polyfill from "./packages/excalidraw/polyfill";
import { mockThrottleRAF } from "./packages/excalidraw/tests/helpers/mocks";
import { yellow } from "./packages/excalidraw/tests/helpers/colorize";
import { testPolyfills } from "./packages/excalidraw/tests/helpers/polyfills";
vi.mock("@excalidraw/common", async (importOriginal) => {
const module = await importOriginal<typeof import("@excalidraw/common")>();
return {
...module,
throttleRAF: mockThrottleRAF,
};
});
// mock for pep.js not working with setPointerCapture()
HTMLElement.prototype.setPointerCapture = vi.fn();
+86 -8
View File
@@ -1095,6 +1095,45 @@
resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-11.0.3.tgz#e39999307b102cff3645ec4f5b3665f5297a2224"
integrity sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==
"@codemirror/commands@^6.0.0":
version "6.10.2"
resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.10.2.tgz#338bf53ab146de7bb26da4a1d32c6a6ff4d36b39"
integrity sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==
dependencies:
"@codemirror/language" "^6.0.0"
"@codemirror/state" "^6.4.0"
"@codemirror/view" "^6.27.0"
"@lezer/common" "^1.1.0"
"@codemirror/language@^6.0.0":
version "6.12.2"
resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.12.2.tgz#7db5a46757411cf251e8f450474c05710c27d42c"
integrity sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==
dependencies:
"@codemirror/state" "^6.0.0"
"@codemirror/view" "^6.23.0"
"@lezer/common" "^1.5.0"
"@lezer/highlight" "^1.0.0"
"@lezer/lr" "^1.0.0"
style-mod "^4.0.0"
"@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0":
version "6.5.4"
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.4.tgz#f5be4b8c0d2310180d5f15a9f641c21ca69faf19"
integrity sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==
dependencies:
"@marijn/find-cluster-break" "^1.0.0"
"@codemirror/view@^6.0.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0":
version "6.39.16"
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.39.16.tgz#e9d876aba20b31df7858abd7c2a845319c70b302"
integrity sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==
dependencies:
"@codemirror/state" "^6.5.0"
crelt "^1.0.6"
style-mod "^4.1.0"
w3c-keyname "^2.2.4"
"@esbuild/aix-ppc64@0.19.10":
version "0.19.10"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.10.tgz#fb3922a0183d27446de00cf60d4f7baaadf98d84"
@@ -1492,10 +1531,10 @@
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
"@excalidraw/mermaid-to-excalidraw@2.0.0-rfc3":
version "2.0.0-rfc3"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.0.0-rfc3.tgz#2aed27280b135086d3d23878e66751819f47c3d4"
integrity sha512-OlKySL2aZwxgvO0wKpjq5fNNWWYwYGQAVMqwG3CJZ/zEf9NotTtX+Rl/WgL6qWvNgDq8/mavOnEstC+42gqnIQ==
"@excalidraw/mermaid-to-excalidraw@2.0.0-rc4":
version "2.0.0-rc4"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.0.0-rc4.tgz#9d38568de8e403fefb6a162efa71e024ea1f7e03"
integrity sha512-92efu7VTYF6appGKgbDJAZEM/YXICpYPYOfl+j1E9SVilIFCJEAg7xH2lt/c44WFtffG+rWKrYwf9KUrPYy1Qw==
dependencies:
"@excalidraw/markdown-to-text" "0.1.2"
"@mermaid-js/parser" "^0.6.3"
@@ -2045,6 +2084,30 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@lezer/common@^1.0.0", "@lezer/common@^1.1.0", "@lezer/common@^1.3.0", "@lezer/common@^1.5.0":
version "1.5.1"
resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.5.1.tgz#6e8c114ff5d36a41148e146a253734d3bb8807d3"
integrity sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==
"@lezer/highlight@^1.0.0":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.3.tgz#a20f324b71148a2ea9ba6ff42e58bbfaec702857"
integrity sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==
dependencies:
"@lezer/common" "^1.3.0"
"@lezer/lr@^1.0.0":
version "1.4.8"
resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.8.tgz#333de9bc9346057323ff09beb4cda47ccc38a498"
integrity sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==
dependencies:
"@lezer/common" "^1.0.0"
"@marijn/find-cluster-break@^1.0.0":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8"
integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==
"@mermaid-js/parser@^0.6.3":
version "0.6.3"
resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.6.3.tgz#3ce92dad2c5d696d29e11e21109c66a7886c824e"
@@ -4540,10 +4603,10 @@ braces@^3.0.3, braces@~3.0.2:
dependencies:
fill-range "^7.1.1"
browser-fs-access@0.29.1:
version "0.29.1"
resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.29.1.tgz#8a9794c73cf86b9aec74201829999c597128379c"
integrity sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==
browser-fs-access@0.38.0:
version "0.38.0"
resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.38.0.tgz#9024c5bf3d962287a08d14beebb86cb819cbb838"
integrity sha512-JveqW2w6pEZqFEEfMgCszXzYpE89dG+nPsmOdcs741mFFAROeL+iqjGEpR07RI+s0YY0EFr+4KnOoACprJTpOw==
browserslist@^4.20.3, browserslist@^4.24.0, browserslist@^4.24.4:
version "4.24.4"
@@ -4968,6 +5031,11 @@ crc-32@^0.3.0:
resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-0.3.0.tgz#6a3d3687f5baec41f7e9b99fe1953a2e5d19775e"
integrity sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==
crelt@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
cross-env@7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf"
@@ -9520,6 +9588,11 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
style-mod@^4.0.0, style-mod@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.3.tgz#6e9012255bb799bdac37e288f7671b5d71bf9f73"
integrity sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==
styled-jsx@5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.1.tgz#839a1c3aaacc4e735fed0781b8619ea5d0009d1f"
@@ -10269,6 +10342,11 @@ vscode-uri@~3.0.8:
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f"
integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==
w3c-keyname@^2.2.4:
version "2.2.8"
resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
w3c-xmlserializer@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073"