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.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 1f27036de1
commit b4c34c9361
2 changed files with 237 additions and 6 deletions
+78 -6
View File
@@ -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<string | null>
getVersionSvg: () => Promise<string | null>
captureValidationPng: () => Promise<string | null>
/** 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<ExportResult | null>
/** 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<string | null>(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<string | null>(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<ExportResult | null>((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,
+159
View File
@@ -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) => `<mxfile>${pages}</mxfile>`
const page = (id: string, label: string) =>
`<diagram id="${id}" name="${id}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="c-${id}" value="${label}" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel></diagram>`
// The provider with a stand-in draw.io that records loads and exports
function setup() {
const { result } = renderHook(() => useDiagram(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<DiagramProvider>{children}</DiagramProvider>
),
})
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, "<mxfile>doc</mxfile>")
expect(await xml).toEqual({
data: undefined,
xml: "<mxfile>doc</mxfile>",
})
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")
})
})