From 92d25446d62296338d2bba053b90330e4a1fb8be Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:51:03 +0100 Subject: [PATCH] feat(packages/excalidraw): tweak and expose more API around state and lifecycle (#10939) --- packages/excalidraw/CHANGELOG.md | 23 +-- packages/excalidraw/components/App.tsx | 120 +++++++----- packages/excalidraw/hooks/useAppStateValue.ts | 176 ++++++++++++------ packages/excalidraw/index.tsx | 47 +++-- .../excalidraw/tests/appStateHooks.test.tsx | 111 +++++++++++ .../excalidraw/tests/packages/events.test.tsx | 16 +- packages/excalidraw/types.ts | 9 +- 7 files changed, 364 insertions(+), 138 deletions(-) create mode 100644 packages/excalidraw/tests/appStateHooks.test.tsx diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index 5d2fa482db..16a98243ee 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -18,22 +18,13 @@ Please add the latest change on the top under the correct section. ### Breaking changes - Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`. + - `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already). ### Features -- Added `onMount` and `onInitialize` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted, and `onInitialize` fires once the initial scene has loaded. +- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code. - ```tsx - { - console.log(container); - excalidrawAPI.scrollToContent(); - }} - onInitialize={(api) => { - api.refresh(); - }} - /> - ``` +- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting. - Same events are also accessible imperatively through `api.onEvent(...)`. @@ -41,7 +32,6 @@ Please add the latest change on the top under the correct section. { api.onEvent("editor:mount", ({ excalidrawAPI, container }) => { - excalidrawAPI.scrollToContent(); console.log(container); }); @@ -54,7 +44,9 @@ Please add the latest change on the top under the correct section. 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`. +- Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`. + +- Exported ``, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`. ```tsx @@ -63,6 +55,9 @@ Please add the latest change on the top under the correct section. ; function Logger() { + // initially null before the ExcalidrawAPIProvider initializes ater + // renders + // When unmounts, is reset back to null const api = useExcalidrawAPI(); useAppStateValue("viewModeEnabled", (viewModeEnabled) => { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 09f059d2ab..4ee4141cd0 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -517,6 +517,7 @@ EditorInterfaceContext.displayName = "EditorInterfaceContext"; const editorLifecycleEventBehavior = { "editor:mount": { cardinality: "once", replay: "last" }, "editor:initialize": { cardinality: "once", replay: "last" }, + "editor:unmount": { cardinality: "once", replay: "last" }, } as const; export const ExcalidrawContainerContext = React.createContext<{ @@ -556,7 +557,7 @@ export const ExcalidrawAPIContext = ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext"; export const ExcalidrawAPISetContext = React.createContext< - ((api: ExcalidrawImperativeAPI) => void) | null + ((api: ExcalidrawImperativeAPI | null) => void) | null >(null); ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext"; @@ -731,6 +732,50 @@ class App extends React.Component { api: ExcalidrawImperativeAPI; + private createExcalidrawAPI(): ExcalidrawImperativeAPI { + const api: ExcalidrawImperativeAPI = { + isDestroyed: false, + 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, + }; + return api; + } + constructor(props: AppProps) { super(props); const defaultAppState = getDefaultAppState(); @@ -791,47 +836,11 @@ class App extends React.Component { 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); + // in case internal editor APIs call this early, otherwise we need + // to construct this in componentDidMount because componentWillUnmount + // will invalidate it (so in StrictMode, doing this in constructor alone + // would be a problem) + this.api = this.createExcalidrawAPI(); } updateEditorAtom = ( @@ -3003,6 +3012,8 @@ class App extends React.Component { public async componentDidMount() { this.unmounted = false; + this.api = this.createExcalidrawAPI(); + this.excalidrawContainerValue.container = this.excalidrawContainerRef.current; @@ -3089,10 +3100,35 @@ class App extends React.Component { this.editorLifecycleEvents.emit("editor:mount", mountPayload); this.props.onMount?.(mountPayload); + this.props.onExcalidrawAPI?.(this.api); } public componentWillUnmount() { + // we're recreating the api object reference so that the + // picks up on it + this.api = { ...this.api, isDestroyed: true }; + + for (const key of Object.keys(this.api) as (keyof typeof this.api)[]) { + if ( + (key.startsWith("get") || + key === "onStateChange" || + key === "onEvent") && + typeof this.api[key] === "function" + ) { + (this.api as any)[key] = () => { + throw new Error( + "ExcalidrawAPI is no longer usable after the editor has been unmounted and will return invalid/empty data. You should check for `ExcalidrawAPI.isDestroyed` before calling get* methods on subscribing to state/event changes.", + ); + }; + } + } + + this.editorLifecycleEvents.emit("editor:unmount"); + this.props.onUnmount?.(); + this.props.onExcalidrawAPI?.(null); + (window as any).launchQueue?.setConsumer(() => {}); + this.renderer.destroy(); this.scene.destroy(); this.scene = new Scene(); diff --git a/packages/excalidraw/hooks/useAppStateValue.ts b/packages/excalidraw/hooks/useAppStateValue.ts index 2f4750a564..12ca1b5b92 100644 --- a/packages/excalidraw/hooks/useAppStateValue.ts +++ b/packages/excalidraw/hooks/useAppStateValue.ts @@ -6,6 +6,51 @@ import { getDefaultAppState } from "../appState"; import type { AppState } from "../types"; +type AppStateSelector = + | keyof AppState + | (keyof AppState)[] + | ((appState: AppState) => unknown); + +const getSelectedValue = (appState: AppState, selector: AppStateSelector) => { + if (typeof selector === "function") { + return selector(appState); + } + if (Array.isArray(selector)) { + return appState; + } + return appState[selector]; +}; + +const getLatestValue = ( + api: ReturnType, + selector: AppStateSelector, + _internal: boolean, +) => { + if (api?.isDestroyed) { + return; + } + + let appState = api?.getAppState(); + if (!appState) { + if (!_internal) { + return undefined; + } + + console.warn( + "useAppStateValue: excalidrawAPI not defined yet for internal component while it should always be defined. Are you sure you're rendering inside of component tree?", + ); + // fall back in case there's a bug so we don't break the app + // (internal components using this internal useAppStateValue expect + // non-undefined values on init) + appState = Object.assign( + { width: 0, height: 0, offsetLeft: 0, offsetTop: 0 }, + getDefaultAppState(), + ); + } + + return getSelectedValue(appState, selector); +}; + /** * Subscribes to specific appState changes. The component re-renders * only when the specified prop(s) change — not on every appState update. @@ -19,92 +64,109 @@ import type { AppState } from "../types"; * - `(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, + selector: AppStateSelector, _internal: boolean = true, ): unknown { const api = useExcalidrawAPI(); + const [, rerender] = useState(0); - 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, - }); + const stateRef = useRef<{ + selector: AppStateSelector; + isInitialized: boolean; + latestValue: unknown; + } | null>(null); + if (!stateRef.current) { + stateRef.current = { + selector, + isInitialized: !!api, + latestValue: getLatestValue(api, selector, _internal), + }; + } stateRef.current.selector = selector; - stateRef.current.callback = callback; - if (!stateRef.current.isInitialized && api) { + if (!stateRef.current.isInitialized && api && !api.isDestroyed) { stateRef.current.isInitialized = true; - stateRef.current.latestValue = getLatestValue(); + stateRef.current.latestValue = getLatestValue(api, selector, _internal); } useEffect(() => { - if (!api) { + const currentStateRef = stateRef.current; + if (!api || api.isDestroyed || !currentStateRef) { return; } - return api.onStateChange( - stateRef.current.selector, - (newValue: any, state: AppState) => { - stateRef.current.latestValue = newValue; - stateRef.current.callback?.(newValue, state); - setValue(newValue); - }, - ); + return api.onStateChange(currentStateRef.selector, (newValue: any) => { + currentStateRef.latestValue = newValue; + rerender((value) => value + 1); + }); }, [api]); return stateRef.current.latestValue; } + +/** + * Subscribes to specific appState changes without causing component rerenders. + * + * The callback is called on every matching change, but also on initial render + * so you can initialize your state. + */ +export function useOnAppStateChange( + prop: K, + callback: (value: AppState[K], appState: AppState) => void, +): undefined; +export function useOnAppStateChange( + props: (keyof AppState)[], + callback: (props: AppState, appState: AppState) => void, +): undefined; +export function useOnAppStateChange( + selector: (appState: AppState) => T, + callback: (value: T, appState: AppState) => void, +): undefined; +export function useOnAppStateChange( + selector: AppStateSelector, + callback: (value: any, appState: AppState) => void, +): undefined { + const api = useExcalidrawAPI(); + + const stateRef = useRef({ + selector, + callback, + }); + stateRef.current.selector = selector; + stateRef.current.callback = callback; + + useEffect(() => { + if (!api || api.isDestroyed) { + return; + } + + stateRef.current.callback( + getLatestValue(api, stateRef.current.selector, true), + api.getAppState(), + ); + + return api.onStateChange( + stateRef.current.selector, + (newValue: any, state: AppState) => { + stateRef.current.callback(newValue, state); + }, + ); + }, [api]); + + return undefined; +} diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 7671951c0e..7ced19a337 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useContext, useEffect, useState } from "react"; +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; import { DEFAULT_UI_OPTIONS, isShallowEqual } from "@excalidraw/common"; @@ -12,7 +18,10 @@ import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollab 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 { + useAppStateValue as _useAppStateValue, + useOnAppStateChange as _useOnAppStateChange, +} from "./hooks/useAppStateValue"; import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai"; import polyfill from "./polyfill"; @@ -31,8 +40,8 @@ polyfill(); /** * Stateless provider that allows `useExcalidrawAPI()` (and hooks built - * on it, such as `useAppStateValue()`) to work outside the - * component tree. + * on it, such as `useAppStateValue()` and `useOnAppStateChange()`) to work + * outside the component tree. */ export const ExcalidrawAPIProvider = ({ children, @@ -57,6 +66,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { initialData, onExcalidrawAPI, onMount, + onUnmount, onInitialize, isCollaborating = false, onPointerUpdate, @@ -119,12 +129,16 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { } const setExcalidrawAPI = useContext(ExcalidrawAPISetContext); + + const onExcalidrawAPIRef = useRef(onExcalidrawAPI); + onExcalidrawAPIRef.current = onExcalidrawAPI; + const handleExcalidrawAPI = useCallback( - (api: ExcalidrawImperativeAPI) => { + (api: ExcalidrawImperativeAPI | null) => { setExcalidrawAPI?.(api); - onExcalidrawAPI?.(api); + onExcalidrawAPIRef.current?.(api); }, - [onExcalidrawAPI, setExcalidrawAPI], + [setExcalidrawAPI], ); useEffect(() => { @@ -162,6 +176,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { initialData={initialData} onExcalidrawAPI={handleExcalidrawAPI} onMount={onMount} + onUnmount={onUnmount} onInitialize={onInitialize} isCollaborating={isCollaborating} onPointerUpdate={onPointerUpdate} @@ -378,7 +393,7 @@ export { } from "./charts"; // ----------------------------------------------------------------------------- -// useAppStateValue() wrapper for host apps for the return type to reflect the +// useExcalidrawStateValue() wrapper for host apps for the return type to reflect the // the potentially `undefined` value for initial render before the excalidrawAPI // is ready. // @@ -388,25 +403,23 @@ export { * @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( +export function useExcalidrawStateValue( prop: K, - callback?: (value: AppState[K], appState: AppState) => void, ): AppState[K] | undefined; -export function useAppStateValue( +export function useExcalidrawStateValue( props: T[], - callback?: (values: AppState, appState: AppState) => void, ): AppState | undefined; -export function useAppStateValue( +export function useExcalidrawStateValue( selector: (appState: AppState) => T, - callback?: (value: T, appState: AppState) => void, ): T | undefined; -export function useAppStateValue( +export function useExcalidrawStateValue( selector: | keyof AppState | (keyof AppState)[] | ((appState: AppState) => unknown), - callback?: (value: any, appState: AppState) => void, ) { - return _useAppStateValue(selector as any, callback, false); + return _useAppStateValue(selector as any, false); } // ----------------------------------------------------------------------------- + +export { _useOnAppStateChange as useOnExcalidrawStateChange }; diff --git a/packages/excalidraw/tests/appStateHooks.test.tsx b/packages/excalidraw/tests/appStateHooks.test.tsx new file mode 100644 index 0000000000..41f1fbba2b --- /dev/null +++ b/packages/excalidraw/tests/appStateHooks.test.tsx @@ -0,0 +1,111 @@ +import { act, cleanup, render, screen } from "@testing-library/react"; +import { vi } from "vitest"; + +import { getDefaultAppState } from "../appState"; +import { ExcalidrawAPIContext } from "../components/App"; +import { AppStateObserver } from "../components/AppStateObserver"; +import { + useAppStateValue, + useOnAppStateChange, +} from "../hooks/useAppStateValue"; + +import type { AppState, ExcalidrawImperativeAPI } from "../types"; + +const createAppState = (): AppState => ({ + ...getDefaultAppState(), + width: 0, + height: 0, + offsetLeft: 0, + offsetTop: 0, +}); + +const createMockAPI = (initialState: AppState) => { + let state = initialState; + const observer = new AppStateObserver(() => state); + + return { + api: { + isDestroyed: false, + getAppState: () => state, + onStateChange: observer.onStateChange, + } as Pick< + ExcalidrawImperativeAPI, + "isDestroyed" | "getAppState" | "onStateChange" + > as ExcalidrawImperativeAPI, + updateAppState: (partial: Partial) => { + const prevState = state; + state = { ...state, ...partial }; + observer.flush(prevState); + }, + }; +}; + +describe("app state hooks", () => { + afterEach(() => { + cleanup(); + }); + + it("useAppStateValue rerenders when the selected value changes", () => { + const renderSpy = vi.fn(); + const { api, updateAppState } = createMockAPI(createAppState()); + + const ValueConsumer = () => { + const value = useAppStateValue("viewModeEnabled"); + renderSpy(value); + return
{String(value)}
; + }; + + render( + + + , + ); + + expect(screen.getByTestId("value").textContent).toBe("false"); + expect(renderSpy).toHaveBeenCalledTimes(1); + + act(() => { + updateAppState({ viewModeEnabled: true }); + }); + + expect(screen.getByTestId("value").textContent).toBe("true"); + expect(renderSpy).toHaveBeenCalledTimes(2); + }); + + it("useOnAppStateChange notifies without rerendering", () => { + const renderSpy = vi.fn(); + const callback = vi.fn(); + const { api, updateAppState } = createMockAPI(createAppState()); + + const ChangeConsumer = () => { + const value = useOnAppStateChange("viewModeEnabled", callback); + renderSpy(value); + return
{String(value)}
; + }; + + render( + + + , + ); + + expect(screen.getByTestId("value").textContent).toBe("undefined"); + expect(renderSpy).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith( + false, + expect.objectContaining({ viewModeEnabled: false }), + ); + + act(() => { + updateAppState({ viewModeEnabled: true }); + }); + + expect(renderSpy).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith( + true, + expect.objectContaining({ viewModeEnabled: true }), + ); + }); +}); diff --git a/packages/excalidraw/tests/packages/events.test.tsx b/packages/excalidraw/tests/packages/events.test.tsx index f30c8b092c..17107decad 100644 --- a/packages/excalidraw/tests/packages/events.test.tsx +++ b/packages/excalidraw/tests/packages/events.test.tsx @@ -41,13 +41,15 @@ describe("event callbacks", () => { await render( - lifecyclePromise.resolve({ - api: api as ExcalidrawImperativeAPI, - mount: api.onEvent("editor:mount"), - initialize: api.onEvent("editor:initialize"), - }) - } + onExcalidrawAPI={(api) => { + if (api) { + lifecyclePromise.resolve({ + api, + mount: api.onEvent("editor:mount"), + initialize: api.onEvent("editor:initialize"), + }); + } + }} />, ); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index b1005d3dcd..63397ea488 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -573,11 +573,15 @@ export interface ExcalidrawProps { * 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; + onExcalidrawAPI?: (api: ExcalidrawImperativeAPI | null) => void; /** * Invoked once the editor root is mounted. */ onMount?: (payload: ExcalidrawMountPayload) => void; + /** + * Invoked when the editor root is unmounted. + */ + onUnmount?: () => void; /** * Invoked once the initial scene is loaded. */ @@ -907,9 +911,12 @@ export type ExcalidrawMountPayload = { export type ExcalidrawImperativeAPIEventMap = { "editor:mount": [payload: ExcalidrawMountPayload]; "editor:initialize": [api: ExcalidrawImperativeAPI]; + "editor:unmount": []; }; export interface ExcalidrawImperativeAPI { + /** Whether the editor has been unmounted and the API is no longer usable. */ + isDestroyed: boolean; updateScene: InstanceType["updateScene"]; applyDeltas: InstanceType["applyDeltas"]; mutateElement: InstanceType["mutateElement"];