diff --git a/contexts/diagram-context.tsx b/contexts/diagram-context.tsx index b9850708..aa73678b 100644 --- a/contexts/diagram-context.tsx +++ b/contexts/diagram-context.tsx @@ -60,6 +60,20 @@ export interface DiagramCommit { pageId?: string | null } +/** An export as draw.io's export action takes it: the format, and for PNG + * its scale, width cap and the page to render (pageId) */ +export interface ExportRequest { + format: string + [key: string]: unknown +} + +/** What draw.io answered: the image (a data URL, or SVG text) in data; for + * format "xml" the document in xml instead */ +export interface ExportResult { + data?: string + xml?: string +} + interface DiagramContextType { chartXML: string // chartXML right away, before the re-render (loadDiagram sets both) @@ -91,6 +105,16 @@ interface DiagramContextType { getThumbnailSvg: () => Promise getVersionSvg: () => Promise captureValidationPng: () => Promise + /** One export with its own result; null when draw.io does not answer + * in time (the MCP shell answers the server's export requests with it) */ + requestExport: ( + request: ExportRequest, + timeoutMs?: number, + ) => Promise + /** Show a document for an export only (the MCP's one-page projection of + * another page): not recorded as the diagram, its autosaves ignored, + * until the next loadDiagram puts the real document back */ + showTransient: (xml: string) => void isDrawioReady: boolean onDrawioLoad: () => void resetDrawioReady: () => void @@ -141,13 +165,19 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { // The page to show again once a full load is done: draw.io shows the // first page of a loaded document const pageAfterLoadRef = useRef(null) + // A transient document (showTransient) is on the canvas, and the page + // the user was on before it + const transientRef = useRef(false) + const pageBeforeTransientRef = useRef(null) // Sends a full load. draw.io runs it when its message arrives, and then // reports "load": until then the editor shows the diagram from before, // so later changes go the same way and keep their order - const fullLoad = (xml: string) => { + const fullLoad = ( + xml: string, + pageId: string | null = useCanvasStore.getState().currentPageId, + ) => { if (!drawioRef.current) return - const pageId = useCanvasStore.getState().currentPageId pageAfterLoadRef.current = pageId && xml.includes(`id="${pageId}"`) ? pageId : null pendingLoadsRef.current++ @@ -252,6 +282,38 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { return pngData?.startsWith("data:image/png") ? pngData : null } + // Like requestTaggedExport, with the request's other parameters (a PNG's + // page and width) and both parts of the answer + const requestExport = useCallback( + (request: ExportRequest, timeoutMs = 10000) => + new Promise((resolve) => { + if (!drawioRef.current) { + resolve(null) + return + } + const id = `export-${++exportSeqRef.current}` + const finish = (value: ExportResult | null) => { + clearTimeout(timer) + delete exportResolversRef.current[id] + resolve(value) + } + const timer = setTimeout(() => finish(null), timeoutMs) + exportResolversRef.current[id] = (data, xml) => + finish({ data, xml }) + drawioRef.current.exportDiagram({ ...request, message: id }) + }), + [], + ) + + const showTransient = (xml: string) => { + if (!transientRef.current) { + pageBeforeTransientRef.current = + useCanvasStore.getState().currentPageId + } + transientRef.current = true + fullLoad(xml) + } + const loadDiagram = ( chart: string, skipValidation?: boolean, @@ -261,6 +323,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { // The editor bridge is shared: a page that is gone (another language // mounted a new one) must not change the new page's canvas if (!mountedRef.current) return null + // A real document ends a transient one; the user goes back to the + // page they were on before it + const pageBeforeTransient = transientRef.current + ? pageBeforeTransientRef.current + : undefined + transientRef.current = false let xmlToLoad = chart // Validate XML structure before loading (unless skipped for internal @@ -315,7 +383,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { if (!applied) { // A full load replaces any preview, and its base is stale now resetPreview() - fullLoad(xmlToLoad) + fullLoad(xmlToLoad, pageBeforeTransient) } if (mode === "commit") { @@ -336,10 +404,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { } const handleDiagramExport = (data: DrawioExportEvent) => { - // Thumbnail, version, validation PNG and file save exports go only - // to their own caller + // Thumbnail, version, validation PNG, file save and requestExport + // exports go only to their own caller const tag = data.message?.message - if (/^(thumbnail|validation|version|save)-/.test(tag ?? "")) { + if (/^(thumbnail|validation|version|save|export)-/.test(tag ?? "")) { exportResolversRef.current[tag as string]?.(data.data, data.xml) return } @@ -369,6 +437,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { // read refs: state captured in its closure would stay stale after a remount const handleDiagramAutoSave = (data: { xml?: string }) => { if (!data?.xml) return + // An edit of a transient document is not the diagram's + if (transientRef.current) return // Don't overwrite a pending restore - if we have a diagram but // DrawIO hasn't loaded yet, it means we're waiting to restore if (!hasCalledOnLoadRef.current && chartXMLRef.current) return @@ -522,6 +592,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { getThumbnailSvg, getVersionSvg, captureValidationPng, + requestExport, + showTransient, isDrawioReady, onDrawioLoad, resetDrawioReady, diff --git a/tests/unit/diagram-context-exports.test.tsx b/tests/unit/diagram-context-exports.test.tsx new file mode 100644 index 00000000..d9d8afe1 --- /dev/null +++ b/tests/unit/diagram-context-exports.test.tsx @@ -0,0 +1,159 @@ +import { act, renderHook } from "@testing-library/react" +import type React from "react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { DiagramProvider, useDiagram } from "@/contexts/diagram-context" +import { useCanvasStore } from "@/stores/canvas-store" + +vi.mock("sonner", () => ({ toast: { success: vi.fn() } })) +// No editor to drive: every load is a full load, and the page to show after +// it is recorded +const selectPage = vi.fn((_id: string) => true) +vi.mock("@/lib/drawio/editor-bridge", () => ({ + canReplaceDiagram: () => false, + commitDiagram: vi.fn(), + previewDiagram: vi.fn(), + resetPreview: vi.fn(), + revertPreview: vi.fn(), + selectPage: (id: string) => selectPage(id), +})) + +const doc = (pages: string) => `${pages}` +const page = (id: string, label: string) => + `` + +// The provider with a stand-in draw.io that records loads and exports +function setup() { + const { result } = renderHook(() => useDiagram(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }) + const requests: { format: string; message: string; pageId?: string }[] = [] + const loads: string[] = [] + result.current.drawioRef.current = { + exportDiagram: (r: any) => requests.push(r), + load: ({ xml }: { xml: string }) => loads.push(xml), + } as any + const reply = ( + request: { message: string }, + data: string | undefined, + xml?: string, + ) => + act(() => + result.current.handleDiagramExport({ + event: "export", + data, + xml, + format: "png", + message: request, + } as any), + ) + return { result, requests, loads, reply } +} + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + useCanvasStore.setState({ currentPageId: null }) +}) + +describe("requestExport", () => { + it("passes the request on and resolves with its own answer", async () => { + const { result, requests, reply } = setup() + const png = result.current.requestExport({ + format: "png", + pageId: "p2", + width: 700, + }) + const xml = result.current.requestExport({ format: "xml" }) + expect(requests).toMatchObject([ + { format: "png", pageId: "p2", width: 700 }, + { format: "xml" }, + ]) + // The XML export answers first, with the document instead of data + reply(requests[1], undefined, "doc") + expect(await xml).toEqual({ + data: undefined, + xml: "doc", + }) + reply(requests[0], "data:image/png;base64,AAAA") + expect(await png).toEqual({ + data: "data:image/png;base64,AAAA", + xml: undefined, + }) + }) + + it("resolves to null when draw.io does not answer in time", async () => { + vi.useFakeTimers() + const { result, requests } = setup() + const pending = result.current.requestExport({ format: "svg" }, 1000) + await vi.advanceTimersByTimeAsync(1000) + expect(await pending).toBeNull() + // A late answer goes nowhere + expect( + result.current.exportResolversRef.current[requests[0].message], + ).toBeUndefined() + }) + + it("resolves to null without draw.io", async () => { + const { result } = setup() + result.current.drawioRef.current = null + expect(await result.current.requestExport({ format: "svg" })).toBeNull() + }) +}) + +describe("showTransient", () => { + it("shows the document without recording it, and ignores its autosaves until the next load", () => { + const { result, loads } = setup() + act(() => { + result.current.loadDiagram(doc(page("p1", "real")), true) + result.current.onDrawioLoad() + }) + expect(result.current.chartXML).toContain('value="real"') + + act(() => { + result.current.showTransient(doc(page("p2", "projection"))) + }) + expect(loads.at(-1)).toContain('value="projection"') + expect(result.current.chartXML).toContain('value="real"') + act(() => { + result.current.handleDiagramAutoSave({ + xml: doc(page("p2", "projection edited")), + }) + }) + expect(result.current.chartXML).toContain('value="real"') + + // The real document comes back: recorded, and saves count again + act(() => { + result.current.loadDiagram(doc(page("p1", "real again")), true) + }) + expect(result.current.chartXML).toContain('value="real again"') + act(() => { + result.current.handleDiagramAutoSave({ + xml: doc(page("p1", "real edited")), + }) + }) + expect(result.current.chartXML).toContain('value="real edited"') + }) + + it("goes back to the page the user was on before the projection", () => { + const { result } = setup() + const twoPages = doc(page("p1", "one") + page("p2", "two")) + act(() => { + result.current.loadDiagram(twoPages, true) + result.current.onDrawioLoad() + }) + // The user is on page two; the projection shows page one + useCanvasStore.setState({ currentPageId: "p2" }) + act(() => { + result.current.showTransient(doc(page("p1", "one"))) + }) + useCanvasStore.setState({ currentPageId: "p1" }) + selectPage.mockClear() + act(() => { + result.current.loadDiagram(twoPages, true) + result.current.onDrawioLoad() + }) + expect(selectPage).toHaveBeenCalledWith("p2") + }) +})