diff --git a/.eslintrc.json b/.eslintrc.json index 89f8227361..708210535c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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"] + } + ] + } + } + ] } diff --git a/examples/with-script-in-browser/package.json b/examples/with-script-in-browser/package.json index 653c2be40e..512786a659 100644 --- a/examples/with-script-in-browser/package.json +++ b/examples/with-script-in-browser/package.json @@ -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", diff --git a/examples/with-script-in-browser/utils.ts b/examples/with-script-in-browser/utils.ts index 285e9652d3..35f2c332c3 100644 --- a/examples/with-script-in-browser/utils.ts +++ b/examples/with-script-in-browser/utils.ts @@ -4,8 +4,6 @@ import { unstable_batchedUpdates } from "react-dom"; type FILE_EXTENSION = Exclude; -const INPUT_CHANGE_INTERVAL_MS = 500; - export type ResolvablePromise = Promise & { resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void; reject: (error: Error) => void; @@ -54,40 +52,6 @@ export const fileOpen = (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; }; diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 2e71cc6788..011379a4c1 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -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(); - 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, - isInitialLoad = false, - ) => { - if (!data.scene) { + // --------------------------------------------------------------------------- + // Hoisted loadImages + // --------------------------------------------------------------------------- + const loadImages = useCallback( + (data: ResolutionType, 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["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 = () => { })} > { return ( - + + + ); diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 6eef30d7f1..622f92fb5e 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -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 { }; 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) { diff --git a/excalidraw-app/data/FileManager.ts b/excalidraw-app/data/FileManager.ts index 435d813252..849e47bc92 100644 --- a/excalidraw-app/data/FileManager.ts +++ b/excalidraw-app/data/FileManager.ts @@ -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; erroredFiles: Map; }>; + 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(); diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index 13cdf09ac4..634fdc4f1c 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -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)[]) => { diff --git a/excalidraw-app/data/fileStatusStore.ts b/excalidraw-app/data/fileStatusStore.ts new file mode 100644 index 0000000000..ff9363ed5d --- /dev/null +++ b/excalidraw-app/data/fileStatusStore.ts @@ -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 + >(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) { + let pending = 0; + let total = 0; + for (const status of statuses.values()) { + total++; + if (status === "loading") { + pending++; + } + } + return { pending, total }; + } +} diff --git a/packages/common/src/appEventBus.test.ts b/packages/common/src/appEventBus.test.ts new file mode 100644 index 0000000000..9a768c2ff7 --- /dev/null +++ b/packages/common/src/appEventBus.test.ts @@ -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(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(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(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(behavior); + bus.emit("initialize", 1); + + expect(() => { + bus.emit("initialize", 2); + }).toThrow('Event "initialize" can only be emitted once'); + }); +}); diff --git a/packages/common/src/appEventBus.ts b/packages/common/src/appEventBus.ts new file mode 100644 index 0000000000..e561419fed --- /dev/null +++ b/packages/common/src/appEventBus.ts @@ -0,0 +1,136 @@ +import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types"; + +import { Emitter } from "./emitter"; +import { isProdEnv } from "./utils"; + +export type AppEventPayloadMap = Record; + +export type AppEventBehavior = { + cardinality: "once" | "many"; + replay: "none" | "last"; +}; + +export type AppEventBehaviorMap = { + [K in keyof Events]: AppEventBehavior; +}; + +type AwaitableAppEventKeys< + Events extends AppEventPayloadMap, + Behavior extends AppEventBehaviorMap, +> = { + [K in keyof Events]: Behavior[K]["cardinality"] extends "once" + ? Behavior[K]["replay"] extends "last" + ? K + : never + : never; +}[keyof Events]; + +type AppEventPromiseValue = Args extends [infer Only] + ? Only + : Args; + +export class AppEventBus< + Events extends AppEventPayloadMap, + Behavior extends AppEventBehaviorMap, +> { + private readonly emitters = new Map>(); + private readonly lastPayload = new Map(); + private readonly emittedOnce = new Set(); + + constructor(private readonly behavior: Behavior) {} + + private getEmitter(name: K): Emitter { + let emitter = this.emitters.get(name); + if (!emitter) { + emitter = new Emitter(); + this.emitters.set(name, emitter); + } + return emitter as Emitter; + } + + private toPromiseValue( + args: Args, + ): AppEventPromiseValue { + return (args.length === 1 ? args[0] : args) as AppEventPromiseValue; + } + + public on( + name: K, + callback: (...args: Events[K]) => void, + ): UnsubscribeCallback; + public on>( + name: K, + ): Promise>; + public on( + name: K, + callback?: (...args: Events[K]) => void, + ): UnsubscribeCallback | Promise> { + 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>((resolve) => { + this.getEmitter(name).once((...args: Events[K]) => { + resolve(this.toPromiseValue(args)); + }); + }); + } + + public emit(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(); + } +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index ca5397ddd1..022f7714bb 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -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"; diff --git a/packages/common/src/versionedSnapshotStore.ts b/packages/common/src/versionedSnapshotStore.ts new file mode 100644 index 0000000000..979f017987 --- /dev/null +++ b/packages/common/src/versionedSnapshotStore.ts @@ -0,0 +1,70 @@ +export type VersionedSnapshot = Readonly<{ + version: number; + value: T; +}>; + +export class VersionedSnapshotStore { + private version = 0; + private value: T; + private readonly waiters = new Set< + (snapshot: VersionedSnapshot) => void + >(); + private readonly subscribers = new Set< + (snapshot: VersionedSnapshot) => void + >(); + + constructor( + initialValue: T, + private readonly isEqual: (prev: T, next: T) => boolean = Object.is, + ) { + this.value = initialValue; + } + + public getSnapshot(): VersionedSnapshot { + 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) => void, + ): () => void { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + } + + public pull(sinceVersion = -1): Promise> { + if (this.version !== sinceVersion) { + return Promise.resolve(this.getSnapshot()); + } + + return new Promise((resolve) => { + this.waiters.add(resolve); + }); + } +} diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index e4a98d19e8..5d2fa482db 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -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 + { + console.log(container); + excalidrawAPI.scrollToContent(); + }} + onInitialize={(api) => { + api.refresh(); + }} + /> + ``` + +- Same events are also accessible imperatively through `api.onEvent(...)`. + + ```tsx + { + 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 + + + + ; + + 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 Library ## 0.18.0 (2025-03-11) diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 9821abadc8..c25712eb94 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -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"; diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 462803d205..4c22a80ee7 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -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"; diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index 453a0be1f7..b7ed8974f1 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -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({ 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} + + + ) : ( + message + ), + duration: Infinity, + }, + }); +}; + +/** awaits host app's onExport result, and renders progress to the UI */ +async function handleOnExportResult( + onExportResult: ReturnType>, + opts: { + signal: AbortSignal; + app: AppClassProperties; + }, +): Promise { + 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 } { + const abortController = new AbortController(); + const signal = abortController.signal; + + const dataPromise = new Promise(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 }) => ( ; kind: "file"; file: File; - fileHandle: FileSystemHandle | null; + fileHandle: FileSystemFileHandle | null; } | { type: ValueOf; 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 }; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 75b850f8c2..09f059d2ab 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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( ); 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( ); ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext"; +export const ExcalidrawAPIContext = + React.createContext(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 + */ +export const useExcalidrawAPI = () => useContext(ExcalidrawAPIContext); let didTapTwice: boolean = false; let tappedTwiceTimer = 0; @@ -635,12 +654,26 @@ class App extends React.Component { * insert to DOM before user initially scrolls to them) */ private initializedEmbeds = new Set(); - 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 { >(); 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 { theme = defaultAppState.theme, name = `${t("labels.untitled")}-${getDateTime()}`, } = props; + this.state = { ...defaultAppState, theme, @@ -744,51 +779,6 @@ class App extends React.Component { 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 { 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 = ( @@ -2042,282 +2074,279 @@ class App extends React.Component { onPointerEnter={this.toggleOverscrollBehavior} onPointerLeave={this.toggleOverscrollBehavior} > - - - - - - - - + + + + + + + - - {this.props.children} - + + {this.props.children} + -
-
-
- - {selectedElements.length === 1 && - this.state.openDialog?.name !== - "elementLinkSelector" && - this.state.showHyperlinkPopup && ( - +
+
+ + {selectedElements.length === 1 && + this.state.openDialog?.name !== + "elementLinkSelector" && + this.state.showHyperlinkPopup && ( + + )} + {this.props.aiEnabled !== false && + selectedElements.length === 1 && + isMagicFrameElement(firstSelectedElement) && ( + + + this.onMagicFrameGenerate( + firstSelectedElement, + "button", + ) + } + /> + + )} + {selectedElements.length === 1 && + isIframeElement(firstSelectedElement) && + firstSelectedElement.customData?.generationData + ?.status === "done" && ( + + + this.onIframeSrcCopy(firstSelectedElement) + } + /> + { + 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", + }); + } + } + }} + /> + + )} + + {this.state.contextMenu && ( + { + this.setState({ contextMenu: null }, () => { + this.focusContainer(); + callback?.(); + }); + }} /> )} - {this.props.aiEnabled !== false && - selectedElements.length === 1 && - isMagicFrameElement(firstSelectedElement) && ( - - - this.onMagicFrameGenerate( - firstSelectedElement, - "button", - ) - } - /> - - )} - {selectedElements.length === 1 && - isIframeElement(firstSelectedElement) && - firstSelectedElement.customData?.generationData - ?.status === "done" && ( - - - this.onIframeSrcCopy(firstSelectedElement) - } - /> - { - 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", - }); - } - } - }} - /> - - )} - - {this.state.toast !== null && ( - - )} - - {this.state.contextMenu && ( - { - this.setState({ contextMenu: null }, () => { - this.focusContainer(); - callback?.(); - }); - }} - /> - )} - - {this.state.newElement && ( - - )} - - {this.state.userToFollow && ( - - )} - {this.renderFrameNames()} - {this.state.activeLockedId && ( - + )} + - )} - {showShapeSwitchPanel && ( - - )} - - {this.renderEmbeddables()} - - - - - - - + {this.state.userToFollow && ( + + )} + {this.renderFrameNames()} + {this.state.activeLockedId && ( + + )} + {showShapeSwitchPanel && ( + + )} + + {this.renderEmbeddables()} + + + + + + + +
); } @@ -3015,12 +3044,10 @@ class App extends React.Component { 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 { errorMessage: , }); } + + 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 { 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 { } 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 { 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 { // 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 { loadFileToCanvas = async ( file: File, - fileHandle: FileSystemHandle | null, + fileHandle: FileSystemFileHandle | null, ) => { file = await normalizeFile(file); try { diff --git a/packages/excalidraw/components/AppStateObserver.ts b/packages/excalidraw/components/AppStateObserver.ts new file mode 100644 index 0000000000..68faabec58 --- /dev/null +++ b/packages/excalidraw/components/AppStateObserver.ts @@ -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 = { + ( + prop: K, + callback: (value: AppState[K], appState: AppState) => void, + opts?: { once: boolean }, + ): UnsubscribeCallback; + (prop: K): Promise; + ( + prop: (keyof AppState)[], + callback: (appState: AppState, appState2: AppState) => void, + opts?: { once: boolean }, + ): UnsubscribeCallback; + (prop: (keyof AppState)[]): Promise; + ( + prop: (appState: AppState) => T, + callback: (value: T, appState: AppState) => void, + opts?: { once: boolean }, + ): UnsubscribeCallback; + (prop: (appState: AppState) => T): Promise; + (opts: { + predicate: (appState: AppState) => boolean; + callback: (appState: AppState) => void; + once?: boolean; + }): UnsubscribeCallback; + (opts: { predicate: (appState: AppState) => boolean }): Promise; + ( + 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((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 = []; + } +} diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index ed3d85be8b..7a2b7344cf 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -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, diff --git a/packages/excalidraw/components/ConvertElementTypePopup.tsx b/packages/excalidraw/components/ConvertElementTypePopup.tsx index 596456671c..48da586b05 100644 --- a/packages/excalidraw/components/ConvertElementTypePopup.tsx +++ b/packages/excalidraw/components/ConvertElementTypePopup.tsx @@ -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"; diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 85d2701b11..bf774bfebc 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -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 && ( - + {(appState.toast || appState.scrolledOutside) && ( +
+ {appState.toast && ( + setAppState({ toast: null })} + duration={appState.toast.duration} + closable={appState.toast.closable} + /> + )} + {!appState.toast && appState.scrolledOutside && ( + + )} +
)}
{renderSidebars()} diff --git a/packages/excalidraw/components/TTDDialog/Chat/TTDChatPanel.tsx b/packages/excalidraw/components/TTDDialog/Chat/TTDChatPanel.tsx index 25aa30df88..3c9d8c608e 100644 --- a/packages/excalidraw/components/TTDDialog/Chat/TTDChatPanel.tsx +++ b/packages/excalidraw/components/TTDDialog/Chat/TTDChatPanel.tsx @@ -11,7 +11,7 @@ import { rateLimitsAtom } from "../TTDContext"; import { ChatHistoryMenu } from "./ChatHistoryMenu"; -import { ChatInterface } from "."; +import { ChatInterface } from "./ChatInterface"; import type { TTDPanelAction } from "../TTDDialogPanel"; diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx index 21a6f16948..251f5554f8 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogSubmitShortcut.tsx @@ -1,4 +1,4 @@ -import { getShortcutKey } from "@excalidraw/excalidraw/shortcut"; +import { getShortcutKey } from "../../shortcut"; export const TTDDialogSubmitShortcut = () => { return ( diff --git a/packages/excalidraw/components/TTDDialog/common.ts b/packages/excalidraw/components/TTDDialog/common.ts index f31987bc9b..d5bac53169 100644 --- a/packages/excalidraw/components/TTDDialog/common.ts +++ b/packages/excalidraw/components/TTDDialog/common.ts @@ -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"; diff --git a/packages/excalidraw/components/TTDDialog/utils/TTDStreamFetch.ts b/packages/excalidraw/components/TTDDialog/utils/TTDStreamFetch.ts index b30b803951..a2c421a867 100644 --- a/packages/excalidraw/components/TTDDialog/utils/TTDStreamFetch.ts +++ b/packages/excalidraw/components/TTDDialog/utils/TTDStreamFetch.ts @@ -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; diff --git a/packages/excalidraw/components/Toast.scss b/packages/excalidraw/components/Toast.scss index 5d8cc8c1d2..89ffc2ade5 100644 --- a/packages/excalidraw/components/Toast.scss +++ b/packages/excalidraw/components/Toast.scss @@ -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; } diff --git a/packages/excalidraw/components/Toast.tsx b/packages/excalidraw/components/Toast.tsx index a7167c00ff..e30b4b2fa0 100644 --- a/packages/excalidraw/components/Toast.tsx +++ b/packages/excalidraw/components/Toast.tsx @@ -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 }) => ( +
+
+
+); + +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 (
-

{message}

+
{message}
{closable && ( ); }; + +export const Toast = Object.assign(ToastComponent, { ProgressBar }); diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index c6245953b9..a30088f30a 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -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, diff --git a/packages/excalidraw/css/styles.scss b/packages/excalidraw/css/styles.scss index e0d42fa716..1891a1f2ef 100644 --- a/packages/excalidraw/css/styles.scss +++ b/packages/excalidraw/css/styles.scss @@ -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; diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index c520c33b1c..65f1809497 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -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 => { @@ -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 => { +): Promise => { 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; diff --git a/packages/excalidraw/data/filesystem.ts b/packages/excalidraw/data/filesystem.ts index 4a8d43c35f..36d2e4b3ca 100644 --- a/packages/excalidraw/data/filesystem.ts +++ b/packages/excalidraw/data/filesystem.ts @@ -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; -const INPUT_CHANGE_INTERVAL_MS = 5000; - export const fileOpen = async (opts: { extensions?: FILE_EXTENSION[]; description: string; @@ -42,40 +36,6 @@ export const fileOpen = async (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 }; diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index f655e5e8e6..1083a47897 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -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; }, ) => { diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts index 047a2ccdec..fc34203262 100644 --- a/packages/excalidraw/data/json.ts +++ b/packages/excalidraw/data/json.ts @@ -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[]; + 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; + 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 ( diff --git a/packages/excalidraw/data/resave.ts b/packages/excalidraw/data/resave.ts index 188041d691..8c443e0e28 100644 --- a/packages/excalidraw/data/resave.ts +++ b/packages/excalidraw/data/resave.ts @@ -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, }); diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index 59157002da..025d3f88e8 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -52,7 +52,7 @@ declare module "png-chunks-extract" { // ----------------------------------------------------------------------------- interface Blob { - handle?: import("browser-fs-acces").FileSystemHandle; + handle?: FileSystemFileHandle; name?: string; } diff --git a/packages/excalidraw/hooks/useAppStateValue.ts b/packages/excalidraw/hooks/useAppStateValue.ts new file mode 100644 index 0000000000..2f4750a564 --- /dev/null +++ b/packages/excalidraw/hooks/useAppStateValue.ts @@ -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 tree, as long as + * ExcalidrawAPIContext.Provider is an ancestor (automatically provided + * inside , 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( + 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( + 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 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(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; +} diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 90ddfb896e..7671951c0e 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -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 + * component tree. + */ +export const ExcalidrawAPIProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [api, setApi] = useState(null); + return ( + + + {children} + + + ); +}; + 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) => { ( + prop: K, + callback?: (value: AppState[K], appState: AppState) => void, +): AppState[K] | undefined; +export function useAppStateValue( + props: T[], + callback?: (values: AppState, appState: AppState) => void, +): AppState | undefined; +export function useAppStateValue( + 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); +} +// ----------------------------------------------------------------------------- diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 66402f3fb7..bfdb476bba 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -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.", diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index d3be66cb37..f60ea96634 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -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" diff --git a/packages/excalidraw/tests/packages/events.test.tsx b/packages/excalidraw/tests/packages/events.test.tsx index bc4441c40d..f30c8b092c 100644 --- a/packages/excalidraw/tests/packages/events.test.tsx +++ b/packages/excalidraw/tests/packages/events.test.tsx @@ -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(); await render( 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; + }>(); + + await render( + + 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( + { + 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(); diff --git a/packages/excalidraw/tests/tool.test.tsx b/packages/excalidraw/tests/tool.test.tsx index f7c101c1da..f82997dd7c 100644 --- a/packages/excalidraw/tests/tool.test.tsx +++ b/packages/excalidraw/tests/tool.test.tsx @@ -20,7 +20,7 @@ describe("setActiveTool()", () => { const excalidrawAPIPromise = resolvablePromise(); await render( excalidrawAPIPromise.resolve(api as any)} + onExcalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)} />, ); excalidrawAPI = await excalidrawAPIPromise; diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 0b654386bf..b1005d3dcd 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -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; 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) | MaybePromise; - 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 | AsyncGenerator; } 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["updateScene"]; applyDeltas: InstanceType["applyDeltas"]; @@ -905,6 +975,8 @@ export interface ExcalidrawImperativeAPI { onUserFollow: ( callback: (payload: OnUserFollowedPayload) => void, ) => UnsubscribeCallback; + onStateChange: InstanceType["onStateChange"]; + onEvent: InstanceType["onEvent"]; } export type FrameNameBounds = { diff --git a/packages/utils/package.json b/packages/utils/package.json index f571d42035..2439480042 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 5259da6288..f3a53e391e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"