feat(packages/excalidraw): state tracking, api hook, and others (#10870)

This commit is contained in:
David Luzar
2026-03-08 23:15:18 +01:00
committed by GitHub
parent fa1f7d9f22
commit 21dd1cfacc
46 changed files with 1900 additions and 582 deletions
+22 -1
View File
@@ -39,5 +39,26 @@
"allowReferrer": true "allowReferrer": true
} }
] ]
} },
"overrides": [
{
"files": ["packages/excalidraw/**/*.{ts,tsx}"],
"excludedFiles": ["packages/excalidraw/**/*.test.{ts,tsx}", "packages/excalidraw/**/*.test.*.{ts,tsx}"],
"rules": {
"@typescript-eslint/no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@excalidraw/excalidraw"],
"message": "Do not import from the barrel 'index.tsx' files. Use direct relative imports to the specific module instead.",
"allowTypeImports": true
}
],
"paths": [".", "..", "../..", "../../..", "../../../..", "../../../../..", "../index", "../../index", "../../../index", "../../../../index"]
}
]
}
}
]
} }
+5 -5
View File
@@ -3,14 +3,14 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"@excalidraw/excalidraw": "*", "@excalidraw/excalidraw": "*",
"browser-fs-access": "0.29.1" "browser-fs-access": "0.38.0",
"react": "19.0.0",
"react-dom": "19.0.0"
}, },
"devDependencies": { "devDependencies": {
"vite": "5.0.12", "typescript": "^5",
"typescript": "^5" "vite": "5.0.12"
}, },
"scripts": { "scripts": {
"start": "vite", "start": "vite",
-36
View File
@@ -4,8 +4,6 @@ import { unstable_batchedUpdates } from "react-dom";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500;
export type ResolvablePromise<T> = Promise<T> & { export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void; resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
@@ -54,40 +52,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions, extensions,
mimeTypes, mimeTypes,
multiple: opts.multiple ?? false, multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener("keyup", scheduleRejection);
document.addEventListener("pointerup", scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (input.files?.length) {
const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType);
}
};
requestAnimationFrame(() => {
window.addEventListener("focus", focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
scheduleRejection.cancel();
window.removeEventListener("focus", focusHandler);
document.removeEventListener("keyup", scheduleRejection);
document.removeEventListener("pointerup", scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new Error("Request Aborted"));
}
};
},
}) as Promise<RetType>; }) as Promise<RetType>;
}; };
+92 -20
View File
@@ -5,6 +5,8 @@ import {
CaptureUpdateAction, CaptureUpdateAction,
reconcileElements, reconcileElements,
useEditorInterface, useEditorInterface,
ExcalidrawAPIProvider,
useExcalidrawAPI,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics"; import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -34,7 +36,6 @@ import {
import polyfill from "@excalidraw/excalidraw/polyfill"; import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob"; import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
import { t } from "@excalidraw/excalidraw/i18n"; import { t } from "@excalidraw/excalidraw/i18n";
import { import {
@@ -74,6 +75,7 @@ import type {
BinaryFiles, BinaryFiles,
ExcalidrawInitialDataState, ExcalidrawInitialDataState,
UIAppState, UIAppState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type { ResolutionType } from "@excalidraw/common/utility-types"; import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils"; import type { ResolvablePromise } from "@excalidraw/common/utils";
@@ -114,6 +116,7 @@ import {
} from "./data"; } from "./data";
import { updateStaleImageStatuses } from "./data/FileManager"; import { updateStaleImageStatuses } from "./data/FileManager";
import { FileStatusStore } from "./data/fileStatusStore";
import { import {
importFromLocalStorage, importFromLocalStorage,
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
@@ -369,6 +372,8 @@ const initializeScene = async (opts: {
}; };
const ExcalidrawWrapper = () => { const ExcalidrawWrapper = () => {
const excalidrawAPI = useExcalidrawAPI();
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe(); const isCollabDisabled = isRunningInIframe();
@@ -399,9 +404,6 @@ const ExcalidrawWrapper = () => {
}, VERSION_TIMEOUT); }, VERSION_TIMEOUT);
}, []); }, []);
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [, setShareDialogState] = useAtom(shareDialogStateAtom); const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom); const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -433,18 +435,15 @@ const ExcalidrawWrapper = () => {
} }
}, [excalidrawAPI]); }, [excalidrawAPI]);
useEffect(() => { // ---------------------------------------------------------------------------
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) { // Hoisted loadImages
return; // ---------------------------------------------------------------------------
} const loadImages = useCallback(
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
const loadImages = ( if (!data.scene || !excalidrawAPI) {
data: ResolutionType<typeof initializeScene>,
isInitialLoad = false,
) => {
if (!data.scene) {
return; return;
} }
if (collabAPI?.isCollaborating()) { if (collabAPI?.isCollaborating()) {
if (data.scene.elements) { if (data.scene.elements) {
collabAPI collabAPI
@@ -471,6 +470,12 @@ const ExcalidrawWrapper = () => {
}, [] as FileId[]) || []; }, [] as FileId[]) || [];
if (data.isExternalScene) { if (data.isExternalScene) {
if (fileIds.length) {
// Direct Firebase call (not through FileManager), so track manually
FileStatusStore.updateStatuses(
fileIds.map((id) => [id, "loading"]),
);
}
loadFilesFromFirebase( loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`, `${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key, data.key,
@@ -482,12 +487,18 @@ const ExcalidrawWrapper = () => {
erroredFiles, erroredFiles,
elements: excalidrawAPI.getSceneElementsIncludingDeleted(), elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
}); });
FileStatusStore.updateStatuses([
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
...[...erroredFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
]);
}); });
} else if (isInitialLoad) { } else if (isInitialLoad) {
if (fileIds.length) { if (fileIds.length) {
LocalData.fileStorage LocalData.fileStorage
.getFiles(fileIds) .getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => { .then(async ({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) { if (loadedFiles.length) {
excalidrawAPI.addFiles(loadedFiles); excalidrawAPI.addFiles(loadedFiles);
} }
@@ -500,10 +511,19 @@ const ExcalidrawWrapper = () => {
} }
// on fresh load, clear unused files from IDB (from previous // on fresh load, clear unused files from IDB (from previous
// session) // session)
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds }); LocalData.fileStorage.clearObsoleteFiles({
currentFileIds: fileIds,
});
} }
} }
}; },
[collabAPI, excalidrawAPI],
);
useEffect(() => {
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return;
}
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => { initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true); loadImages(data, /* isInitialLoad */ true);
@@ -628,7 +648,7 @@ const ExcalidrawWrapper = () => {
false, false,
); );
}; };
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]); }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
useEffect(() => { useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => { const unloadHandler = (event: BeforeUnloadEvent) => {
@@ -773,6 +793,56 @@ const ExcalidrawWrapper = () => {
[setShareDialogState], [setShareDialogState],
); );
// ---------------------------------------------------------------------------
// onExport — intercepts file save to wait for pending image loads
// ---------------------------------------------------------------------------
const onExport: Required<ExcalidrawProps>["onExport"] = useCallback(
async function* () {
let snapshot = FileStatusStore.getSnapshot();
const { pending, total } = FileStatusStore.getPendingCount(
snapshot.value,
);
if (pending === 0) {
return;
}
// Yield initial progress
yield {
type: "progress",
progress: (total - pending) / total,
message: `Loading images (${total - pending}/${total})...`,
};
// Wait for all pending images to finish
while (true) {
snapshot = await FileStatusStore.pull(snapshot.version);
const { pending: nowPending, total: nowTotal } =
FileStatusStore.getPendingCount(snapshot.value);
yield {
type: "progress",
progress: (nowTotal - nowPending) / nowTotal,
message: `Loading images (${nowTotal - nowPending}/${nowTotal})...`,
};
if (nowPending === 0) {
await new Promise((r) => setTimeout(r, 500));
yield {
type: "progress",
message: `Preparing export...`,
};
return;
}
}
},
[],
);
// const onExport = () => {
// return new Promise((r) => setTimeout(r, 2500));
// // console.log("onExport");
// };
// browsers generally prevent infinite self-embedding, there are // browsers generally prevent infinite self-embedding, there are
// cases where it still happens, and while we disallow self-embedding // cases where it still happens, and while we disallow self-embedding
// by not whitelisting our own origin, this serves as an additional guard // by not whitelisting our own origin, this serves as an additional guard
@@ -839,8 +909,8 @@ const ExcalidrawWrapper = () => {
})} })}
> >
<Excalidraw <Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange} onChange={onChange}
onExport={onExport}
initialData={initialStatePromiseRef.current.promise} initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate} onPointerUpdate={collabAPI?.onPointerUpdate}
@@ -1206,7 +1276,9 @@ const ExcalidrawApp = () => {
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<Provider store={appJotaiStore}> <Provider store={appJotaiStore}>
<ExcalidrawWrapper /> <ExcalidrawAPIProvider>
<ExcalidrawWrapper />
</ExcalidrawAPIProvider>
</Provider> </Provider>
</TopErrorBoundary> </TopErrorBoundary>
); );
+2
View File
@@ -72,6 +72,7 @@ import {
FileManager, FileManager,
updateStaleImageStatuses, updateStaleImageStatuses,
} from "../data/FileManager"; } from "../data/FileManager";
import { FileStatusStore } from "../data/fileStatusStore";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { import {
isSavedToFirebase, isSavedToFirebase,
@@ -149,6 +150,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}; };
this.portal = new Portal(this); this.portal = new Portal(this);
this.fileManager = new FileManager({ this.fileManager = new FileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles: async (fileIds) => { getFiles: async (fileIds) => {
const { roomId, roomKey } = this.portal; const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) { if (!roomId || !roomKey) {
+22
View File
@@ -40,10 +40,12 @@ export class FileManager {
private _getFiles; private _getFiles;
private _saveFiles; private _saveFiles;
private _onFileStatusChange;
constructor({ constructor({
getFiles, getFiles,
saveFiles, saveFiles,
onFileStatusChange,
}: { }: {
getFiles: (fileIds: FileId[]) => Promise<{ getFiles: (fileIds: FileId[]) => Promise<{
loadedFiles: BinaryFileData[]; loadedFiles: BinaryFileData[];
@@ -53,9 +55,13 @@ export class FileManager {
savedFiles: Map<FileId, BinaryFileData>; savedFiles: Map<FileId, BinaryFileData>;
erroredFiles: Map<FileId, BinaryFileData>; erroredFiles: Map<FileId, BinaryFileData>;
}>; }>;
onFileStatusChange?: (
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
) => void;
}) { }) {
this._getFiles = getFiles; this._getFiles = getFiles;
this._saveFiles = saveFiles; this._saveFiles = saveFiles;
this._onFileStatusChange = onFileStatusChange;
} }
/** /**
@@ -146,6 +152,8 @@ export class FileManager {
this.fetchingFiles.set(id, true); this.fetchingFiles.set(id, true);
} }
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
try { try {
const { loadedFiles, erroredFiles } = await this._getFiles(ids); const { loadedFiles, erroredFiles } = await this._getFiles(ids);
@@ -156,6 +164,13 @@ export class FileManager {
this.erroredFiles_fetch.set(fileId, true); this.erroredFiles_fetch.set(fileId, true);
} }
this._onFileStatusChange?.([
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
...[...erroredFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
]);
return { loadedFiles, erroredFiles }; return { loadedFiles, erroredFiles };
} finally { } finally {
for (const id of ids) { for (const id of ids) {
@@ -195,6 +210,13 @@ export class FileManager {
}; };
reset() { reset() {
if (this._onFileStatusChange && this.fetchingFiles.size) {
this._onFileStatusChange(
[...this.fetchingFiles.keys()].map(
(id) => [id, "error"] as [FileId, "error"],
),
);
}
this.fetchingFiles.clear(); this.fetchingFiles.clear();
this.savingFiles.clear(); this.savingFiles.clear();
this.savedFiles.clear(); this.savedFiles.clear();
+2
View File
@@ -42,6 +42,7 @@ import type { MaybePromise } from "@excalidraw/common/utility-types";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager"; import { FileManager } from "./FileManager";
import { FileStatusStore } from "./fileStatusStore";
import { Locker } from "./Locker"; import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync"; import { updateBrowserStateVersion } from "./tabSync";
@@ -166,6 +167,7 @@ export class LocalData {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static fileStorage = new LocalFileManager({ static fileStorage = new LocalFileManager({
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
getFiles(ids) { getFiles(ids) {
return getMany(ids, filesStore).then( return getMany(ids, filesStore).then(
async (filesData: (BinaryFileData | undefined)[]) => { async (filesData: (BinaryFileData | undefined)[]) => {
+48
View File
@@ -0,0 +1,48 @@
import { VersionedSnapshotStore } from "@excalidraw/common";
import type { FileId } from "@excalidraw/element/types";
export type FileLoadingStatus = "loading" | "loaded" | "error";
export class FileStatusStore {
private static store = new VersionedSnapshotStore<
Map<FileId, FileLoadingStatus>
>(new Map());
static getSnapshot() {
return this.store.getSnapshot();
}
static pull(sinceVersion?: number) {
return this.store.pull(sinceVersion);
}
static updateStatuses(updates: Array<[FileId, FileLoadingStatus]>) {
if (!updates.length) {
return;
}
this.store.update((prev) => {
let changed = false;
const next = new Map(prev);
for (const [id, status] of updates) {
if (next.get(id) !== status) {
next.set(id, status);
changed = true;
}
}
return changed ? next : prev;
});
}
static getPendingCount(statuses: Map<FileId, FileLoadingStatus>) {
let pending = 0;
let total = 0;
for (const status of statuses.values()) {
total++;
if (status === "loading") {
pending++;
}
}
return { pending, total };
}
}
+74
View File
@@ -0,0 +1,74 @@
import { AppEventBus } from "./appEventBus";
type TestEvents = {
initialize: [api: number];
pointerUp: [pointerId: string];
viewState: [zoom: number];
};
const behavior = {
initialize: { cardinality: "once", replay: "last" },
pointerUp: { cardinality: "many", replay: "none" },
viewState: { cardinality: "many", replay: "last" },
} as const;
const flushMicrotasks = async () => Promise.resolve();
describe("AppEventBus", () => {
it("replays once events to late callback and Promise subscribers", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("initialize", 42);
const calls: number[] = [];
bus.on("initialize", (value) => {
calls.push(value);
});
expect(calls).toEqual([]);
await flushMicrotasks();
expect(calls).toEqual([42]);
await expect(bus.on("initialize")).resolves.toBe(42);
});
it("does not replay stream events to late subscribers", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("pointerUp", "first");
const calls: string[] = [];
bus.on("pointerUp", (pointerId) => {
calls.push(pointerId);
});
await flushMicrotasks();
expect(calls).toEqual([]);
bus.emit("pointerUp", "second");
expect(calls).toEqual(["second"]);
});
it("replays replay-last stream events and stays subscribed", async () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("viewState", 1);
const calls: number[] = [];
bus.on("viewState", (zoom) => {
calls.push(zoom);
});
await flushMicrotasks();
expect(calls).toEqual([1]);
bus.emit("viewState", 2);
expect(calls).toEqual([1, 2]);
});
it("throws when emitting a once event twice", () => {
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
bus.emit("initialize", 1);
expect(() => {
bus.emit("initialize", 2);
}).toThrow('Event "initialize" can only be emitted once');
});
});
+136
View File
@@ -0,0 +1,136 @@
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
import { Emitter } from "./emitter";
import { isProdEnv } from "./utils";
export type AppEventPayloadMap = Record<string, unknown[]>;
export type AppEventBehavior = {
cardinality: "once" | "many";
replay: "none" | "last";
};
export type AppEventBehaviorMap<Events extends AppEventPayloadMap> = {
[K in keyof Events]: AppEventBehavior;
};
type AwaitableAppEventKeys<
Events extends AppEventPayloadMap,
Behavior extends AppEventBehaviorMap<Events>,
> = {
[K in keyof Events]: Behavior[K]["cardinality"] extends "once"
? Behavior[K]["replay"] extends "last"
? K
: never
: never;
}[keyof Events];
type AppEventPromiseValue<Args extends any[]> = Args extends [infer Only]
? Only
: Args;
export class AppEventBus<
Events extends AppEventPayloadMap,
Behavior extends AppEventBehaviorMap<Events>,
> {
private readonly emitters = new Map<keyof Events, Emitter<any>>();
private readonly lastPayload = new Map<keyof Events, any[]>();
private readonly emittedOnce = new Set<keyof Events>();
constructor(private readonly behavior: Behavior) {}
private getEmitter<K extends keyof Events>(name: K): Emitter<Events[K]> {
let emitter = this.emitters.get(name);
if (!emitter) {
emitter = new Emitter<any>();
this.emitters.set(name, emitter);
}
return emitter as Emitter<Events[K]>;
}
private toPromiseValue<Args extends any[]>(
args: Args,
): AppEventPromiseValue<Args> {
return (args.length === 1 ? args[0] : args) as AppEventPromiseValue<Args>;
}
public on<K extends keyof Events>(
name: K,
callback: (...args: Events[K]) => void,
): UnsubscribeCallback;
public on<K extends AwaitableAppEventKeys<Events, Behavior>>(
name: K,
): Promise<AppEventPromiseValue<Events[K]>>;
public on<K extends keyof Events>(
name: K,
callback?: (...args: Events[K]) => void,
): UnsubscribeCallback | Promise<AppEventPromiseValue<Events[K]>> {
const eventBehavior = this.behavior[name];
const cachedPayload = this.lastPayload.get(name) as Events[K] | undefined;
if (callback) {
if (eventBehavior.replay === "last" && cachedPayload) {
queueMicrotask(() => callback(...cachedPayload));
if (eventBehavior.cardinality === "once") {
return () => {};
}
}
return this.getEmitter(name).on(callback);
}
if (
eventBehavior.cardinality !== "once" ||
eventBehavior.replay !== "last"
) {
throw new Error(`Event "${String(name)}" requires a callback`);
}
if (cachedPayload) {
return Promise.resolve(this.toPromiseValue(cachedPayload));
}
return new Promise<AppEventPromiseValue<Events[K]>>((resolve) => {
this.getEmitter(name).once((...args: Events[K]) => {
resolve(this.toPromiseValue(args));
});
});
}
public emit<K extends keyof Events>(name: K, ...args: Events[K]) {
const eventBehavior = this.behavior[name];
if (!isProdEnv()) {
if (eventBehavior.cardinality === "once") {
if (this.emittedOnce.has(name)) {
throw new Error(`Event "${String(name)}" can only be emitted once`);
}
this.emittedOnce.add(name);
}
}
if (eventBehavior.replay === "last") {
this.lastPayload.set(name, args);
}
try {
this.getEmitter(name).trigger(...args);
} finally {
if (eventBehavior.cardinality === "once") {
this.getEmitter(name).clear();
}
}
}
public clear() {
this.lastPayload.clear();
this.emittedOnce.clear();
for (const emitter of this.emitters.values()) {
emitter.clear();
}
this.emitters.clear();
}
}
+2
View File
@@ -11,5 +11,7 @@ export * from "./random";
export * from "./url"; export * from "./url";
export * from "./utils"; export * from "./utils";
export * from "./emitter"; export * from "./emitter";
export * from "./appEventBus";
export * from "./editorInterface"; export * from "./editorInterface";
export * from "./versionedSnapshotStore";
export { Debug } from "../debug"; export { Debug } from "../debug";
@@ -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. 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 ## Excalidraw Library
## 0.18.0 (2025-03-11) ## 0.18.0 (2025-03-11)
@@ -30,7 +30,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { useStylesPanelMode } from ".."; import { useStylesPanelMode } from "../components/App";
import { register } from "./register"; import { register } from "./register";
@@ -27,7 +27,7 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut"; import { getShortcutKey } from "../shortcut";
import { useStylesPanelMode } from ".."; import { useStylesPanelMode } from "../components/App";
import { register } from "./register"; import { register } from "./register";
+217 -36
View File
@@ -9,18 +9,20 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { Theme } from "@excalidraw/element/types"; import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
import { useEditorInterface } from "../components/App"; import { useEditorInterface } from "../components/App";
import { CheckboxItem } from "../components/CheckboxItem"; import { CheckboxItem } from "../components/CheckboxItem";
import { DarkModeToggle } from "../components/DarkModeToggle"; import { DarkModeToggle } from "../components/DarkModeToggle";
import { ProjectName } from "../components/ProjectName"; import { ProjectName } from "../components/ProjectName";
import { Toast } from "../components/Toast";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { ExportIcon, questionCircle, saveAs } from "../components/icons"; import { ExportIcon, questionCircle, saveAs } from "../components/icons";
import { loadFromJSON, saveAsJSON } from "../data"; import { loadFromJSON, saveAsJSON } from "../data";
import { isImageFileHandle } from "../data/blob"; import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem"; import { nativeFileSystemSupported } from "../data/filesystem";
import { resaveAsImageWithScene } from "../data/resave"; import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -31,7 +33,15 @@ import "../components/ToolIcon.scss";
import { register } from "./register"; import { register } from "./register";
import type { AppState } from "../types"; import type { JSONExportData } from "../data/json";
import type {
AppClassProperties,
AppState,
BinaryFiles,
ExcalidrawProps,
OnExportProgress,
} from "../types";
export const actionChangeProjectName = register<AppState["name"]>({ export const actionChangeProjectName = register<AppState["name"]>({
name: "changeProjectName", name: "changeProjectName",
@@ -150,6 +160,143 @@ export const actionChangeExportEmbedScene = register<
), ),
}); });
// ---------------------------------------------------------------------------
// onExport interception helpers
// ---------------------------------------------------------------------------
let onExportInProgress = false;
const onProgressToast = (
app: AppClassProperties,
progress: {
message?: OnExportProgress["message"];
progress?: number | null;
},
) => {
const message = progress.message ?? t("progressDialog.defaultMessage");
app.setAppState({
toast: {
message:
progress.progress != null ? (
<>
{message}
<Toast.ProgressBar progress={progress.progress} />
</>
) : (
message
),
duration: Infinity,
},
});
};
/** awaits host app's onExport result, and renders progress to the UI */
async function handleOnExportResult(
onExportResult: ReturnType<NonNullable<ExcalidrawProps["onExport"]>>,
opts: {
signal: AbortSignal;
app: AppClassProperties;
},
): Promise<void> {
if (opts.app.state.isLoading) {
onProgressToast(opts.app, { progress: null });
await opts.app.onStateChange({ predicate: (state) => !state.isLoading });
}
if (
onExportResult != null &&
typeof onExportResult === "object" &&
Symbol.asyncIterator in onExportResult
) {
for await (const value of onExportResult) {
if (opts.signal.aborted) {
onExportResult.return();
return;
}
if (value.type === "progress") {
onProgressToast(opts.app, {
message: value.message,
progress: value.progress ?? null,
});
} else if (value.type === "done") {
return;
}
}
// Generator completed without explicit "done" message
return;
}
if (onExportResult instanceof Promise) {
onProgressToast(opts.app, { progress: null });
await onExportResult;
}
}
function prepareDataForJSONExport(
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
app: AppClassProperties,
): { abortController: AbortController; data: Promise<JSONExportData> } {
const abortController = new AbortController();
const signal = abortController.signal;
const dataPromise = new Promise<JSONExportData>(async (resolve) => {
try {
if (app.props.onExport) {
await handleOnExportResult(
app.props.onExport(
"json",
{
elements,
appState,
files,
},
{
signal,
},
),
{
app,
signal,
},
);
}
} catch (error: any) {
if (error?.name === "AbortError") {
// if abort error, assume it's a reaction on the signal being aborted
console.warn(
`onExport() aborted by host app (signal aborted: ${signal.aborted})`,
);
} else {
// non-abort error
//
console.error("Error during props.onExport() handling", error);
}
// either way, we currently don't allow host apps to cancel save actions
// so we resolve to orig data
}
resolve({
elements,
appState,
// return latest files in case they finished loading during onExport
files: app.files,
});
});
return {
abortController,
data: dataPromise,
};
}
// ---------------------------------------------------------------------------
// Save actions
// ---------------------------------------------------------------------------
export const actionSaveToActiveFile = register({ export const actionSaveToActiveFile = register({
name: "saveToActiveFile", name: "saveToActiveFile",
label: "buttons.save", label: "buttons.save",
@@ -163,42 +310,62 @@ export const actionSaveToActiveFile = register({
); );
}, },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle; if (onExportInProgress) {
return false;
}
onExportInProgress = true;
const previousFileHandle = appState.fileHandle;
const filename = app.getName();
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try { try {
const { fileHandle } = isImageFileHandle(appState.fileHandle) const { fileHandle } = isImageFileHandle(previousFileHandle)
? await resaveAsImageWithScene( ? await resaveAsImageWithScene(
elements, exportedDataPromise,
appState, previousFileHandle,
app.files, filename,
app.getName(),
) )
: await saveAsJSON(elements, appState, app.files, app.getName()); : await saveAsJSON({
data: exportedDataPromise,
filename,
fileHandle: previousFileHandle,
});
return { return {
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.NEVER,
appState: { appState: {
...appState,
fileHandle, fileHandle,
toast: fileHandleExists toast: {
? { message:
message: fileHandle?.name previousFileHandle && fileHandle?.name
? t("toast.fileSavedToFilename").replace( ? t("toast.fileSavedToFilename").replace(
"{filename}", "{filename}",
`"${fileHandle.name}"`, `"${fileHandle.name}"`,
) )
: t("toast.fileSaved"), : t("toast.fileSaved"),
} duration: 1500,
: null, },
}, },
}; };
} catch (error: any) { } catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else { } else {
console.warn(error); console.warn(error);
} }
return { captureUpdate: CaptureUpdateAction.EVENTUALLY }; return {
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
} }
}, },
keyTest: (event) => keyTest: (event) =>
@@ -212,36 +379,50 @@ export const actionSaveFileToDisk = register({
viewMode: true, viewMode: true,
trackEvent: { category: "export" }, trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => { perform: async (elements, appState, value, app) => {
if (onExportInProgress) {
return false;
}
onExportInProgress = true;
const { abortController, data: exportedDataPromise } =
prepareDataForJSONExport(elements, appState, app.files, app);
try { try {
const { fileHandle } = await saveAsJSON( const { fileHandle: savedFileHandle } = await saveAsJSON({
elements, data: exportedDataPromise,
{ filename: app.getName(),
...appState, fileHandle: null,
fileHandle: null, });
},
app.files,
app.getName(),
);
return { return {
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.NEVER,
appState: { appState: {
...appState,
openDialog: null, openDialog: null,
fileHandle, fileHandle: savedFileHandle,
toast: { message: t("toast.fileSaved") }, toast: { message: t("toast.fileSaved") },
}, },
}; };
} catch (error: any) { } catch (error: any) {
abortController.abort();
if (error?.name !== "AbortError") { if (error?.name !== "AbortError") {
console.error(error); console.error(error);
} else { } else {
console.warn(error); console.warn(error);
} }
return { captureUpdate: CaptureUpdateAction.EVENTUALLY }; return {
captureUpdate: CaptureUpdateAction.NEVER,
appState: {
toast: null,
},
};
} finally {
onExportInProgress = false;
} }
}, },
keyTest: (event) => keyTest: (event) =>
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD], event.key.toLowerCase() === KEYS.S &&
event.shiftKey &&
event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<ToolButton <ToolButton
type="button" type="button"
@@ -18,7 +18,7 @@ import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter"; import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n"; import { t } from "../i18n";
import { useStylesPanelMode } from ".."; import { useStylesPanelMode } from "../components/App";
import type { History } from "../history"; import type { History } from "../history";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
+2 -4
View File
@@ -33,8 +33,6 @@ import {
normalizeFile, normalizeFile,
} from "./data/blob"; } from "./data/blob";
import type { FileSystemHandle } from "./data/filesystem";
import type { BinaryFiles } from "./types"; import type { BinaryFiles } from "./types";
type ElementsClipboard = { type ElementsClipboard = {
@@ -369,7 +367,7 @@ type AllowedParsedDataTransferItem =
type: ValueOf<typeof IMAGE_MIME_TYPES>; type: ValueOf<typeof IMAGE_MIME_TYPES>;
kind: "file"; kind: "file";
file: File; file: File;
fileHandle: FileSystemHandle | null; fileHandle: FileSystemFileHandle | null;
} }
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string }; | { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
@@ -378,7 +376,7 @@ type ParsedDataTransferItem =
type: string; type: string;
kind: "file"; kind: "file";
file: File; file: File;
fileHandle: FileSystemHandle | null; fileHandle: FileSystemFileHandle | null;
} }
| { type: string; kind: "string"; value: string }; | { type: string; kind: "string"; value: string };
+360 -319
View File
@@ -88,6 +88,7 @@ import {
isShallowEqual, isShallowEqual,
arrayToMap, arrayToMap,
applyDarkModeFilter, applyDarkModeFilter,
AppEventBus,
type EXPORT_IMAGE_TYPES, type EXPORT_IMAGE_TYPES,
randomInteger, randomInteger,
CLASSES, CLASSES,
@@ -448,7 +449,7 @@ import { StaticCanvas, InteractiveCanvas } from "./canvases";
import NewElementCanvas from "./canvases/NewElementCanvas"; import NewElementCanvas from "./canvases/NewElementCanvas";
import { isPointHittingLink } from "./hyperlink/helpers"; import { isPointHittingLink } from "./hyperlink/helpers";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { Toast } from "./Toast"; import { AppStateObserver, type OnStateChange } from "./AppStateObserver";
import { findShapeByKey } from "./shapes"; import { findShapeByKey } from "./shapes";
@@ -464,7 +465,6 @@ import type {
import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { ClipboardData, PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import type { ContextMenuItems } from "./ContextMenu"; import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem";
import type { import type {
AppClassProperties, AppClassProperties,
@@ -488,6 +488,7 @@ import type {
UnsubscribeCallback, UnsubscribeCallback,
EmbedsValidationStatus, EmbedsValidationStatus,
ElementsPendingErasure, ElementsPendingErasure,
ExcalidrawImperativeAPIEventMap,
GenerateDiagramToCode, GenerateDiagramToCode,
NullableGridSize, NullableGridSize,
Offsets, Offsets,
@@ -513,6 +514,11 @@ const EditorInterfaceContext = React.createContext<EditorInterface>(
); );
EditorInterfaceContext.displayName = "EditorInterfaceContext"; EditorInterfaceContext.displayName = "EditorInterfaceContext";
const editorLifecycleEventBehavior = {
"editor:mount": { cardinality: "once", replay: "last" },
"editor:initialize": { cardinality: "once", replay: "last" },
} as const;
export const ExcalidrawContainerContext = React.createContext<{ export const ExcalidrawContainerContext = React.createContext<{
container: HTMLDivElement | null; container: HTMLDivElement | null;
id: string | null; id: string | null;
@@ -545,6 +551,15 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
); );
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext"; ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
export const ExcalidrawAPIContext =
React.createContext<ExcalidrawImperativeAPI | null>(null);
ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext";
export const ExcalidrawAPISetContext = React.createContext<
((api: ExcalidrawImperativeAPI) => void) | null
>(null);
ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext";
export const useApp = () => useContext(AppContext); export const useApp = () => useContext(AppContext);
export const useAppProps = () => useContext(AppPropsContext); export const useAppProps = () => useContext(AppPropsContext);
export const useEditorInterface = () => export const useEditorInterface = () =>
@@ -561,6 +576,10 @@ export const useExcalidrawSetAppState = () =>
useContext(ExcalidrawSetAppStateContext); useContext(ExcalidrawSetAppStateContext);
export const useExcalidrawActionManager = () => export const useExcalidrawActionManager = () =>
useContext(ExcalidrawActionManagerContext); useContext(ExcalidrawActionManagerContext);
/**
* Requires wrapping your component in <ExcalidrawAPIContext.Provider>
*/
export const useExcalidrawAPI = () => useContext(ExcalidrawAPIContext);
let didTapTwice: boolean = false; let didTapTwice: boolean = false;
let tappedTwiceTimer = 0; let tappedTwiceTimer = 0;
@@ -635,12 +654,26 @@ class App extends React.Component<AppProps, AppState> {
* insert to DOM before user initially scrolls to them) */ * insert to DOM before user initially scrolls to them) */
private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>(); private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
private handleToastClose = () => {
this.setToast(null);
};
private elementsPendingErasure: ElementsPendingErasure = new Set(); private elementsPendingErasure: ElementsPendingErasure = new Set();
private _initialized = false;
private readonly editorLifecycleEvents = new AppEventBus<
ExcalidrawImperativeAPIEventMap,
typeof editorLifecycleEventBehavior
>(editorLifecycleEventBehavior);
public onEvent = this.editorLifecycleEvents.on.bind(
this.editorLifecycleEvents,
) as AppEventBus<
ExcalidrawImperativeAPIEventMap,
typeof editorLifecycleEventBehavior
>["on"];
private appStateObserver = new AppStateObserver(() => this.state);
public onStateChange: OnStateChange = this.appStateObserver.onStateChange;
public flowChartCreator: FlowChartCreator = new FlowChartCreator(); public flowChartCreator: FlowChartCreator = new FlowChartCreator();
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator(); private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
@@ -696,11 +729,12 @@ class App extends React.Component<AppProps, AppState> {
>(); >();
onRemoveEventListenersEmitter = new Emitter<[]>(); onRemoveEventListenersEmitter = new Emitter<[]>();
api: ExcalidrawImperativeAPI;
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
const { const {
excalidrawAPI,
viewModeEnabled = false, viewModeEnabled = false,
zenModeEnabled = false, zenModeEnabled = false,
gridModeEnabled = false, gridModeEnabled = false,
@@ -708,6 +742,7 @@ class App extends React.Component<AppProps, AppState> {
theme = defaultAppState.theme, theme = defaultAppState.theme,
name = `${t("labels.untitled")}-${getDateTime()}`, name = `${t("labels.untitled")}-${getDateTime()}`,
} = props; } = props;
this.state = { this.state = {
...defaultAppState, ...defaultAppState,
theme, theme,
@@ -744,51 +779,6 @@ class App extends React.Component<AppProps, AppState> {
this.store = new Store(this); this.store = new Store(this);
this.history = new History(this.store); this.history = new History(this.store);
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene,
applyDeltas: this.applyDeltas,
mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles,
resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
getSceneElementsMapIncludingDeleted:
this.getSceneElementsMapIncludingDeleted,
history: {
clear: this.resetHistory,
},
scrollToContent: this.scrollToContent,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
getFiles: () => this.files,
getName: this.getName,
registerAction: (action: Action) => {
this.actionManager.registerAction(action);
},
refresh: this.refresh,
setToast: this.setToast,
id: this.id,
setActiveTool: this.setActiveTool,
setCursor: this.setCursor,
resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface,
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
} as const;
if (typeof excalidrawAPI === "function") {
excalidrawAPI(api);
} else {
console.error("excalidrawAPI should be a function!");
}
}
this.excalidrawContainerValue = { this.excalidrawContainerValue = {
container: this.excalidrawContainerRef.current, container: this.excalidrawContainerRef.current,
id: this.id, id: this.id,
@@ -800,6 +790,48 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.registerAll(actions); this.actionManager.registerAll(actions);
this.actionManager.registerAction(createUndoAction(this.history)); this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history)); this.actionManager.registerAction(createRedoAction(this.history));
this.api = {
updateScene: this.updateScene,
applyDeltas: this.applyDeltas,
mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles,
resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
getSceneElementsMapIncludingDeleted:
this.getSceneElementsMapIncludingDeleted,
history: {
clear: this.resetHistory,
},
scrollToContent: this.scrollToContent,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
getFiles: () => this.files,
getName: this.getName,
registerAction: (action: Action) => {
this.actionManager.registerAction(action);
},
refresh: this.refresh,
setToast: this.setToast,
id: this.id,
setActiveTool: this.setActiveTool,
setCursor: this.setCursor,
resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface,
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
onStateChange: this.onStateChange,
onEvent: this.onEvent,
} as const;
props.onExcalidrawAPI?.(this.api);
} }
updateEditorAtom = <Value, Args extends unknown[], Result>( updateEditorAtom = <Value, Args extends unknown[], Result>(
@@ -2042,282 +2074,279 @@ class App extends React.Component<AppProps, AppState> {
onPointerEnter={this.toggleOverscrollBehavior} onPointerEnter={this.toggleOverscrollBehavior}
onPointerLeave={this.toggleOverscrollBehavior} onPointerLeave={this.toggleOverscrollBehavior}
> >
<AppContext.Provider value={this}> <ExcalidrawAPIContext.Provider value={this.api}>
<AppPropsContext.Provider value={this.props}> <AppContext.Provider value={this}>
<ExcalidrawContainerContext.Provider <AppPropsContext.Provider value={this.props}>
value={this.excalidrawContainerValue} <ExcalidrawContainerContext.Provider
> value={this.excalidrawContainerValue}
<EditorInterfaceContext.Provider value={this.editorInterface}> >
<ExcalidrawSetAppStateContext.Provider value={this.setAppState}> <EditorInterfaceContext.Provider value={this.editorInterface}>
<ExcalidrawAppStateContext.Provider value={this.state}> <ExcalidrawSetAppStateContext.Provider
<ExcalidrawElementsContext.Provider value={this.setAppState}
value={this.scene.getNonDeletedElements()} >
> <ExcalidrawAppStateContext.Provider value={this.state}>
<ExcalidrawActionManagerContext.Provider <ExcalidrawElementsContext.Provider
value={this.actionManager} value={this.scene.getNonDeletedElements()}
> >
<LayerUI <ExcalidrawActionManagerContext.Provider
canvas={this.canvas} value={this.actionManager}
appState={this.state}
files={this.files}
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getNonDeletedElements()}
onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle}
langCode={getLanguage().code}
renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" &&
this.state.zenModeEnabled
}
UIOptions={this.props.UIOptions}
onExportImage={this.onExportImage}
renderWelcomeScreen={
!this.state.isLoading &&
this.state.showWelcomeScreen &&
this.state.activeTool.type ===
this.state.preferredSelectionTool.type &&
!this.state.zenModeEnabled &&
!this.scene.getElementsIncludingDeleted().length
}
app={this}
isCollaborating={this.props.isCollaborating}
generateLinkForSelection={
this.props.generateLinkForSelection
}
> >
{this.props.children} <LayerUI
</LayerUI> canvas={this.canvas}
appState={this.state}
files={this.files}
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getNonDeletedElements()}
onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle}
langCode={getLanguage().code}
renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled ===
"undefined" && this.state.zenModeEnabled
}
UIOptions={this.props.UIOptions}
onExportImage={this.onExportImage}
renderWelcomeScreen={
!this.state.isLoading &&
this.state.showWelcomeScreen &&
this.state.activeTool.type ===
this.state.preferredSelectionTool.type &&
!this.state.zenModeEnabled &&
!this.scene.getElementsIncludingDeleted().length
}
app={this}
isCollaborating={this.props.isCollaborating}
generateLinkForSelection={
this.props.generateLinkForSelection
}
>
{this.props.children}
</LayerUI>
<div className="excalidraw-textEditorContainer" /> <div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" /> <div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" /> <div className="excalidraw-eye-dropper-container" />
<SVGLayer <SVGLayer
trails={[ trails={[
this.laserTrails, this.laserTrails,
this.lassoTrail, this.lassoTrail,
this.eraserTrail, this.eraserTrail,
]} ]}
/> />
{selectedElements.length === 1 && {selectedElements.length === 1 &&
this.state.openDialog?.name !== this.state.openDialog?.name !==
"elementLinkSelector" && "elementLinkSelector" &&
this.state.showHyperlinkPopup && ( this.state.showHyperlinkPopup && (
<Hyperlink <Hyperlink
key={firstSelectedElement.id} key={firstSelectedElement.id}
element={firstSelectedElement} element={firstSelectedElement}
scene={this.scene} scene={this.scene}
setAppState={this.setAppState} setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen} onLinkOpen={this.props.onLinkOpen}
setToast={this.setToast} setToast={this.setToast}
updateEmbedValidationStatus={ updateEmbedValidationStatus={
this.updateEmbedValidationStatus this.updateEmbedValidationStatus
} }
/>
)}
{this.props.aiEnabled !== false &&
selectedElements.length === 1 &&
isMagicFrameElement(firstSelectedElement) && (
<ElementCanvasButtons
element={firstSelectedElement}
elementsMap={elementsMap}
>
<ElementCanvasButton
title={t("labels.convertToCode")}
icon={MagicIcon}
checked={false}
onChange={() =>
this.onMagicFrameGenerate(
firstSelectedElement,
"button",
)
}
/>
</ElementCanvasButtons>
)}
{selectedElements.length === 1 &&
isIframeElement(firstSelectedElement) &&
firstSelectedElement.customData?.generationData
?.status === "done" && (
<ElementCanvasButtons
element={firstSelectedElement}
elementsMap={elementsMap}
>
<ElementCanvasButton
title={t("labels.copySource")}
icon={copyIcon}
checked={false}
onChange={() =>
this.onIframeSrcCopy(firstSelectedElement)
}
/>
<ElementCanvasButton
title="Enter fullscreen"
icon={fullscreenIcon}
checked={false}
onChange={() => {
const iframe =
this.getHTMLIFrameElement(
firstSelectedElement,
);
if (iframe) {
try {
iframe.requestFullscreen();
this.setState({
activeEmbeddable: {
element: firstSelectedElement,
state: "active",
},
selectedElementIds: {
[firstSelectedElement.id]: true,
},
newElement: null,
selectionElement: null,
});
} catch (err: any) {
console.warn(err);
this.setState({
errorMessage:
"Couldn't enter fullscreen",
});
}
}
}}
/>
</ElementCanvasButtons>
)}
{this.state.contextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
top={this.state.contextMenu.top}
left={this.state.contextMenu.left}
actionManager={this.actionManager}
onClose={(callback) => {
this.setState({ contextMenu: null }, () => {
this.focusContainer();
callback?.();
});
}}
/> />
)} )}
{this.props.aiEnabled !== false && <StaticCanvas
selectedElements.length === 1 && canvas={this.canvas}
isMagicFrameElement(firstSelectedElement) && (
<ElementCanvasButtons
element={firstSelectedElement}
elementsMap={elementsMap}
>
<ElementCanvasButton
title={t("labels.convertToCode")}
icon={MagicIcon}
checked={false}
onChange={() =>
this.onMagicFrameGenerate(
firstSelectedElement,
"button",
)
}
/>
</ElementCanvasButtons>
)}
{selectedElements.length === 1 &&
isIframeElement(firstSelectedElement) &&
firstSelectedElement.customData?.generationData
?.status === "done" && (
<ElementCanvasButtons
element={firstSelectedElement}
elementsMap={elementsMap}
>
<ElementCanvasButton
title={t("labels.copySource")}
icon={copyIcon}
checked={false}
onChange={() =>
this.onIframeSrcCopy(firstSelectedElement)
}
/>
<ElementCanvasButton
title="Enter fullscreen"
icon={fullscreenIcon}
checked={false}
onChange={() => {
const iframe =
this.getHTMLIFrameElement(
firstSelectedElement,
);
if (iframe) {
try {
iframe.requestFullscreen();
this.setState({
activeEmbeddable: {
element: firstSelectedElement,
state: "active",
},
selectedElementIds: {
[firstSelectedElement.id]: true,
},
newElement: null,
selectionElement: null,
});
} catch (err: any) {
console.warn(err);
this.setState({
errorMessage:
"Couldn't enter fullscreen",
});
}
}
}}
/>
</ElementCanvasButtons>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={this.handleToastClose}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
{this.state.contextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
top={this.state.contextMenu.top}
left={this.state.contextMenu.left}
actionManager={this.actionManager}
onClose={(callback) => {
this.setState({ contextMenu: null }, () => {
this.focusContainer();
callback?.();
});
}}
/>
)}
<StaticCanvas
canvas={this.canvas}
rc={this.rc}
elementsMap={elementsMap}
allElementsMap={allElementsMap}
visibleElements={visibleElements}
sceneNonce={sceneNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
scale={window.devicePixelRatio}
appState={this.state}
renderConfig={{
imageCache: this.imageCache,
isExporting: false,
renderGrid: isGridModeEnabled(this),
canvasBackgroundColor:
this.state.viewBackgroundColor,
embedsValidationStatus: this.embedsValidationStatus,
elementsPendingErasure: this.elementsPendingErasure,
pendingFlowchartNodes:
this.flowChartCreator.pendingNodes,
theme: this.state.theme,
}}
/>
{this.state.newElement && (
<NewElementCanvas
appState={this.state}
scale={window.devicePixelRatio}
rc={this.rc} rc={this.rc}
elementsMap={elementsMap} elementsMap={elementsMap}
allElementsMap={allElementsMap} allElementsMap={allElementsMap}
visibleElements={visibleElements}
sceneNonce={sceneNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
scale={window.devicePixelRatio}
appState={this.state}
renderConfig={{ renderConfig={{
imageCache: this.imageCache, imageCache: this.imageCache,
isExporting: false, isExporting: false,
renderGrid: false, renderGrid: isGridModeEnabled(this),
canvasBackgroundColor: canvasBackgroundColor:
this.state.viewBackgroundColor, this.state.viewBackgroundColor,
embedsValidationStatus: embedsValidationStatus:
this.embedsValidationStatus, this.embedsValidationStatus,
elementsPendingErasure: elementsPendingErasure:
this.elementsPendingErasure, this.elementsPendingErasure,
pendingFlowchartNodes: null, pendingFlowchartNodes:
this.flowChartCreator.pendingNodes,
theme: this.state.theme, theme: this.state.theme,
}} }}
/> />
)} {this.state.newElement && (
<InteractiveCanvas <NewElementCanvas
app={this} appState={this.state}
containerRef={this.excalidrawContainerRef} scale={window.devicePixelRatio}
canvas={this.interactiveCanvas} rc={this.rc}
elementsMap={elementsMap} elementsMap={elementsMap}
visibleElements={visibleElements} allElementsMap={allElementsMap}
allElementsMap={allElementsMap} renderConfig={{
selectedElements={selectedElements} imageCache: this.imageCache,
sceneNonce={sceneNonce} isExporting: false,
selectionNonce={ renderGrid: false,
this.state.selectionElement?.versionNonce canvasBackgroundColor:
} this.state.viewBackgroundColor,
scale={window.devicePixelRatio} embedsValidationStatus:
appState={this.state} this.embedsValidationStatus,
renderScrollbars={ elementsPendingErasure:
this.props.renderScrollbars === true this.elementsPendingErasure,
} pendingFlowchartNodes: null,
editorInterface={this.editorInterface} theme: this.state.theme,
renderInteractiveSceneCallback={ }}
this.renderInteractiveSceneCallback />
} )}
handleCanvasRef={this.handleInteractiveCanvasRef} <InteractiveCanvas
onContextMenu={this.handleCanvasContextMenu}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.handleCanvasPointerUp}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={this.handleCanvasDoubleClick}
/>
{this.state.userToFollow && (
<FollowMode
width={this.state.width}
height={this.state.height}
userToFollow={this.state.userToFollow}
onDisconnect={this.maybeUnfollowRemoteUser}
/>
)}
{this.renderFrameNames()}
{this.state.activeLockedId && (
<UnlockPopup
app={this} app={this}
activeLockedId={this.state.activeLockedId} containerRef={this.excalidrawContainerRef}
canvas={this.interactiveCanvas}
elementsMap={elementsMap}
visibleElements={visibleElements}
allElementsMap={allElementsMap}
selectedElements={selectedElements}
sceneNonce={sceneNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
scale={window.devicePixelRatio}
appState={this.state}
renderScrollbars={
this.props.renderScrollbars === true
}
editorInterface={this.editorInterface}
renderInteractiveSceneCallback={
this.renderInteractiveSceneCallback
}
handleCanvasRef={this.handleInteractiveCanvasRef}
onContextMenu={this.handleCanvasContextMenu}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.handleCanvasPointerUp}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={this.handleCanvasDoubleClick}
/> />
)} {this.state.userToFollow && (
{showShapeSwitchPanel && ( <FollowMode
<ConvertElementTypePopup app={this} /> width={this.state.width}
)} height={this.state.height}
</ExcalidrawActionManagerContext.Provider> userToFollow={this.state.userToFollow}
{this.renderEmbeddables()} onDisconnect={this.maybeUnfollowRemoteUser}
</ExcalidrawElementsContext.Provider> />
</ExcalidrawAppStateContext.Provider> )}
</ExcalidrawSetAppStateContext.Provider> {this.renderFrameNames()}
</EditorInterfaceContext.Provider> {this.state.activeLockedId && (
</ExcalidrawContainerContext.Provider> <UnlockPopup
</AppPropsContext.Provider> app={this}
</AppContext.Provider> activeLockedId={this.state.activeLockedId}
/>
)}
{showShapeSwitchPanel && (
<ConvertElementTypePopup app={this} />
)}
</ExcalidrawActionManagerContext.Provider>
{this.renderEmbeddables()}
</ExcalidrawElementsContext.Provider>
</ExcalidrawAppStateContext.Provider>
</ExcalidrawSetAppStateContext.Provider>
</EditorInterfaceContext.Provider>
</ExcalidrawContainerContext.Provider>
</AppPropsContext.Provider>
</AppContext.Provider>
</ExcalidrawAPIContext.Provider>
</div> </div>
); );
} }
@@ -3015,12 +3044,10 @@ class App extends React.Component<AppProps, AppState> {
this.history.record(increment.delta); this.history.record(increment.delta);
}); });
const { onIncrement } = this.props;
// per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation // per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation
if (onIncrement) { if (this.props.onIncrement) {
this.store.onStoreIncrementEmitter.on((increment) => { this.store.onStoreIncrementEmitter.on((increment) => {
onIncrement(increment); this.props.onIncrement?.(increment);
}); });
} }
@@ -3054,6 +3081,14 @@ class App extends React.Component<AppProps, AppState> {
errorMessage: <BraveMeasureTextError />, errorMessage: <BraveMeasureTextError />,
}); });
} }
const mountPayload = {
excalidrawAPI: this.api,
container: this.excalidrawContainerRef.current,
};
this.editorLifecycleEvents.emit("editor:mount", mountPayload);
this.props.onMount?.(mountPayload);
} }
public componentWillUnmount() { public componentWillUnmount() {
@@ -3074,6 +3109,8 @@ class App extends React.Component<AppProps, AppState> {
this.onChangeEmitter.clear(); this.onChangeEmitter.clear();
this.store.onStoreIncrementEmitter.clear(); this.store.onStoreIncrementEmitter.clear();
this.store.onDurableIncrementEmitter.clear(); this.store.onDurableIncrementEmitter.clear();
this.appStateObserver.clear();
this.editorLifecycleEvents.clear();
ShapeCache.destroy(); ShapeCache.destroy();
SnapCache.destroy(); SnapCache.destroy();
clearTimeout(touchTimeout); clearTimeout(touchTimeout);
@@ -3239,6 +3276,15 @@ class App extends React.Component<AppProps, AppState> {
} }
componentDidUpdate(prevProps: AppProps, prevState: AppState) { componentDidUpdate(prevProps: AppProps, prevState: AppState) {
// must be updated *before* state change listeners are triggered below
if (!this._initialized && !this.state.isLoading) {
this._initialized = true;
this.editorLifecycleEvents.emit("editor:initialize", this.api);
this.props.onInitialize?.(this.api);
}
this.appStateObserver.flush(prevState);
this.updateEmbeddables(); this.updateEmbeddables();
const elements = this.scene.getElementsIncludingDeleted(); const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getElementsMapIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted();
@@ -4324,13 +4370,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState(state); this.setState(state);
}; };
setToast = ( setToast = (toast: AppState["toast"]) => {
toast: {
message: string;
closable?: boolean;
duration?: number;
} | null,
) => {
this.setState({ toast }); this.setState({ toast });
}; };
@@ -5155,7 +5195,8 @@ class App extends React.Component<AppProps, AppState> {
// eye dropper // eye dropper
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
const lowerCased = event.key.toLocaleLowerCase(); const lowerCased = event.key.toLocaleLowerCase();
const isPickingStroke = lowerCased === KEYS.S && event.shiftKey; const isPickingStroke =
lowerCased === KEYS.S && event.shiftKey && !event[KEYS.CTRL_OR_CMD];
const isPickingBackground = const isPickingBackground =
event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey); event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey);
@@ -11602,7 +11643,7 @@ class App extends React.Component<AppProps, AppState> {
loadFileToCanvas = async ( loadFileToCanvas = async (
file: File, file: File,
fileHandle: FileSystemHandle | null, fileHandle: FileSystemFileHandle | null,
) => { ) => {
file = await normalizeFile(file); file = await normalizeFile(file);
try { try {
@@ -0,0 +1,208 @@
import type { AppState, UnsubscribeCallback } from "../types";
type StateChangeSelector =
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown);
type StateChangePredicateOptions = {
predicate: (appState: AppState) => boolean;
callback?: (appState: AppState) => void;
once?: boolean;
};
type StateChangeArg = StateChangeSelector | StateChangePredicateOptions;
type StateChangeListener = {
predicate: (appState: AppState, prevState: AppState) => boolean;
getValue: (appState: AppState) => unknown;
callback: (value: any, appState: AppState) => void;
once: boolean;
};
type NormalizedStateChange = {
predicate: StateChangeListener["predicate"];
getValue: StateChangeListener["getValue"];
callback?: StateChangeListener["callback"];
once: boolean;
matchesImmediately: boolean;
};
export type OnStateChange = {
<K extends keyof AppState>(
prop: K,
callback: (value: AppState[K], appState: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
<K extends keyof AppState>(prop: K): Promise<AppState[K]>;
(
prop: (keyof AppState)[],
callback: (appState: AppState, appState2: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
(prop: (keyof AppState)[]): Promise<AppState>;
<T>(
prop: (appState: AppState) => T,
callback: (value: T, appState: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
<T>(prop: (appState: AppState) => T): Promise<T>;
(opts: {
predicate: (appState: AppState) => boolean;
callback: (appState: AppState) => void;
once?: boolean;
}): UnsubscribeCallback;
(opts: { predicate: (appState: AppState) => boolean }): Promise<AppState>;
(
selector: StateChangeSelector,
callback: (value: any, appState: AppState) => void,
): any;
};
export class AppStateObserver {
private listeners: StateChangeListener[] = [];
constructor(private readonly getState: () => AppState) {}
private isStateChangePredicateOptions(
propOrOpts: StateChangeArg,
): propOrOpts is StateChangePredicateOptions {
return (
typeof propOrOpts === "object" &&
!Array.isArray(propOrOpts) &&
"predicate" in propOrOpts
);
}
private subscribe(listener: StateChangeListener): UnsubscribeCallback {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(
(existingListener) => existingListener !== listener,
);
};
}
private normalize(
propOrOpts: StateChangeArg,
callback?: (value: any, appState: AppState) => void,
opts?: { once: boolean },
): NormalizedStateChange {
let predicate: StateChangeListener["predicate"];
let getValue: StateChangeListener["getValue"];
let normalizedCallback = callback;
let once = opts?.once ?? false;
let matchesImmediately = false;
if (this.isStateChangePredicateOptions(propOrOpts)) {
const {
predicate: predicateFn,
callback: callbackFromOpts,
once: onceFromOpts,
} = propOrOpts;
predicate = predicateFn;
getValue = (appState: AppState) => appState;
normalizedCallback = callbackFromOpts
? (_value: AppState, appState: AppState) => callbackFromOpts(appState)
: undefined;
once = onceFromOpts ?? false;
matchesImmediately = predicateFn(this.getState());
} else if (typeof propOrOpts === "function") {
const selector = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
selector(appState) !== selector(prevState);
getValue = (appState: AppState) => selector(appState);
} else if (Array.isArray(propOrOpts)) {
const keys = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
keys.some((key) => appState[key] !== prevState[key]);
getValue = (appState: AppState) => appState;
} else {
const key = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
appState[key] !== prevState[key];
getValue = (appState: AppState) => appState[key];
}
return {
predicate,
getValue,
callback: normalizedCallback,
once,
matchesImmediately,
};
}
public onStateChange: OnStateChange = ((
propOrOpts: StateChangeArg,
callback?: any,
opts?: { once: boolean },
) => {
const {
predicate,
getValue,
callback: stateChangeCallback,
once,
matchesImmediately,
} = this.normalize(propOrOpts, callback, opts);
if (stateChangeCallback) {
if (matchesImmediately) {
queueMicrotask(() => {
const state = this.getState();
stateChangeCallback(getValue(state), state);
});
if (once) {
return () => {};
}
}
return this.subscribe({
predicate,
getValue,
callback: stateChangeCallback,
once,
});
}
if (matchesImmediately) {
return Promise.resolve(getValue(this.getState()));
}
return new Promise<any>((resolve) => {
this.subscribe({
predicate,
getValue,
callback: (value) => resolve(value),
once: true,
});
});
}) as OnStateChange;
public flush(prevState: AppState) {
if (!this.listeners.length) {
return;
}
const state = this.getState();
const listenersToKeep: StateChangeListener[] = [];
for (const listener of this.listeners) {
if (listener.predicate(state, prevState)) {
listener.callback(listener.getValue(state), state);
if (!listener.once) {
listenersToKeep.push(listener);
}
} else {
listenersToKeep.push(listener);
}
}
this.listeners = listenersToKeep;
}
public clear() {
this.listeners = [];
}
}
@@ -10,12 +10,11 @@ import {
isWritableElement, isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
import type { MarkRequired } from "@excalidraw/common/utility-types"; import type { MarkRequired } from "@excalidraw/common/utility-types";
import { actionToggleShapeSwitch } from "../../actions/actionToggleShapeSwitch";
import { getShortcutKey } from "../../shortcut";
import { import {
actionClearCanvas, actionClearCanvas,
actionLink, actionLink,
@@ -1,7 +1,9 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { import {
bumpVersion,
getLinearElementSubType, getLinearElementSubType,
mutateElement,
updateElbowArrowPoints, updateElbowArrowPoints,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -37,6 +39,8 @@ import {
isProdEnv, isProdEnv,
mapFind, mapFind,
reduceToCommonValue, reduceToCommonValue,
ROUNDNESS,
sceneCoordsToViewportCoords,
updateActiveTool, updateActiveTool,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -71,12 +75,6 @@ import type {
import type { Scene } from "@excalidraw/element"; import type { Scene } from "@excalidraw/element";
import {
bumpVersion,
mutateElement,
ROUNDNESS,
sceneCoordsToViewportCoords,
} from "..";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { atom } from "../editor-jotai"; import { atom } from "../editor-jotai";
+25 -12
View File
@@ -60,6 +60,7 @@ import { ImageExportDialog } from "./ImageExportDialog";
import { Island } from "./Island"; import { Island } from "./Island";
import { JSONExportDialog } from "./JSONExportDialog"; import { JSONExportDialog } from "./JSONExportDialog";
import { LaserPointerButton } from "./LaserPointerButton"; import { LaserPointerButton } from "./LaserPointerButton";
import { Toast } from "./Toast";
import "./LayerUI.scss"; import "./LayerUI.scss";
import "./Toolbar.scss"; import "./Toolbar.scss";
@@ -605,18 +606,30 @@ const LayerUI = ({
showExitZenModeBtn={showExitZenModeBtn} showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen} renderWelcomeScreen={renderWelcomeScreen}
/> />
{appState.scrolledOutside && ( {(appState.toast || appState.scrolledOutside) && (
<button <div className="floating-status-stack">
type="button" {appState.toast && (
className="scroll-back-to-content" <Toast
onClick={() => { message={appState.toast.message}
setAppState((appState) => ({ onClose={() => setAppState({ toast: null })}
...calculateScrollCenter(elements, appState), duration={appState.toast.duration}
})); closable={appState.toast.closable}
}} />
> )}
{t("buttons.scrollBackToContent")} {!appState.toast && appState.scrolledOutside && (
</button> <button
type="button"
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState),
}));
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
)} )}
</div> </div>
{renderSidebars()} {renderSidebars()}
@@ -11,7 +11,7 @@ import { rateLimitsAtom } from "../TTDContext";
import { ChatHistoryMenu } from "./ChatHistoryMenu"; import { ChatHistoryMenu } from "./ChatHistoryMenu";
import { ChatInterface } from "."; import { ChatInterface } from "./ChatInterface";
import type { TTDPanelAction } from "../TTDDialogPanel"; import type { TTDPanelAction } from "../TTDDialogPanel";
@@ -1,4 +1,4 @@
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut"; import { getShortcutKey } from "../../shortcut";
export const TTDDialogSubmitShortcut = () => { export const TTDDialogSubmitShortcut = () => {
return ( return (
@@ -1,4 +1,12 @@
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common"; import {
DEFAULT_EXPORT_PADDING,
EDITOR_LS_KEYS,
THEME,
} from "@excalidraw/common";
import { convertToExcalidrawElements } from "@excalidraw/element";
import { exportToCanvas } from "@excalidraw/utils";
import type { import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
@@ -6,11 +14,6 @@ import type {
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { EditorLocalStorage } from "../../data/EditorLocalStorage"; import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import {
convertToExcalidrawElements,
exportToCanvas,
THEME,
} from "../../index";
import type { MermaidToExcalidrawLibProps } from "./types"; import type { MermaidToExcalidrawLibProps } from "./types";
@@ -1,9 +1,6 @@
import { RequestError } from "@excalidraw/excalidraw/errors"; import { RequestError } from "../../../errors";
import type { import type { LLMMessage, TTTDDialog } from "../types";
LLMMessage,
TTTDDialog,
} from "@excalidraw/excalidraw/components/TTDDialog/types";
interface RateLimitInfo { interface RateLimitInfo {
rateLimit?: number; rateLimit?: number;
+31 -15
View File
@@ -1,35 +1,51 @@
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.Toast { .Toast {
$closeButtonSize: 1.2rem; $closeButtonSize: 1.2rem;
$closeButtonPadding: 0.4rem; $closeButtonPadding: 0.4rem;
animation: fade-in 0.5s; animation: Toast-fade-in 0.5s;
background-color: var(--button-gray-1); min-width: 220px;
border-radius: 4px; max-width: min(360px, calc(100vw - 32px));
bottom: 10px; border-radius: var(--border-radius-lg);
border: 1px solid var(--default-border-color);
background-color: var(--island-bg-color);
color: var(--text-primary-color);
padding: 0.5rem 0.75rem;
box-shadow: 0 0 0 1px var(--color-surface-lowest);
box-sizing: border-box; box-sizing: border-box;
cursor: default; cursor: default;
left: 50%; pointer-events: none;
margin-left: -150px;
padding: 4px 0;
position: absolute;
text-align: center;
width: 300px;
z-index: 999999;
.Toast__message { .Toast__message {
font-family: var(--ui-font);
font-size: 0.75rem;
line-height: 1.25rem;
text-align: center;
padding: 0 $closeButtonSize + ($closeButtonPadding); padding: 0 $closeButtonSize + ($closeButtonPadding);
color: var(--popup-text-color);
white-space: pre-wrap; white-space: pre-wrap;
} }
.Toast__progress-bar {
margin-top: 0.35rem;
width: 100%;
height: 4px;
border-radius: 999px;
background-color: var(--button-gray-2);
overflow: hidden;
}
.Toast__progress-bar-fill {
height: 100%;
border-radius: inherit;
background-color: var(--color-primary);
}
.close { .close {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
padding: $closeButtonPadding; padding: $closeButtonPadding;
pointer-events: auto;
.ToolIcon__icon { .ToolIcon__icon {
width: $closeButtonSize; width: $closeButtonSize;
@@ -38,7 +54,7 @@
} }
} }
@keyframes fade-in { @keyframes Toast-fade-in {
from { from {
opacity: 0; opacity: 0;
} }
+18 -4
View File
@@ -5,11 +5,22 @@ import { ToolButton } from "./ToolButton";
import "./Toast.scss"; import "./Toast.scss";
import type { CSSProperties } from "react"; import type { CSSProperties, ReactNode } from "react";
const DEFAULT_TOAST_TIMEOUT = 5000; const DEFAULT_TOAST_TIMEOUT = 5000;
export const Toast = ({ const ProgressBar = ({ progress }: { progress: number }) => (
<div className="Toast__progress-bar">
<div
className="Toast__progress-bar-fill"
style={{
width: `${Math.min(5, Math.round(progress * 100))}%`,
}}
/>
</div>
);
const ToastComponent = ({
message, message,
onClose, onClose,
closable = false, closable = false,
@@ -17,7 +28,7 @@ export const Toast = ({
duration = DEFAULT_TOAST_TIMEOUT, duration = DEFAULT_TOAST_TIMEOUT,
style, style,
}: { }: {
message: string; message: ReactNode;
onClose: () => void; onClose: () => void;
closable?: boolean; closable?: boolean;
duration?: number; duration?: number;
@@ -47,11 +58,12 @@ export const Toast = ({
return ( return (
<div <div
className="Toast" className="Toast"
role="status"
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
style={style} style={style}
> >
<p className="Toast__message">{message}</p> <div className="Toast__message">{message}</div>
{closable && ( {closable && (
<ToolButton <ToolButton
icon={CloseIcon} icon={CloseIcon}
@@ -64,3 +76,5 @@ export const Toast = ({
</div> </div>
); );
}; };
export const Toast = Object.assign(ToastComponent, { ProgressBar });
@@ -6,7 +6,6 @@ import {
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
type EditorInterface, type EditorInterface,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
import type { import type {
InteractiveCanvasRenderConfig, InteractiveCanvasRenderConfig,
@@ -24,6 +23,8 @@ import type {
import { t } from "../../i18n"; import { t } from "../../i18n";
import { renderInteractiveScene } from "../../renderer/interactiveScene"; import { renderInteractiveScene } from "../../renderer/interactiveScene";
import { AnimationController } from "../../renderer/animation";
import type { import type {
AppClassProperties, AppClassProperties,
AppState, AppState,
+20
View File
@@ -500,6 +500,26 @@ body.excalidraw-cursor-resize * {
} }
} }
.floating-status-stack {
position: absolute;
left: 50%;
bottom: 30px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
pointer-events: none;
.scroll-back-to-content {
position: static;
left: auto;
bottom: auto;
transform: none;
pointer-events: var(--ui-pointerEvents);
}
}
.help-icon { .help-icon {
@include outlineButtonStyles; @include outlineButtonStyles;
@include filledButtonOnCanvas; @include filledButtonOnCanvas;
+10 -9
View File
@@ -27,7 +27,6 @@ import {
import type { AppState, DataURL, LibraryItem } from "../types"; import type { AppState, DataURL, LibraryItem } from "../types";
import type { FileSystemHandle } from "browser-fs-access";
import type { ImportedLibraryData } from "./types"; import type { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File): Promise<string> => { const parseFileContents = async (blob: Blob | File): Promise<string> => {
@@ -104,7 +103,7 @@ export const getMimeType = (blob: Blob | string): string => {
return ""; return "";
}; };
export const getFileHandleType = (handle: FileSystemHandle | null) => { export const getFileHandleType = (handle: FileSystemFileHandle | null) => {
if (!handle) { if (!handle) {
return null; return null;
} }
@@ -118,7 +117,9 @@ export const isImageFileHandleType = (
return type === "png" || type === "svg"; return type === "png" || type === "svg";
}; };
export const isImageFileHandle = (handle: FileSystemHandle | null) => { export const isImageFileHandle = (
handle: FileSystemFileHandle | null,
): handle is FileSystemFileHandle => {
const type = getFileHandleType(handle); const type = getFileHandleType(handle);
return type === "png" || type === "svg"; return type === "png" || type === "svg";
}; };
@@ -139,8 +140,8 @@ export const loadSceneOrLibraryFromBlob = async (
/** @see restore.localAppState */ /** @see restore.localAppState */
localAppState: AppState | null, localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null, localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */ /** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null, fileHandle?: FileSystemFileHandle | null,
) => { ) => {
const contents = await parseFileContents(blob); const contents = await parseFileContents(blob);
let data; let data;
@@ -198,8 +199,8 @@ export const loadFromBlob = async (
/** @see restore.localAppState */ /** @see restore.localAppState */
localAppState: AppState | null, localAppState: AppState | null,
localElements: readonly ExcalidrawElement[] | null, localElements: readonly ExcalidrawElement[] | null,
/** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */ /** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */
fileHandle?: FileSystemHandle | null, fileHandle?: FileSystemFileHandle | null,
) => { ) => {
const ret = await loadSceneOrLibraryFromBlob( const ret = await loadSceneOrLibraryFromBlob(
blob, blob,
@@ -392,7 +393,7 @@ export const ImageURLToFile = async (
export const getFileHandle = async ( export const getFileHandle = async (
event: DragEvent | React.DragEvent | DataTransferItem, event: DragEvent | React.DragEvent | DataTransferItem,
): Promise<FileSystemHandle | null> => { ): Promise<FileSystemFileHandle | null> => {
if (nativeFileSystemSupported) { if (nativeFileSystemSupported) {
try { try {
const dataTransferItem = const dataTransferItem =
@@ -400,7 +401,7 @@ export const getFileHandle = async (
? event ? event
: (event as DragEvent).dataTransfer?.items?.[0]; : (event as DragEvent).dataTransfer?.items?.[0];
const handle: FileSystemHandle | null = const handle: FileSystemFileHandle | null =
(await (dataTransferItem as any).getAsFileSystemHandle()) || null; (await (dataTransferItem as any).getAsFileSystemHandle()) || null;
return handle; return handle;
+4 -44
View File
@@ -4,18 +4,12 @@ import {
supported as nativeFileSystemSupported, supported as nativeFileSystemSupported,
} from "browser-fs-access"; } from "browser-fs-access";
import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common"; import { MIME_TYPES } from "@excalidraw/common";
import { AbortError } from "../errors";
import { normalizeFile } from "./blob"; import { normalizeFile } from "./blob";
import type { FileSystemHandle } from "browser-fs-access";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 5000;
export const fileOpen = async <M extends boolean | undefined = false>(opts: { export const fileOpen = async <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[]; extensions?: FILE_EXTENSION[];
description: string; description: string;
@@ -42,40 +36,6 @@ export const fileOpen = async <M extends boolean | undefined = false>(opts: {
extensions, extensions,
mimeTypes, mimeTypes,
multiple: opts.multiple ?? false, multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
document.addEventListener(EVENT.KEYUP, scheduleRejection);
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (input.files?.length) {
const ret = opts.multiple ? [...input.files] : input.files[0];
resolve(ret as RetType);
}
};
requestAnimationFrame(() => {
window.addEventListener(EVENT.FOCUS, focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
scheduleRejection.cancel();
window.removeEventListener(EVENT.FOCUS, focusHandler);
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new AbortError());
}
};
},
}); });
if (Array.isArray(files)) { if (Array.isArray(files)) {
@@ -95,8 +55,8 @@ export const fileSave = (
extension: FILE_EXTENSION; extension: FILE_EXTENSION;
mimeTypes?: string[]; mimeTypes?: string[];
description: string; description: string;
/** existing FileSystemHandle */ /** existing FileSystemFileHandle */
fileHandle?: FileSystemHandle | null; fileHandle?: FileSystemFileHandle | null;
}, },
) => { ) => {
return _fileSave( return _fileSave(
@@ -108,8 +68,8 @@ export const fileSave = (
mimeTypes: opts.mimeTypes, mimeTypes: opts.mimeTypes,
}, },
opts.fileHandle, opts.fileHandle,
false,
); );
}; };
export { nativeFileSystemSupported }; export { nativeFileSystemSupported };
export type { FileSystemHandle };
+1 -3
View File
@@ -33,8 +33,6 @@ import { canvasToBlob } from "./blob";
import { fileSave } from "./filesystem"; import { fileSave } from "./filesystem";
import { serializeAsJSON } from "./json"; import { serializeAsJSON } from "./json";
import type { FileSystemHandle } from "./filesystem";
import type { ExportType } from "../scene/types"; import type { ExportType } from "../scene/types";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles } from "../types";
@@ -110,7 +108,7 @@ export const exportCanvas = async (
viewBackgroundColor: string; viewBackgroundColor: string;
/** filename, if applicable */ /** filename, if applicable */
name?: string; name?: string;
fileHandle?: FileSystemHandle | null; fileHandle?: FileSystemFileHandle | null;
exportingFrame: ExcalidrawFrameLikeElement | null; exportingFrame: ExcalidrawFrameLikeElement | null;
}, },
) => { ) => {
+27 -18
View File
@@ -1,12 +1,13 @@
import { import {
DEFAULT_FILENAME,
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
getExportSource, getExportSource,
MIME_TYPES, MIME_TYPES,
VERSIONS, VERSIONS,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement, NonDeleted } from "@excalidraw/element/types";
import type { MaybePromise } from "@excalidraw/common/utility-types";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
@@ -21,6 +22,12 @@ import type {
ImportedLibraryData, ImportedLibraryData,
} from "./types"; } from "./types";
export type JSONExportData = {
elements: readonly NonDeleted<ExcalidrawElement>[];
appState: AppState;
files: BinaryFiles;
};
/** /**
* Strips out files which are only referenced by deleted elements * Strips out files which are only referenced by deleted elements
*/ */
@@ -67,27 +74,29 @@ export const serializeAsJSON = (
return JSON.stringify(data, null, 2); return JSON.stringify(data, null, 2);
}; };
export const saveAsJSON = async ( export const saveAsJSON = async ({
elements: readonly ExcalidrawElement[], data,
appState: AppState, filename,
files: BinaryFiles, fileHandle,
/** filename */ }: {
name: string = appState.name || DEFAULT_FILENAME, data: MaybePromise<JSONExportData>;
) => { filename: string;
const serialized = serializeAsJSON(elements, appState, files, "local"); fileHandle: AppState["fileHandle"];
const blob = new Blob([serialized], { }) => {
type: MIME_TYPES.excalidraw, const blob = Promise.resolve(data).then(({ elements, appState, files }) => {
const serialized = serializeAsJSON(elements, appState, files, "local");
return new Blob([serialized], {
type: MIME_TYPES.excalidraw,
});
}); });
const fileHandle = await fileSave(blob, { const savedFileHandle = await fileSave(blob, {
name, name: filename,
extension: "excalidraw", extension: "excalidraw",
description: "Excalidraw file", description: "Excalidraw file",
fileHandle: isImageFileHandle(appState.fileHandle) fileHandle: isImageFileHandle(fileHandle) ? null : fileHandle,
? null
: appState.fileHandle,
}); });
return { fileHandle }; return { fileHandle: savedFileHandle };
}; };
export const loadFromJSON = async ( export const loadFromJSON = async (
+21 -8
View File
@@ -1,26 +1,39 @@
import type { MaybePromise } from "@excalidraw/common/utility-types";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getFileHandleType, isImageFileHandleType } from "./blob"; import { getFileHandleType, isImageFileHandleType } from "./blob";
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { exportCanvas, prepareElementsForExport } from "."; import { exportCanvas, prepareElementsForExport } from ".";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles } from "../types";
export const resaveAsImageWithScene = async ( export const resaveAsImageWithScene = async (
elements: readonly ExcalidrawElement[], data: MaybePromise<{
appState: AppState, elements: readonly ExcalidrawElement[];
files: BinaryFiles, appState: AppState;
name: string, files: BinaryFiles;
}>,
fileHandle: FileSystemFileHandle,
filename: string,
) => { ) => {
const { exportBackground, viewBackgroundColor, fileHandle } = appState;
const fileHandleType = getFileHandleType(fileHandle); const fileHandleType = getFileHandleType(fileHandle);
if (!fileHandle || !isImageFileHandleType(fileHandleType)) { if (Math.random() < 1) {
throw new Error("OLALALALA");
}
if (!isImageFileHandleType(fileHandleType)) {
throw new Error( throw new Error(
"fileHandle should exist and should be of type svg or png when resaving", "fileHandle should exist and should be of type svg or png when resaving",
); );
} }
let { elements, appState, files } = await data;
const { exportBackground, viewBackgroundColor } = appState;
appState = { appState = {
...appState, ...appState,
exportEmbedScene: true, exportEmbedScene: true,
@@ -35,7 +48,7 @@ export const resaveAsImageWithScene = async (
await exportCanvas(fileHandleType, exportedElements, appState, files, { await exportCanvas(fileHandleType, exportedElements, appState, files, {
exportBackground, exportBackground,
viewBackgroundColor, viewBackgroundColor,
name, name: filename,
fileHandle, fileHandle,
exportingFrame, exportingFrame,
}); });
+1 -1
View File
@@ -52,7 +52,7 @@ declare module "png-chunks-extract" {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
interface Blob { interface Blob {
handle?: import("browser-fs-acces").FileSystemHandle; handle?: FileSystemFileHandle;
name?: string; name?: string;
} }
@@ -0,0 +1,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;
}
+90 -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 { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common";
import App from "./components/App"; import App, {
ExcalidrawAPIContext,
ExcalidrawAPISetContext,
} from "./components/App";
import { InitializeApp } from "./components/InitializeApp"; import { InitializeApp } from "./components/InitializeApp";
import Footer from "./components/footer/FooterCenter"; import Footer from "./components/footer/FooterCenter";
import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollaborationTrigger"; import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollaborationTrigger";
import MainMenu from "./components/main-menu/MainMenu"; import MainMenu from "./components/main-menu/MainMenu";
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen"; import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
import { defaultLang } from "./i18n"; import { defaultLang } from "./i18n";
import { useAppStateValue as _useAppStateValue } from "./hooks/useAppStateValue";
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai"; import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
import polyfill from "./polyfill"; import polyfill from "./polyfill";
@@ -16,16 +20,44 @@ import "./css/app.scss";
import "./css/styles.scss"; import "./css/styles.scss";
import "./fonts/fonts.css"; import "./fonts/fonts.css";
import type { AppProps, ExcalidrawProps } from "./types"; import type {
AppProps,
AppState,
ExcalidrawImperativeAPI,
ExcalidrawProps,
} from "./types";
polyfill(); polyfill();
/**
* Stateless provider that allows `useExcalidrawAPI()` (and hooks built
* on it, such as `useAppStateValue()`) to work outside the <Excalidraw>
* component tree.
*/
export const ExcalidrawAPIProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null);
return (
<ExcalidrawAPIContext.Provider value={api}>
<ExcalidrawAPISetContext.Provider value={setApi}>
{children}
</ExcalidrawAPISetContext.Provider>
</ExcalidrawAPIContext.Provider>
);
};
const ExcalidrawBase = (props: ExcalidrawProps) => { const ExcalidrawBase = (props: ExcalidrawProps) => {
const { const {
onExport,
onChange, onChange,
onIncrement, onIncrement,
initialData, initialData,
excalidrawAPI, onExcalidrawAPI,
onMount,
onInitialize,
isCollaborating = false, isCollaborating = false,
onPointerUpdate, onPointerUpdate,
renderTopLeftUI, renderTopLeftUI,
@@ -86,6 +118,15 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
UIOptions.canvasActions.toggleTheme = true; UIOptions.canvasActions.toggleTheme = true;
} }
const setExcalidrawAPI = useContext(ExcalidrawAPISetContext);
const handleExcalidrawAPI = useCallback(
(api: ExcalidrawImperativeAPI) => {
setExcalidrawAPI?.(api);
onExcalidrawAPI?.(api);
},
[onExcalidrawAPI, setExcalidrawAPI],
);
useEffect(() => { useEffect(() => {
const importPolyfill = async () => { const importPolyfill = async () => {
//@ts-ignore //@ts-ignore
@@ -115,10 +156,13 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
<EditorJotaiProvider store={editorJotaiStore}> <EditorJotaiProvider store={editorJotaiStore}>
<InitializeApp langCode={langCode} theme={theme}> <InitializeApp langCode={langCode} theme={theme}>
<App <App
onExport={onExport}
onChange={onChange} onChange={onChange}
onIncrement={onIncrement} onIncrement={onIncrement}
initialData={initialData} initialData={initialData}
excalidrawAPI={excalidrawAPI} onExcalidrawAPI={handleExcalidrawAPI}
onMount={onMount}
onInitialize={onInitialize}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate} onPointerUpdate={onPointerUpdate}
renderTopLeftUI={renderTopLeftUI} renderTopLeftUI={renderTopLeftUI}
@@ -285,7 +329,13 @@ export { Button } from "./components/Button";
export { Footer }; export { Footer };
export { MainMenu }; export { MainMenu };
export { Ellipsify } from "./components/Ellipsify"; export { Ellipsify } from "./components/Ellipsify";
export { useEditorInterface, useStylesPanelMode } from "./components/App"; export {
useEditorInterface,
useStylesPanelMode,
useExcalidrawAPI,
ExcalidrawAPIContext,
} from "./components/App";
export { WelcomeScreen }; export { WelcomeScreen };
export { LiveCollaborationTrigger }; export { LiveCollaborationTrigger };
export { Stats } from "./components/Stats"; export { Stats } from "./components/Stats";
@@ -326,3 +376,37 @@ export {
tryParseSpreadsheet, tryParseSpreadsheet,
isSpreadsheetValidForChartType, isSpreadsheetValidForChartType,
} from "./charts"; } 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);
}
// -----------------------------------------------------------------------------
+4
View File
@@ -403,6 +403,10 @@
"errorDialog": { "errorDialog": {
"title": "Error" "title": "Error"
}, },
"progressDialog": {
"title": "Saving",
"defaultMessage": "Preparing to save..."
},
"exportDialog": { "exportDialog": {
"disk_title": "Save to disk", "disk_title": "Save to disk",
"disk_details": "Export the scene data to a file from which you can import later.", "disk_details": "Export the scene data to a file from which you can import later.",
+2 -2
View File
@@ -90,8 +90,7 @@
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "2.0.0-rc4", "@excalidraw/mermaid-to-excalidraw": "2.0.0-rc4",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"radix-ui": "1.4.3", "browser-fs-access": "0.38.0",
"browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
@@ -112,6 +111,7 @@
"png-chunks-extract": "1.0.0", "png-chunks-extract": "1.0.0",
"points-on-curve": "1.0.1", "points-on-curve": "1.0.1",
"pwacompat": "2.0.17", "pwacompat": "2.0.17",
"radix-ui": "1.4.3",
"roughjs": "4.6.4", "roughjs": "4.6.4",
"sass": "1.51.0", "sass": "1.51.0",
"tunnel-rat": "0.1.2" "tunnel-rat": "0.1.2"
@@ -6,7 +6,7 @@ import { resolvablePromise } from "@excalidraw/common";
import { Excalidraw, CaptureUpdateAction } from "../../index"; import { Excalidraw, CaptureUpdateAction } from "../../index";
import { API } from "../helpers/api"; import { API } from "../helpers/api";
import { Pointer } from "../helpers/ui"; import { Pointer } from "../helpers/ui";
import { render } from "../test-utils"; import { render, unmountComponent } from "../test-utils";
import type { ExcalidrawImperativeAPI } from "../../types"; import type { ExcalidrawImperativeAPI } from "../../types";
@@ -21,12 +21,91 @@ describe("event callbacks", () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>(); const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
await render( await render(
<Excalidraw <Excalidraw
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)} onExcalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
/>, />,
); );
excalidrawAPI = await excalidrawAPIPromise; excalidrawAPI = await excalidrawAPIPromise;
}); });
it("should resolve editor:mount/editor:initialize when subscribed before mount", async () => {
unmountComponent();
const lifecyclePromise = resolvablePromise<{
api: ExcalidrawImperativeAPI;
mount: Promise<{
excalidrawAPI: ExcalidrawImperativeAPI;
container: HTMLDivElement | null;
}>;
initialize: Promise<ExcalidrawImperativeAPI>;
}>();
await render(
<Excalidraw
onExcalidrawAPI={(api) =>
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 () => { it("should trigger onChange on render", async () => {
const onChange = vi.fn(); const onChange = vi.fn();
+1 -1
View File
@@ -20,7 +20,7 @@ describe("setActiveTool()", () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>(); const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
await render( await render(
<Excalidraw <Excalidraw
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)} onExcalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
/>, />,
); );
excalidrawAPI = await excalidrawAPIPromise; excalidrawAPI = await excalidrawAPIPromise;
+76 -4
View File
@@ -53,7 +53,6 @@ import type { Spreadsheet } from "./charts";
import type { ClipboardData } from "./clipboard"; import type { ClipboardData } from "./clipboard";
import type App from "./components/App"; import type App from "./components/App";
import type Library from "./data/library"; import type Library from "./data/library";
import type { FileSystemHandle } from "./data/filesystem";
import type { ContextMenuItems } from "./components/ContextMenu"; import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping"; import type { SnapLine } from "./snapping";
import type { ImportedDataState } from "./data/types"; import type { ImportedDataState } from "./data/types";
@@ -408,7 +407,11 @@ export interface AppState {
previousSelectedElementIds: { [id: string]: true }; previousSelectedElementIds: { [id: string]: true };
selectedElementsAreBeingDragged: boolean; selectedElementsAreBeingDragged: boolean;
shouldCacheIgnoreZoom: boolean; shouldCacheIgnoreZoom: boolean;
toast: { message: string; closable?: boolean; duration?: number } | null; toast: {
message: React.ReactNode;
closable?: boolean;
duration?: number;
} | null;
zenModeEnabled: boolean; zenModeEnabled: boolean;
theme: Theme; theme: Theme;
/** grid cell px size */ /** grid cell px size */
@@ -427,7 +430,7 @@ export interface AppState {
offsetTop: number; offsetTop: number;
offsetLeft: number; offsetLeft: number;
fileHandle: FileSystemHandle | null; fileHandle: FileSystemFileHandle | null;
collaborators: Map<SocketId, Collaborator>; collaborators: Map<SocketId, Collaborator>;
stats: { stats: {
open: boolean; open: boolean;
@@ -546,17 +549,39 @@ export type OnUserFollowedPayload = {
action: "FOLLOW" | "UNFOLLOW"; action: "FOLLOW" | "UNFOLLOW";
}; };
export type OnExportProgress = {
type: "progress";
message?: React.ReactNode;
/** 0-1 range */
progress?: number;
};
export interface ExcalidrawProps { export interface ExcalidrawProps {
onChange?: ( onChange?: (
elements: readonly OrderedExcalidrawElement[], elements: readonly OrderedExcalidrawElement[],
appState: AppState, appState: AppState,
files: BinaryFiles, files: BinaryFiles,
) => void; ) => void;
/**
* note: only subscribes if the props.onIncrement is defined on initial render
*/
onIncrement?: (event: DurableIncrement | EphemeralIncrement) => void; onIncrement?: (event: DurableIncrement | EphemeralIncrement) => void;
initialData?: initialData?:
| (() => MaybePromise<ExcalidrawInitialDataState | null>) | (() => MaybePromise<ExcalidrawInitialDataState | null>)
| 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; isCollaborating?: boolean;
onPointerUpdate?: (payload: { onPointerUpdate?: (payload: {
pointer: { x: number; y: number; tool: "pointer" | "laser" }; pointer: { x: number; y: number; tool: "pointer" | "laser" };
@@ -641,6 +666,32 @@ export interface ExcalidrawProps {
aiEnabled?: boolean; aiEnabled?: boolean;
showDeprecatedFonts?: boolean; showDeprecatedFonts?: boolean;
renderScrollbars?: 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 = { export type SceneData = {
@@ -719,6 +770,7 @@ export type AppProps = Merge<
export type AppClassProperties = { export type AppClassProperties = {
props: AppProps; props: AppProps;
state: AppState; state: AppState;
api: App["api"];
sessionExportThemeOverride: App["sessionExportThemeOverride"]; sessionExportThemeOverride: App["sessionExportThemeOverride"];
interactiveCanvas: HTMLCanvasElement | null; interactiveCanvas: HTMLCanvasElement | null;
/** static canvas */ /** static canvas */
@@ -764,9 +816,13 @@ export type AppClassProperties = {
onPointerUpEmitter: App["onPointerUpEmitter"]; onPointerUpEmitter: App["onPointerUpEmitter"];
updateEditorAtom: App["updateEditorAtom"]; updateEditorAtom: App["updateEditorAtom"];
onPointerDownEmitter: App["onPointerDownEmitter"]; onPointerDownEmitter: App["onPointerDownEmitter"];
onEvent: App["onEvent"];
onStateChange: App["onStateChange"];
lastPointerMoveCoords: App["lastPointerMoveCoords"]; lastPointerMoveCoords: App["lastPointerMoveCoords"];
bindModeHandler: App["bindModeHandler"]; bindModeHandler: App["bindModeHandler"];
setAppState: App["setAppState"];
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{
@@ -839,6 +895,20 @@ export type PointerDownState = Readonly<{
export type UnsubscribeCallback = () => void; 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 { export interface ExcalidrawImperativeAPI {
updateScene: InstanceType<typeof App>["updateScene"]; updateScene: InstanceType<typeof App>["updateScene"];
applyDeltas: InstanceType<typeof App>["applyDeltas"]; applyDeltas: InstanceType<typeof App>["applyDeltas"];
@@ -905,6 +975,8 @@ export interface ExcalidrawImperativeAPI {
onUserFollow: ( onUserFollow: (
callback: (payload: OnUserFollowedPayload) => void, callback: (payload: OnUserFollowedPayload) => void,
) => UnsubscribeCallback; ) => UnsubscribeCallback;
onStateChange: InstanceType<typeof App>["onStateChange"];
onEvent: InstanceType<typeof App>["onEvent"];
} }
export type FrameNameBounds = { export type FrameNameBounds = {
+1 -1
View File
@@ -50,7 +50,7 @@
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "6.0.2", "@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.38.0",
"pako": "2.0.3", "pako": "2.0.3",
"perfect-freehand": "1.2.0", "perfect-freehand": "1.2.0",
"png-chunk-text": "1.0.0", "png-chunk-text": "1.0.0",
+4 -4
View File
@@ -4603,10 +4603,10 @@ braces@^3.0.3, braces@~3.0.2:
dependencies: dependencies:
fill-range "^7.1.1" fill-range "^7.1.1"
browser-fs-access@0.29.1: browser-fs-access@0.38.0:
version "0.29.1" version "0.38.0"
resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.29.1.tgz#8a9794c73cf86b9aec74201829999c597128379c" resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.38.0.tgz#9024c5bf3d962287a08d14beebb86cb819cbb838"
integrity sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw== integrity sha512-JveqW2w6pEZqFEEfMgCszXzYpE89dG+nPsmOdcs741mFFAROeL+iqjGEpR07RI+s0YY0EFr+4KnOoACprJTpOw==
browserslist@^4.20.3, browserslist@^4.24.0, browserslist@^4.24.4: browserslist@^4.20.3, browserslist@^4.24.0, browserslist@^4.24.4:
version "4.24.4" version "4.24.4"