mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29:51 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user