diff --git a/extensions/excalidraw/manifest.json b/extensions/excalidraw/manifest.json index d0906e5..c64e39b 100644 --- a/extensions/excalidraw/manifest.json +++ b/extensions/excalidraw/manifest.json @@ -3,7 +3,7 @@ "name": "Excalidraw", "description": "Collaborative drawing with Excalidraw", "developer": "Lunaris", - "version": "1.0.0", + "version": "1.0.1", "sdk": "^0.0.4", "modifications": [ { diff --git a/extensions/excalidraw/package.json b/extensions/excalidraw/package.json index 15f79ed..0551c00 100644 --- a/extensions/excalidraw/package.json +++ b/extensions/excalidraw/package.json @@ -1,6 +1,6 @@ { "name": "lunaris-extension-excalidraw", - "version": "1.0.0", + "version": "1.0.1", "private": true, "type": "module", "scripts": { diff --git a/extensions/excalidraw/src/index.test.tsx b/extensions/excalidraw/src/index.test.tsx index 404fc85..a0046a5 100644 --- a/extensions/excalidraw/src/index.test.tsx +++ b/extensions/excalidraw/src/index.test.tsx @@ -10,6 +10,7 @@ const state = vi.hoisted(() => ({ exportToBlob: vi.fn(), isLoading: false, locale: "en", + yProvider: null as { waitForPersistence: () => Promise } | null, yDoc: null as { getArray: () => object; getMap: () => Map; @@ -48,6 +49,7 @@ vi.mock("@lunarisapp/plugin-sdk/data", () => ({ error: state.error, isLoading: state.isLoading, yDoc: state.yDoc, + yProvider: state.yProvider, }), useYArray: vi.fn(), })); @@ -90,6 +92,7 @@ afterEach(() => { state.isLoading = false; state.locale = "en"; state.yDoc = null; + state.yProvider = null; vi.clearAllMocks(); vi.unstubAllGlobals(); }); @@ -107,6 +110,7 @@ describe("Excalidraw external extension", () => { it("shows a local skeleton while the Yjs document loads", () => { state.isLoading = true; + state.yDoc = testYDoc(); renderContent(); expect(screen.getByTestId("excalidraw-skeleton")).toBeTruthy(); }); diff --git a/extensions/excalidraw/src/index.tsx b/extensions/excalidraw/src/index.tsx index 65affd5..0ad73be 100644 --- a/extensions/excalidraw/src/index.tsx +++ b/extensions/excalidraw/src/index.tsx @@ -1,8 +1,5 @@ import { exportToBlob } from "@excalidraw/excalidraw"; -import type { - CompileContent, - PluginCompileContext, -} from "@lunarisapp/plugin-sdk"; +import type { CompileContent, PluginCompileContext } from "@lunarisapp/plugin-sdk"; import { ContentRendererReady, defineExternalContentType, @@ -126,7 +123,8 @@ export function ExcalidrawView({ documentId: string; reportReady?: () => void; }) { - const { error, isLoading, yDoc } = useCurrentProjectYjsDocument(documentId); + const { error, isLoading, yDoc, yProvider } = + useCurrentProjectYjsDocument(documentId); const { canWriteContent } = useWorkspaceAccess(); const { locale } = useLocale(); const theme = useColorScheme(); @@ -134,7 +132,7 @@ export function ExcalidrawView({ if (error) { return ; } - if (isLoading && !yDoc) return ; + if (isLoading) return ; if (!yDoc) { return ( { expect(screen.getByText("file-1")).toBeTruthy(); doc.destroy(); }); + + it("waits for writable document updates to persist", async () => { + const doc = new Doc(); + const waitForPersistence = vi.fn().mockResolvedValue(undefined); + + render( + "idle", + subscribePersistenceState: () => () => undefined, + waitForPersistence, + }} + theme="light" + yDoc={doc} + />, + ); + + doc.transact(() => doc.getMap("metadata").set("loaded", true), "host"); + await Promise.resolve(); + expect(waitForPersistence).not.toHaveBeenCalled(); + + doc.getMap("metadata").set("updated", true); + await waitFor(() => expect(waitForPersistence).toHaveBeenCalledOnce()); + doc.destroy(); + }); }); diff --git a/extensions/excalidraw/src/yjs-excalidraw.tsx b/extensions/excalidraw/src/yjs-excalidraw.tsx index 846b7c1..1605c80 100644 --- a/extensions/excalidraw/src/yjs-excalidraw.tsx +++ b/extensions/excalidraw/src/yjs-excalidraw.tsx @@ -1,4 +1,5 @@ import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; +import type { PluginYjsProvider } from "@lunarisapp/plugin-sdk"; import { useEffect, useMemo, useRef, useState } from "react"; import { ExcalidrawBinding, yjsToExcalidraw } from "y-excalidraw"; import { Awareness } from "y-protocols/awareness.js"; @@ -9,11 +10,13 @@ import { yjsAssetsToFiles } from "./yjs-assets"; export function YjsExcalidraw({ locale, + persistence, readOnly = false, theme, yDoc, }: { locale: string; + persistence?: PluginYjsProvider | null; readOnly?: boolean; theme: "dark" | "light"; yDoc: Doc; @@ -27,6 +30,41 @@ export function YjsExcalidraw({ const initialElements = useMemo(() => yjsToExcalidraw(yElements), [yElements]); const initialFiles = useMemo(() => yjsAssetsToFiles(yAssets), [yAssets]); + useEffect(() => { + if (!persistence || readOnly) return; + + let dirty = false; + let flushing = false; + let stopped = false; + const flush = async () => { + if (flushing || stopped) return; + flushing = true; + try { + do { + dirty = false; + await persistence.waitForPersistence(); + } while (dirty && !stopped); + } catch { + // Closing the sandbox rejects outstanding bridge calls after the host + // has already received the update. Avoid an unhandled teardown rejection. + } finally { + flushing = false; + } + }; + const requestFlush = (_update: Uint8Array, origin: unknown) => { + if (origin === "host") return; + dirty = true; + // Let the SDK forward this update to the host before asking it to drain. + queueMicrotask(() => void flush()); + }; + + yDoc.on("updateV2", requestFlush); + return () => { + stopped = true; + yDoc.off("updateV2", requestFlush); + }; + }, [persistence, readOnly, yDoc]); + useEffect( () => () => { awareness.destroy();