From b4c34c9361c28b6f6683ac771a8f51951765d68b Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 13:44:11 +0900 Subject: [PATCH] feat(canvas): DiagramProvider exports with options and shows a transient document requestExport(request, timeoutMs) runs one draw.io export with the request's own parameters (a PNG's pageId and width) and resolves with its answer (data, or xml for format "xml"); null when draw.io does not answer. showTransient(xml) puts a document on the canvas for an export only: it is not recorded, its autosaves are ignored, and the next loadDiagram brings the user back to the page they were on. The MCP's canvas shell answers the server's export requests with both. --- contexts/diagram-context.tsx | 84 ++++++++++- tests/unit/diagram-context-exports.test.tsx | 159 ++++++++++++++++++++ 2 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 tests/unit/diagram-context-exports.test.tsx 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") + }) +})