mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
* feat(mcp): add multi-page (mxfile) support
The MCP server's write path could only address a single drawio page even
though the underlying .drawio file format and the embedded editor both
natively support multi-page documents. A user asking for "a second page
with a CNN diagram" would hit the validator with the error
"Expected closing tag </root> but found </mxCell>" because the validator
assumed input was a bare <mxGraphModel> and could not walk past the
<mxfile><diagram>...</diagram></mxfile> wrapper.
This patch closes the gap end to end:
* New helper module `pages.ts` centralises page CRUD (normalize, parse,
list, find, add, rename, delete) so every layer agrees that the
canonical in-memory shape is always <mxfile>. normalizeToMxfile and
addPageToDoc both strip any leading <?xml ?> declaration before
embedding a fragment inside <diagram> (the declaration is only valid
at document start). addPageToDoc explicitly rejects full <mxfile>
inputs so a caller cannot accidentally nest a document inside a page.
* `xml-validation.ts` now detects an <mxfile> root and scopes the
duplicate-id check per <diagram>. The legacy regex check would
otherwise reject every multi-page doc, because cells "0" and "1"
repeat in each page's <root> by design. The DOM-parse path is gated
by a cheap regex pre-check so legacy bare <mxGraphModel> callers
don't pay any extra cost. The autoFix duplicate-id rename step is
also guarded against mxfile inputs — renaming those sentinel cells
would silently break drawio's parent references.
* `diagram-operations.ts` accepts an optional PageSelector. For
<mxfile> input it resolves the page first and scopes all
querySelectorAll calls to that page's <root>, so a delete on page 2's
cell "2" no longer touches page 1's cell "2".
* `create_new_diagram` accepts either a bare <mxGraphModel> (legacy,
auto-wrapped into a single-page mxfile) or a full <mxfile> with N
diagrams. All existing single-page callers keep working unchanged.
* `edit_diagram`, `get_diagram`, and `export_diagram` gain optional
`page_id` / `page_name` / `page_index` parameters. When omitted they
target the first page — the "active by convention" default. Tool
handlers with all-optional input schemas coalesce missing arguments
via `input ?? {}` so a no-args MCP invocation can't crash on
destructure before reaching the session-existence check.
* New tools: `list_pages`, `add_page`, `rename_page`, `delete_page`.
* Page-targeted PNG/SVG export uses a "load + export + restore" dance:
the server projects the target page into a single-page <mxfile>,
pushes it into the transient state so the browser reloads the iframe
with just that page, waits for drawio to render (~3s), triggers the
export, captures the data, and then restores the original multi-page
document. The dance is wrapped in `try/finally` so the restore runs
unconditionally — even if an exception is thrown mid-dance, the
user's multi-tab view is recovered before the function returns.
The earlier attempt to use drawio's `selectPage` postMessage was a
no-op because drawio's JSON embed protocol does not expose that
action — silently exporting whatever tab happened to be active. The
load-export-restore approach trades a brief visible tab-flicker for
correctness: the exported image is guaranteed to match the requested
page.
* Tool description strings reflect the multi-page semantics so the LLM
client learns the new contract.
* Package version bumped 0.2.0 → 0.3.0 (additive surface — four new
tools, three extended input schemas, canonical XML shape change).
* CI: `.github/workflows/test.yml` gains an explicit install + vitest
run for the mcp-server package so the new multi-page invariants are
covered by automation, not just local runs.
Backward compatibility: every existing single-page caller continues to
work without modification. The session.xml shape is normalised on every
write, removing the wrapper-injection hack from the .drawio download
path.
Tests: 43 unit tests under `packages/mcp-server/tests/multi-page.test.ts`
pin the validator's mxfile path, the page-scoped operations, the XML
declaration-prefix handling for both normalizeToMxfile and addPageToDoc,
addPageToDoc's rejection of full <mxfile> inputs, the single-page
projection used by export_diagram (a direct regression test for the
selectPage bug — two distinct page selectors must produce visually
different projections), and the Transformer + CNN motivating scenario.
A `tests/smoke.mjs` smoke test drives the built `dist/index.js` over
JSON-RPC and asserts all 9 tools register with the right input schemas.
Root vitest suite (107 tests) still green.
* fix(mcp): rewrite page-targeted export browser-side; harden edit/get
The page-targeted PNG/SVG export never worked: export_diagram swapped the
live session to a single-page projection, slept 3s, then wrote the export
flag onto a state object that setState() had already replaced in the store
Map — so the browser never saw the request and every such export timed out.
The swap+restore also clobbered concurrent edits.
Move the projection entirely browser-side: requestExport() hands a single
-page <mxfile> to the bridge via state.exportXml; the bridge loads it,
lets draw.io render, exports, then reloads the user's real document. The
canonical session state is never mutated, so there is no restore race and
no fixed-delay guessing. The export poll now re-reads the live store entry
each tick instead of a captured reference. autosave is suppressed and the
version-bump reload is skipped while a projection is on screen; if no real
document was captured, restore forces a server reload rather than leaving
the iframe stuck on the projection.
Also:
- edit_diagram now returns isError on a page-level failure (selector matched
no page / page has no <root>) instead of reporting success-with-warnings
and persisting a no-op; the pre-edit history snapshot is taken only after
that gate so a failed edit leaves no phantom undo entry.
- edit_diagram/get_diagram re-normalise browser-pushed xml to mxfile so a
bare <mxGraphModel> can't silently strip a multi-page document.
- get_diagram now errors (instead of silently returning the full doc) when a
selector is given but the session isn't a parseable mxfile.
- page_id / page_name / add_page.id get .min(1) so empty strings can't
silently target the first page.
- Extract pages.ts:projectPage(), collapsing three copies of the
parse→find→serialise projection logic in index.ts.
- Replace the never-in-CI tests/smoke.mjs with tests/server-wiring.test.ts,
which boots the server from source via tsx and runs under the existing
vitest CI step.
* chore(mcp): set version to 0.2.1 for release
---------
Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
1026 lines
45 KiB
TypeScript
1026 lines
45 KiB
TypeScript
/**
|
|
* Embedded HTTP Server for MCP
|
|
* Serves draw.io embed with state sync and history UI
|
|
*/
|
|
|
|
import http from "node:http"
|
|
|
|
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
|
|
|
|
function readBody(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
cb: (body: string) => void,
|
|
): void {
|
|
let body = ""
|
|
let size = 0
|
|
req.on("data", (chunk: Buffer) => {
|
|
size += chunk.length
|
|
if (size > MAX_BODY_BYTES) {
|
|
res.writeHead(413, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "Payload too large" }))
|
|
req.destroy()
|
|
return
|
|
}
|
|
body += chunk
|
|
})
|
|
req.on("end", () => cb(body))
|
|
}
|
|
|
|
import {
|
|
addHistory,
|
|
clearHistory,
|
|
getHistory,
|
|
getHistoryEntry,
|
|
updateLastHistorySvg,
|
|
} from "./history.js"
|
|
import { log } from "./logger.js"
|
|
|
|
// Configurable draw.io embed URL for private deployments
|
|
const DRAWIO_BASE_URL =
|
|
process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
|
|
|
// Extract origin (scheme + host + port) from URL for postMessage security check
|
|
function getOrigin(url: string): string {
|
|
try {
|
|
const parsed = new URL(url)
|
|
return `${parsed.protocol}//${parsed.host}`
|
|
} catch {
|
|
return url // Fallback if parsing fails
|
|
}
|
|
}
|
|
|
|
const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL)
|
|
|
|
// Minimal blank diagram used to bootstrap new sessions.
|
|
// This avoids the draw.io embed spinner (spin=1) getting stuck when no `load(xml)` is ever sent.
|
|
const DEFAULT_DIAGRAM_XML = `<mxfile host="app.diagrams.net"><diagram id="blank" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
|
|
|
// Normalize URL for iframe src - ensure no double slashes
|
|
function normalizeUrl(url: string): string {
|
|
// Remove trailing slash to avoid double slashes
|
|
return url.replace(/\/$/, "")
|
|
}
|
|
|
|
function isLikelyMcpSessionId(sessionId: string): boolean {
|
|
// Keep this cheap and conservative to avoid creating state for arbitrary IDs.
|
|
return sessionId.startsWith("mcp-") && sessionId.length <= 128
|
|
}
|
|
|
|
// Find the most recent active session (for auto-redirect when no sessionId provided)
|
|
function getMostRecentSessionId(): string | null {
|
|
let mostRecent: { id: string; lastUpdated: Date } | null = null
|
|
for (const [sessionId, state] of stateStore) {
|
|
if (!mostRecent || state.lastUpdated > mostRecent.lastUpdated) {
|
|
mostRecent = { id: sessionId, lastUpdated: state.lastUpdated }
|
|
}
|
|
}
|
|
return mostRecent?.id || null
|
|
}
|
|
|
|
function ensureSessionStateInitialized(sessionId: string): void {
|
|
if (!sessionId) return
|
|
if (!isLikelyMcpSessionId(sessionId)) return
|
|
if (stateStore.has(sessionId)) return
|
|
|
|
setState(sessionId, DEFAULT_DIAGRAM_XML)
|
|
}
|
|
|
|
interface SessionState {
|
|
xml: string
|
|
version: number
|
|
lastUpdated: Date
|
|
svg?: string // Cached SVG from last browser save
|
|
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
|
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
|
|
exportXml?: string // Single-page projection to load before a page-targeted export
|
|
exportData?: string // Base64/SVG data returned by browser after export
|
|
}
|
|
|
|
export const stateStore = new Map<string, SessionState>()
|
|
|
|
let server: http.Server | null = null
|
|
let serverPort = 6002
|
|
const MAX_PORT = 6020
|
|
const SESSION_TTL = 60 * 60 * 1000
|
|
|
|
export function getState(sessionId: string): SessionState | undefined {
|
|
return stateStore.get(sessionId)
|
|
}
|
|
|
|
export function setState(sessionId: string, xml: string, svg?: string): number {
|
|
const existing = stateStore.get(sessionId)
|
|
const newVersion = (existing?.version || 0) + 1
|
|
stateStore.set(sessionId, {
|
|
xml,
|
|
version: newVersion,
|
|
lastUpdated: new Date(),
|
|
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
|
syncRequested: undefined, // Clear sync request when browser pushes state
|
|
exportFormat: existing?.exportFormat, // Preserve pending export request
|
|
exportXml: existing?.exportXml, // Preserve pending projection
|
|
exportData: existing?.exportData, // Preserve export result
|
|
})
|
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
|
return newVersion
|
|
}
|
|
|
|
/**
|
|
* Ask the browser bridge to export the current diagram as png/svg.
|
|
*
|
|
* When `projectionXml` is given (a single-page <mxfile>), the bridge loads it
|
|
* first, waits for draw.io's own load event, exports, then reloads the
|
|
* session's real document — so a page-targeted export never mutates the
|
|
* canonical session state and needs no fixed-delay guessing on the server.
|
|
*
|
|
* Returns false when the session is unknown. Callers should then poll
|
|
* `getState(sessionId)?.exportData` for the result.
|
|
*/
|
|
export function requestExport(
|
|
sessionId: string,
|
|
format: "png" | "svg",
|
|
projectionXml?: string,
|
|
): boolean {
|
|
const state = stateStore.get(sessionId)
|
|
if (!state) return false
|
|
state.exportData = undefined
|
|
state.exportXml = projectionXml
|
|
state.exportFormat = format
|
|
return true
|
|
}
|
|
|
|
export function requestSync(sessionId: string): boolean {
|
|
const state = stateStore.get(sessionId)
|
|
if (state) {
|
|
state.syncRequested = Date.now()
|
|
log.debug(`Sync requested for session=${sessionId}`)
|
|
return true
|
|
}
|
|
log.debug(`Sync requested for non-existent session=${sessionId}`)
|
|
return false
|
|
}
|
|
|
|
export async function waitForSync(
|
|
sessionId: string,
|
|
timeoutMs = 3000,
|
|
): Promise<boolean> {
|
|
const start = Date.now()
|
|
while (Date.now() - start < timeoutMs) {
|
|
const state = stateStore.get(sessionId)
|
|
if (!state?.syncRequested) return true // Sync completed
|
|
await new Promise((r) => setTimeout(r, 100))
|
|
}
|
|
log.warn(`Sync timeout for session=${sessionId}`)
|
|
return false // Timeout
|
|
}
|
|
|
|
export function startHttpServer(port = 6002): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
if (server) {
|
|
resolve(serverPort)
|
|
return
|
|
}
|
|
|
|
serverPort = port
|
|
server = http.createServer(handleRequest)
|
|
|
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
if (err.code === "EADDRINUSE") {
|
|
if (port >= MAX_PORT) {
|
|
reject(
|
|
new Error(
|
|
`No available ports in range 6002-${MAX_PORT}`,
|
|
),
|
|
)
|
|
return
|
|
}
|
|
log.info(`Port ${port} in use, trying ${port + 1}`)
|
|
server = null
|
|
startHttpServer(port + 1)
|
|
.then(resolve)
|
|
.catch(reject)
|
|
} else {
|
|
reject(err)
|
|
}
|
|
})
|
|
|
|
server.listen(port, "127.0.0.1", () => {
|
|
serverPort = port
|
|
log.info(`HTTP server running on http://localhost:${port}`)
|
|
resolve(port)
|
|
})
|
|
})
|
|
}
|
|
|
|
export function stopHttpServer(): void {
|
|
if (server) {
|
|
server.close()
|
|
server = null
|
|
}
|
|
}
|
|
|
|
function cleanupExpiredSessions(): void {
|
|
const now = Date.now()
|
|
for (const [sessionId, state] of stateStore) {
|
|
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
|
|
stateStore.delete(sessionId)
|
|
clearHistory(sessionId)
|
|
log.info(`Cleaned up expired session: ${sessionId}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
const cleanupIntervalId = setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
|
|
|
|
export function shutdown(): void {
|
|
clearInterval(cleanupIntervalId)
|
|
stopHttpServer()
|
|
}
|
|
|
|
export function getServerPort(): number {
|
|
return serverPort
|
|
}
|
|
|
|
function handleRequest(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
): void {
|
|
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
|
|
|
const requestOrigin = req.headers.origin
|
|
if (requestOrigin === `http://localhost:${serverPort}`) {
|
|
res.setHeader("Access-Control-Allow-Origin", requestOrigin)
|
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
|
}
|
|
|
|
if (req.method === "OPTIONS") {
|
|
res.writeHead(204)
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
const sessionId = url.searchParams.get("mcp") || ""
|
|
|
|
// Auto-redirect to most recent session if no sessionId provided
|
|
if (!sessionId) {
|
|
const recentSessionId = getMostRecentSessionId()
|
|
if (recentSessionId) {
|
|
res.writeHead(302, { Location: `/?mcp=${recentSessionId}` })
|
|
res.end()
|
|
return
|
|
}
|
|
}
|
|
|
|
ensureSessionStateInitialized(sessionId)
|
|
|
|
res.writeHead(200, { "Content-Type": "text/html" })
|
|
res.end(getHtmlPage(sessionId))
|
|
} else if (url.pathname === "/api/state") {
|
|
handleStateApi(req, res, url)
|
|
} else if (url.pathname === "/api/history") {
|
|
handleHistoryApi(req, res, url)
|
|
} else if (url.pathname === "/api/restore") {
|
|
handleRestoreApi(req, res)
|
|
} else if (url.pathname === "/api/history-svg") {
|
|
handleHistorySvgApi(req, res)
|
|
} else {
|
|
res.writeHead(404)
|
|
res.end("Not Found")
|
|
}
|
|
}
|
|
|
|
function handleStateApi(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
url: URL,
|
|
): void {
|
|
if (req.method === "GET") {
|
|
const sessionId = url.searchParams.get("sessionId")
|
|
if (!sessionId) {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
|
return
|
|
}
|
|
ensureSessionStateInitialized(sessionId)
|
|
const state = stateStore.get(sessionId)
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(
|
|
JSON.stringify({
|
|
xml: state?.xml || null,
|
|
version: state?.version || 0,
|
|
syncRequested: !!state?.syncRequested,
|
|
exportFormat: state?.exportFormat || null,
|
|
exportXml: state?.exportXml || null,
|
|
}),
|
|
)
|
|
} else if (req.method === "POST") {
|
|
readBody(req, res, (body) => {
|
|
try {
|
|
const data = JSON.parse(body)
|
|
const { sessionId } = data
|
|
if (!sessionId) {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
|
return
|
|
}
|
|
|
|
// Browser is returning export data (png/svg)
|
|
if (data.exportData !== undefined) {
|
|
const state = stateStore.get(sessionId)
|
|
if (state) {
|
|
state.exportData = data.exportData
|
|
state.exportFormat = undefined
|
|
state.exportXml = undefined
|
|
log.debug(
|
|
`Export data received for session=${sessionId}`,
|
|
)
|
|
}
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ success: true }))
|
|
return
|
|
}
|
|
|
|
const version = setState(sessionId, data.xml, data.svg)
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ success: true, version }))
|
|
} catch {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
|
}
|
|
})
|
|
} else {
|
|
res.writeHead(405)
|
|
res.end("Method Not Allowed")
|
|
}
|
|
}
|
|
|
|
function handleHistoryApi(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
url: URL,
|
|
): void {
|
|
if (req.method !== "GET") {
|
|
res.writeHead(405)
|
|
res.end("Method Not Allowed")
|
|
return
|
|
}
|
|
|
|
const sessionId = url.searchParams.get("sessionId")
|
|
if (!sessionId) {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "sessionId required" }))
|
|
return
|
|
}
|
|
|
|
const history = getHistory(sessionId)
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(
|
|
JSON.stringify({
|
|
entries: history.map((entry, i) => ({ index: i, svg: entry.svg })),
|
|
count: history.length,
|
|
}),
|
|
)
|
|
}
|
|
|
|
function handleRestoreApi(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
): void {
|
|
if (req.method !== "POST") {
|
|
res.writeHead(405)
|
|
res.end("Method Not Allowed")
|
|
return
|
|
}
|
|
|
|
readBody(req, res, (body) => {
|
|
try {
|
|
const { sessionId, index } = JSON.parse(body)
|
|
if (!sessionId || index === undefined) {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(
|
|
JSON.stringify({ error: "sessionId and index required" }),
|
|
)
|
|
return
|
|
}
|
|
|
|
const entry = getHistoryEntry(sessionId, index)
|
|
if (!entry) {
|
|
res.writeHead(404, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "Entry not found" }))
|
|
return
|
|
}
|
|
|
|
const newVersion = setState(sessionId, entry.xml)
|
|
addHistory(sessionId, entry.xml, entry.svg)
|
|
|
|
log.info(`Restored session ${sessionId} to index ${index}`)
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ success: true, newVersion }))
|
|
} catch {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
|
}
|
|
})
|
|
}
|
|
|
|
function handleHistorySvgApi(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
): void {
|
|
if (req.method !== "POST") {
|
|
res.writeHead(405)
|
|
res.end("Method Not Allowed")
|
|
return
|
|
}
|
|
|
|
readBody(req, res, (body) => {
|
|
try {
|
|
const { sessionId, svg } = JSON.parse(body)
|
|
if (!sessionId || !svg) {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "sessionId and svg required" }))
|
|
return
|
|
}
|
|
|
|
updateLastHistorySvg(sessionId, svg)
|
|
res.writeHead(200, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ success: true }))
|
|
} catch {
|
|
res.writeHead(400, { "Content-Type": "application/json" })
|
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
|
}
|
|
})
|
|
}
|
|
|
|
function getHtmlPage(sessionId: string): string {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Next AI Draw.io</title>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap');
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html, body { width: 100%; height: 100%; overflow: hidden; }
|
|
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
|
#header {
|
|
padding: 0 20px; height: 52px;
|
|
background: linear-gradient(to bottom, #ffffff, #fafbfc);
|
|
border-bottom: 1px solid #e8ecf0;
|
|
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
|
position: relative; z-index: 10;
|
|
}
|
|
#header .brand {
|
|
display: flex; align-items: center; gap: 10px;
|
|
}
|
|
#header .logo {
|
|
width: 28px; height: 28px; border-radius: 6px;
|
|
background: #18181b;
|
|
display: flex; align-items: center; justify-content: center;
|
|
overflow: hidden;
|
|
}
|
|
#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }
|
|
#header .title {
|
|
font-size: 15px; font-weight: 600; color: #1a1a2e;
|
|
letter-spacing: -0.3px;
|
|
}
|
|
#header .session {
|
|
font-size: 11px; color: #8b95a5; font-weight: 400;
|
|
background: #f1f3f9; padding: 3px 8px; border-radius: 4px;
|
|
margin-left: 12px; font-family: 'SF Mono', Monaco, monospace;
|
|
}
|
|
#header .right { display: flex; align-items: center; gap: 12px; }
|
|
#save-btn {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 7px 14px; border-radius: 8px; font-size: 13px;
|
|
background: linear-gradient(to bottom, #18181b, #27272a);
|
|
color: white; border: none; cursor: pointer;
|
|
font-weight: 500; font-family: inherit;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
|
transition: all 0.15s ease;
|
|
}
|
|
#save-btn svg { width: 14px; height: 14px; }
|
|
#save-btn:hover {
|
|
background: linear-gradient(to bottom, #27272a, #3f3f46);
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
|
|
}
|
|
#save-btn:active { transform: translateY(0); }
|
|
#save-btn:disabled, #history-btn:disabled {
|
|
background: #e5e7eb; color: #9ca3af;
|
|
cursor: not-allowed; transform: none; box-shadow: none;
|
|
}
|
|
#history-btn {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 7px 14px; border-radius: 8px; font-size: 13px;
|
|
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
|
|
cursor: pointer; font-weight: 500; font-family: inherit;
|
|
transition: all 0.15s ease;
|
|
}
|
|
#history-btn svg { width: 14px; height: 14px; }
|
|
#history-btn:hover {
|
|
background: #e4e4e7; border-color: #d4d4d8;
|
|
}
|
|
#drawio { flex: 1; border: none; }
|
|
#history-modal, #save-modal {
|
|
display: none; position: fixed; inset: 0;
|
|
background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
|
|
z-index: 2000; align-items: center; justify-content: center;
|
|
}
|
|
#history-modal.open, #save-modal.open { display: flex; }
|
|
.modal-content {
|
|
background: white; border-radius: 16px;
|
|
width: 90%; max-width: 480px; max-height: 70vh;
|
|
display: flex; flex-direction: column;
|
|
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
|
|
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
|
|
animation: modalIn 0.2s ease-out;
|
|
}
|
|
@keyframes modalIn {
|
|
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
}
|
|
.modal-header {
|
|
padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5;
|
|
}
|
|
.modal-header h2 {
|
|
font-size: 17px; font-weight: 600; margin: 0; color: #18181b;
|
|
letter-spacing: -0.3px;
|
|
}
|
|
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
|
.modal-footer {
|
|
padding: 16px 24px; border-top: 1px solid #f1f3f5;
|
|
display: flex; gap: 10px; justify-content: flex-end;
|
|
}
|
|
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
|
.history-item {
|
|
border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px;
|
|
cursor: pointer; text-align: center; transition: all 0.15s ease;
|
|
background: #fafafa;
|
|
}
|
|
.history-item:hover { border-color: #a1a1aa; background: white; }
|
|
.history-item.selected {
|
|
border-color: #18181b; background: white;
|
|
box-shadow: 0 0 0 3px rgba(24,24,27,0.1);
|
|
}
|
|
.history-item .thumb {
|
|
aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
margin-bottom: 6px; overflow: hidden;
|
|
}
|
|
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
|
.history-item .label { font-size: 11px; color: #71717a; font-weight: 500; }
|
|
.btn {
|
|
padding: 9px 18px; border-radius: 8px; font-size: 13px;
|
|
cursor: pointer; border: none; font-weight: 500;
|
|
font-family: inherit; transition: all 0.15s ease;
|
|
}
|
|
.btn-primary {
|
|
background: linear-gradient(to bottom, #18181b, #27272a);
|
|
color: white;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
|
|
}
|
|
.btn-primary:hover {
|
|
background: linear-gradient(to bottom, #27272a, #3f3f46);
|
|
transform: translateY(-1px);
|
|
}
|
|
.btn-primary:disabled {
|
|
background: #e4e4e7; color: #a1a1aa;
|
|
cursor: not-allowed; transform: none; box-shadow: none;
|
|
}
|
|
.btn-secondary {
|
|
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
|
|
}
|
|
.btn-secondary:hover { background: #e4e4e7; }
|
|
.empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; }
|
|
.form-group { margin-bottom: 18px; }
|
|
.form-group label {
|
|
display: block; font-size: 13px; font-weight: 500;
|
|
margin-bottom: 8px; color: #3f3f46;
|
|
}
|
|
.form-group select, .form-group input {
|
|
width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7;
|
|
border-radius: 8px; font-size: 14px; outline: none;
|
|
font-family: inherit; background: white;
|
|
transition: all 0.15s ease;
|
|
}
|
|
.form-group select:focus, .form-group input:focus {
|
|
border-color: #18181b;
|
|
box-shadow: 0 0 0 3px rgba(24,24,27,0.08);
|
|
}
|
|
.filename-group { display: flex; }
|
|
.filename-group input { border-radius: 8px 0 0 8px; border-right: none; }
|
|
.filename-group .ext {
|
|
padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7;
|
|
border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a;
|
|
font-family: 'SF Mono', Monaco, monospace;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="container">
|
|
<div id="header">
|
|
<div class="brand">
|
|
<div class="logo">
|
|
<svg viewBox="0 0 1536 1536" fill="#ffffff">
|
|
<g transform="translate(0,1536) scale(0.1,-0.1)">
|
|
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431 -560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356 319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85 -17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529 -2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128 -82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303 -177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276 286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242 241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337 -25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22 -5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204 -383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72 -167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300 6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884 -15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534 c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154 11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448 -20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10 -47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293 -369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6 39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52 -149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415 20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50 118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0 535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539 376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70 290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
<span class="title">Next AI Draw.io</span>
|
|
${sessionId ? `<span class="session">${sessionId.slice(-8)}</span>` : ""}
|
|
</div>
|
|
<div class="right">
|
|
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
<polyline points="12 6 12 12 16 14"></polyline>
|
|
</svg>
|
|
History
|
|
</button>
|
|
<button id="save-btn" ${sessionId ? "" : "disabled"}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
|
<polyline points="7 10 12 15 17 10"></polyline>
|
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
|
</svg>
|
|
Download
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0"></iframe>
|
|
</div>
|
|
<div id="history-modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header"><h2>History</h2></div>
|
|
<div class="modal-body">
|
|
<div id="history-grid" class="history-grid"></div>
|
|
<div id="history-empty" class="empty" style="display:none;">No history yet</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" id="cancel-btn">Cancel</button>
|
|
<button class="btn btn-primary" id="restore-btn" disabled>Restore</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="save-modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header"><h2>Download Diagram</h2></div>
|
|
<div class="modal-body">
|
|
<div class="form-group">
|
|
<label>Format</label>
|
|
<select id="save-format">
|
|
<option value="drawio">Draw.io (.drawio)</option>
|
|
<option value="png">PNG Image (.png)</option>
|
|
<option value="svg">SVG Vector (.svg)</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Filename</label>
|
|
<div class="filename-group">
|
|
<input type="text" id="save-filename" value="diagram" placeholder="Enter filename">
|
|
<span class="ext" id="save-ext">.drawio</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" id="save-cancel-btn">Cancel</button>
|
|
<button class="btn btn-primary" id="save-confirm-btn">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
const sessionId = "${sessionId}";
|
|
const iframe = document.getElementById('drawio');
|
|
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
|
let pendingSvgExport = null;
|
|
let pendingAiSvg = false;
|
|
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
|
let projectionExportActive = false; // page-targeted export: showing a transient single-page projection
|
|
let projectionRestoreXml = null; // the real document to reload once a projection export finishes
|
|
|
|
window.addEventListener('message', (e) => {
|
|
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
|
try {
|
|
const msg = JSON.parse(e.data);
|
|
if (msg.event === 'init') {
|
|
isReady = true;
|
|
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
|
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
|
// Ignore autosave while a single-page projection is on screen
|
|
// for a page-targeted export — otherwise we'd push the
|
|
// transient projection back as the canonical session state.
|
|
if (projectionExportActive) return;
|
|
// Request SVG export, then push state with SVG
|
|
pendingSvgExport = msg.xml;
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
|
// Fallback if export doesn't respond
|
|
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
|
} else if (msg.event === 'export' && msg.data) {
|
|
// Handle MCP server export request (png/svg)
|
|
// Verify the response matches the requested format to avoid capturing
|
|
// unrelated exports (autosave SVG, sync XML)
|
|
if (pendingMcpExport) {
|
|
const d = msg.data;
|
|
const isPng = pendingMcpExport === 'png' && (d.startsWith('data:image/png') || (typeof d === 'string' && d.length > 100 && !d.startsWith('<')));
|
|
const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
|
|
if (isPng || isSvg) {
|
|
pendingMcpExport = null;
|
|
fetch('/api/state', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sessionId, exportData: d })
|
|
}).catch(() => {});
|
|
// Page-targeted export: restore the user's real
|
|
// multi-page document now that we have the image.
|
|
restoreFromProjection();
|
|
return;
|
|
}
|
|
}
|
|
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
|
|
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
|
|
const dl = pendingDownload;
|
|
pendingDownload = null;
|
|
let dataUrl = msg.data;
|
|
if (!dataUrl.startsWith('data:')) {
|
|
const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml';
|
|
dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data)));
|
|
}
|
|
const a = document.createElement('a');
|
|
a.href = dataUrl; a.download = dl.filename;
|
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
saveModal.classList.remove('open');
|
|
saveConfirmBtn.disabled = false;
|
|
saveConfirmBtn.textContent = 'Save';
|
|
return;
|
|
}
|
|
// Handle sync export (XML format) - server requested fresh state
|
|
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
|
|
pendingSyncExport = false;
|
|
pushState(msg.data, '');
|
|
return;
|
|
}
|
|
// Handle SVG export
|
|
let svg = msg.data;
|
|
if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
|
|
if (pendingSvgExport) {
|
|
const xml = pendingSvgExport;
|
|
pendingSvgExport = null;
|
|
pushState(xml, svg);
|
|
} else if (pendingAiSvg) {
|
|
pendingAiSvg = false;
|
|
fetch('/api/history-svg', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sessionId, svg })
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
} catch {}
|
|
});
|
|
|
|
function loadDiagram(xml, capturePreview = false) {
|
|
if (!isReady) { pendingXml = xml; return; }
|
|
lastXml = xml;
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
|
|
if (capturePreview) {
|
|
setTimeout(() => {
|
|
pendingAiSvg = true;
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
// Restore the user's real document after a page-targeted projection
|
|
// export. If we never captured one (lastXml was null at projection
|
|
// start), fall back to forcing a reload from the server on the next
|
|
// poll by rewinding currentVersion — never leave the iframe stuck on
|
|
// the transient projection.
|
|
function restoreFromProjection() {
|
|
if (!projectionExportActive) return;
|
|
projectionExportActive = false;
|
|
if (projectionRestoreXml) {
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: projectionRestoreXml, autosave: 1 }), '*');
|
|
projectionRestoreXml = null;
|
|
} else {
|
|
currentVersion = -1; // force the next poll to reload from server
|
|
}
|
|
}
|
|
|
|
async function pushState(xml, svg = '') {
|
|
if (!sessionId) return;
|
|
try {
|
|
const r = await fetch('/api/state', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sessionId, xml, svg })
|
|
});
|
|
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
|
|
} catch (e) { console.error('Push failed:', e); }
|
|
}
|
|
|
|
let pendingSyncExport = false;
|
|
|
|
async function poll() {
|
|
if (!sessionId) return;
|
|
try {
|
|
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
|
if (!r.ok) return;
|
|
const s = await r.json();
|
|
// Handle sync request - server needs fresh state
|
|
if (s.syncRequested && !pendingSyncExport) {
|
|
pendingSyncExport = true;
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
|
}
|
|
// Load new diagram from server (before export, so we export latest).
|
|
// While a page-targeted projection is on screen, skip the reload
|
|
// so it doesn't fight the projection — and leave currentVersion
|
|
// unadvanced so this bump is re-detected and applied once the
|
|
// real document is restored.
|
|
if (s.version > currentVersion && s.xml && !projectionExportActive) {
|
|
currentVersion = s.version;
|
|
loadDiagram(s.xml, true);
|
|
}
|
|
// Handle export request from MCP server (png/svg).
|
|
//
|
|
// Plain export: capture whatever tab is currently displayed.
|
|
//
|
|
// Page-targeted export: the server sends a single-page <mxfile>
|
|
// projection in s.exportXml. We load it into the iframe, let
|
|
// draw.io render it, export, then reload the user's real
|
|
// document — all browser-side. The canonical session state is
|
|
// never mutated, so there is no server-side restore race and no
|
|
// dependence on poll timing. autosave is suppressed while the
|
|
// projection is showing (see projectionExportActive guard).
|
|
if (s.exportFormat && !pendingMcpExport && isReady) {
|
|
pendingMcpExport = s.exportFormat;
|
|
const fireExport = () => {
|
|
const exportOpts = pendingMcpExport === 'png'
|
|
? { action: 'export', format: 'png', scale: 2 }
|
|
: { action: 'export', format: 'svg' };
|
|
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
|
};
|
|
if (s.exportXml) {
|
|
// Stash the real document so we can restore after export.
|
|
projectionRestoreXml = lastXml;
|
|
projectionExportActive = true;
|
|
// Load the projection without touching lastXml/server state.
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*');
|
|
// Let draw.io render the loaded page before exporting
|
|
// (same proven settle delay as the AI-preview path).
|
|
setTimeout(fireExport, 600);
|
|
} else {
|
|
fireExport();
|
|
}
|
|
// Timeout: reset if draw.io never responds, and restore the
|
|
// real document if a projection was left showing.
|
|
setTimeout(() => {
|
|
if (pendingMcpExport) {
|
|
pendingMcpExport = null;
|
|
restoreFromProjection();
|
|
}
|
|
}, 10000);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if (sessionId) { poll(); setInterval(poll, 2000); }
|
|
|
|
// Save modal
|
|
const saveBtn = document.getElementById('save-btn');
|
|
const saveModal = document.getElementById('save-modal');
|
|
const saveFormat = document.getElementById('save-format');
|
|
const saveFilename = document.getElementById('save-filename');
|
|
const saveExt = document.getElementById('save-ext');
|
|
const saveCancelBtn = document.getElementById('save-cancel-btn');
|
|
const saveConfirmBtn = document.getElementById('save-confirm-btn');
|
|
let pendingDownload = null;
|
|
|
|
const extMap = { drawio: '.drawio', png: '.png', svg: '.svg' };
|
|
|
|
saveBtn.onclick = () => {
|
|
if (!sessionId || !isReady) return;
|
|
saveModal.classList.add('open');
|
|
saveFilename.focus();
|
|
saveFilename.select();
|
|
};
|
|
|
|
saveFormat.onchange = () => {
|
|
saveExt.textContent = extMap[saveFormat.value] || '.drawio';
|
|
};
|
|
|
|
saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); };
|
|
saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); };
|
|
|
|
saveConfirmBtn.onclick = () => {
|
|
const format = saveFormat.value;
|
|
const filename = (saveFilename.value.trim() || 'diagram') + extMap[format];
|
|
saveConfirmBtn.disabled = true;
|
|
saveConfirmBtn.textContent = 'Exporting...';
|
|
|
|
if (format === 'drawio') {
|
|
// Use lastXml directly instead of requesting export (avoids race with SVG exports).
|
|
// session.xml is canonically <mxfile> after the multi-page refactor,
|
|
// so no wrapper injection is needed. The legacy fallback below
|
|
// remains only for documents that somehow slipped past
|
|
// normalisation (e.g. an older session loaded from external state).
|
|
let xmlData = lastXml || '';
|
|
if (xmlData && !xmlData.includes('<mxfile')) {
|
|
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
|
}
|
|
const blob = new Blob([xmlData], { type: 'application/xml' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = filename;
|
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
saveModal.classList.remove('open');
|
|
saveConfirmBtn.disabled = false;
|
|
saveConfirmBtn.textContent = 'Save';
|
|
} else if (format === 'png') {
|
|
pendingDownload = { format: 'png', filename };
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2 }), '*');
|
|
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
|
|
} else if (format === 'svg') {
|
|
pendingDownload = { format: 'svg', filename };
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
|
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
|
|
}
|
|
};
|
|
|
|
// History UI
|
|
const historyBtn = document.getElementById('history-btn');
|
|
const historyModal = document.getElementById('history-modal');
|
|
const historyGrid = document.getElementById('history-grid');
|
|
const historyEmpty = document.getElementById('history-empty');
|
|
const restoreBtn = document.getElementById('restore-btn');
|
|
const cancelBtn = document.getElementById('cancel-btn');
|
|
let historyData = [], selectedIdx = null;
|
|
|
|
historyBtn.onclick = async () => {
|
|
if (!sessionId) return;
|
|
try {
|
|
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
|
|
if (r.ok) {
|
|
const d = await r.json();
|
|
historyData = d.entries || [];
|
|
renderHistory();
|
|
}
|
|
} catch {}
|
|
historyModal.classList.add('open');
|
|
};
|
|
|
|
cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedIdx = null; restoreBtn.disabled = true; };
|
|
historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); };
|
|
|
|
function renderHistory() {
|
|
if (historyData.length === 0) {
|
|
historyGrid.style.display = 'none';
|
|
historyEmpty.style.display = 'block';
|
|
return;
|
|
}
|
|
historyGrid.style.display = 'grid';
|
|
historyEmpty.style.display = 'none';
|
|
historyGrid.innerHTML = historyData.map((e, i) => \`
|
|
<div class="history-item" data-idx="\${e.index}">
|
|
<div class="thumb">\${e.svg ? \`<img src="\${e.svg}">\` : '#' + e.index}</div>
|
|
<div class="label">#\${e.index}</div>
|
|
</div>
|
|
\`).join('');
|
|
historyGrid.querySelectorAll('.history-item').forEach(item => {
|
|
item.onclick = () => {
|
|
const idx = parseInt(item.dataset.idx);
|
|
if (selectedIdx === idx) { selectedIdx = null; restoreBtn.disabled = true; }
|
|
else { selectedIdx = idx; restoreBtn.disabled = false; }
|
|
historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.idx) === selectedIdx));
|
|
};
|
|
});
|
|
}
|
|
|
|
restoreBtn.onclick = async () => {
|
|
if (selectedIdx === null) return;
|
|
restoreBtn.disabled = true;
|
|
restoreBtn.textContent = 'Restoring...';
|
|
try {
|
|
const r = await fetch('/api/restore', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sessionId, index: selectedIdx })
|
|
});
|
|
if (r.ok) { cancelBtn.onclick(); await poll(); }
|
|
else { alert('Restore failed'); }
|
|
} catch { alert('Restore failed'); }
|
|
restoreBtn.textContent = 'Restore';
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
}
|