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