diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json index b991fe64..c8ffaebd 100644 --- a/packages/mcp-server/package-lock.json +++ b/packages/mcp-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@next-ai-drawio/mcp-server", - "version": "0.5.0", + "version": "0.6.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.31.0", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index f4120946..adca08b1 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.5.0", + "version": "0.6.0", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "type": "module", "main": "dist/index.js", diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index 3d5a00da..ad269a03 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -104,12 +104,15 @@ interface SessionState { lastPolled?: number // Last browser poll; an open tab keeps the session alive 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 + exportFormat?: ExportFormat // Set by MCP tool to request browser export exportXml?: string // Single-page projection to load before a page-targeted export exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only) exportData?: string // Base64/SVG data returned by browser after export } +/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */ +export type ExportFormat = "png" | "svg" | "xmlsvg" + /** * draw.io's PNG export takes these directly: width caps the image size * (never upscales), pageId renders a page other than the one on screen. @@ -179,7 +182,7 @@ export function setState( */ export function requestExport( sessionId: string, - format: "png" | "svg", + format: ExportFormat, projectionXml?: string, options?: ExportOptions, ): boolean { diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 53bd4f11..faacb463 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -31,6 +31,7 @@ import { editDiagram, targetPageXml } from "./edit-diagram.js" import { checkEditGate } from "./edit-gate.js" import { addHistory } from "./history.js" import { + type ExportFormat, type ExportOptions, getServerPort, getState, @@ -900,7 +901,7 @@ let exportQueue: Promise = Promise.resolve() */ function exportViaBrowser( sessionId: string, - format: "png" | "svg", + format: ExportFormat, projectionXml?: string, options?: ExportOptions, ): Promise { @@ -1092,14 +1093,14 @@ server.registerTool( { title: "Export diagram", description: - "Export the current diagram to a file. Supports .drawio (XML), .png, and .svg formats. " + + "Export the current diagram to a file. Supports .drawio (XML), .png, .svg, and .drawio.svg (an SVG with the diagram embedded, which draw.io can open and edit again). " + "The format is auto-detected from the file extension, or can be specified explicitly.\n\n" + "Multi-page behaviour:\n" + "- .drawio with NO page selector: writes the full (all pages).\n" + "- .drawio with a page selector: writes a single-page containing only that page.\n" + "- .png / .svg with NO page selector: exports the currently active page in the browser.\n" + "- .png with a page selector: renders that page without changing what the user sees.\n" + - "- .svg with a page selector: temporarily loads that page into the browser, captures it, then restores the full document (the user sees a brief flicker).", + "- .svg / .drawio.svg with a page selector: temporarily loads that page into the browser, captures it, then restores the full document (the user sees a brief flicker).", inputSchema: { ...pageSelectorSchema, path: z @@ -1108,7 +1109,7 @@ server.registerTool( "Absolute file path to save to (e.g. /Users/me/diagram.drawio, ~/diagram.png). Relative paths resolve against the MCP server's working directory, which is often not your project.", ), format: z - .enum(["drawio", "png", "svg"]) + .enum(["drawio", "png", "svg", "drawio.svg"]) .optional() .describe( "Export format. If omitted, detected from file extension. Defaults to drawio.", @@ -1161,10 +1162,16 @@ server.registerTool( const nodePath = await import("node:path") // Detect format from extension if not specified - const ext = nodePath.extname(path).toLowerCase() + const lowerPath = path.toLowerCase() const detectedFormat = format || - (ext === ".png" ? "png" : ext === ".svg" ? "svg" : "drawio") + (lowerPath.endsWith(".drawio.svg") + ? "drawio.svg" + : lowerPath.endsWith(".png") + ? "png" + : lowerPath.endsWith(".svg") + ? "svg" + : "drawio") // .drawio path - write XML directly (no browser round-trip). if (detectedFormat === "drawio") { @@ -1209,15 +1216,21 @@ server.registerTool( } } - // PNG or SVG: request browser to export via iframe + // PNG or SVG: request browser to export via iframe. Replace a + // known extension that does not match the format. let filePath = path - if (ext !== `.${detectedFormat}`) { - if (ext === ".drawio" || ext === ".png" || ext === ".svg") { - filePath = filePath.slice(0, -ext.length) - } - filePath = `${filePath}.${detectedFormat}` + const suffix = `.${detectedFormat}` + if (!lowerPath.endsWith(suffix)) { + const known = [".drawio.svg", ".drawio", ".png", ".svg"].find( + (e) => lowerPath.endsWith(e), + ) + if (known) filePath = filePath.slice(0, -known.length) + filePath = `${filePath}${suffix}` } const absolutePath = nodePath.resolve(filePath) + // draw.io's name for an SVG with the diagram embedded + const browserFormat = + detectedFormat === "drawio.svg" ? "xmlsvg" : detectedFormat const state = getState(currentSession.id) if (!state) { @@ -1275,7 +1288,7 @@ server.registerTool( const exportData = await exportViaBrowser( currentSession.id, - detectedFormat as "png" | "svg", + browserFormat, projectionXml, pngPageId ? { pageId: pngPageId } : undefined, ) diff --git a/packages/mcp-server/src/preview/index.html b/packages/mcp-server/src/preview/index.html index 501718eb..d7858cdd 100644 --- a/packages/mcp-server/src/preview/index.html +++ b/packages/mcp-server/src/preview/index.html @@ -40,7 +40,7 @@ - +
@@ -66,12 +66,13 @@ +
- + .drawio
diff --git a/packages/mcp-server/src/preview/preview.css b/packages/mcp-server/src/preview/preview.css index 16e95986..15e85066 100644 --- a/packages/mcp-server/src/preview/preview.css +++ b/packages/mcp-server/src/preview/preview.css @@ -1,4 +1,3 @@ -@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; } @@ -6,7 +5,7 @@ html, body { width: 100%; height: 100%; overflow: hidden; } 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; + font-family: system-ui, -apple-system, 'Segoe UI', 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; @@ -74,7 +73,7 @@ html, body { width: 100%; height: 100%; overflow: hidden; } 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; + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; animation: modalIn 0.2s ease-out; } @keyframes modalIn { @@ -156,7 +155,7 @@ html, body { width: 100%; height: 100%; overflow: hidden; } transform: translateX(-50%); z-index: 3000; max-width: 480px; padding: 10px 16px; border-radius: 8px; font-size: 13px; background: #18181b; color: white; - font-family: 'DM Sans', system-ui, -apple-system, sans-serif; + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; box-shadow: 0 4px 12px rgba(0,0,0,0.2); } #notice.open { display: block; } @@ -165,3 +164,27 @@ html, body { width: 100%; height: 100%; overflow: hidden; } border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a; font-family: 'SF Mono', Monaco, monospace; } + +/* Dark mode follows the system, like draw.io with dark=auto */ +@media (prefers-color-scheme: dark) { + #header { background: #1c1c20; border-bottom-color: #2e2e34; box-shadow: none; } + #header .title { color: #f4f4f5; } + #header .session { background: #2a2a30; color: #a1a1aa; } + #history-btn, .btn-secondary { background: #2a2a30; color: #e4e4e7; border-color: #3f3f46; } + #history-btn:hover, .btn-secondary:hover { background: #3f3f46; border-color: #52525b; } + #save-btn, .btn-primary { background: #f4f4f5; color: #18181b; } + #save-btn:hover, .btn-primary:hover { background: #e4e4e7; } + #save-btn:disabled, #history-btn:disabled, .btn-primary:disabled { background: #2a2a30; color: #71717a; } + .modal-content { background: #1c1c20; color: #e4e4e7; } + .modal-header, .modal-footer { border-color: #2e2e34; } + .modal-header h2 { color: #f4f4f5; } + .history-item { background: #27272a; border-color: #3f3f46; } + .history-item:hover { background: #2e2e34; border-color: #71717a; } + .history-item.selected { background: #2e2e34; border-color: #f4f4f5; box-shadow: 0 0 0 3px rgba(244,244,245,0.15); } + .history-item .label, .empty { color: #a1a1aa; } + .form-group label { color: #d4d4d8; } + .form-group select, .form-group input { background: #27272a; color: #f4f4f5; border-color: #3f3f46; } + .form-group select:focus, .form-group input:focus { border-color: #a1a1aa; box-shadow: 0 0 0 3px rgba(244,244,245,0.1); } + .filename-group .ext { background: #2a2a30; border-color: #3f3f46; color: #a1a1aa; } + #notice { background: #f4f4f5; color: #18181b; } +} diff --git a/packages/mcp-server/src/preview/preview.js b/packages/mcp-server/src/preview/preview.js index 95307e25..e7d5213b 100644 --- a/packages/mcp-server/src/preview/preview.js +++ b/packages/mcp-server/src/preview/preview.js @@ -3,7 +3,7 @@ let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null; let pendingSvgExport = null; let pendingSvgBase = 0; // version the pending autosave was based on let pendingAiSvg = false; -let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export +let pendingMcpExport = null; // 'png', 'svg' or 'xmlsvg' when MCP requested export let mcpExportSeq = 0; // number of the latest MCP export let projectionExportActive = false; // page-targeted export: showing a transient single-page projection let forceReload = false; // reload the server state on the next poll even if the version is unchanged @@ -49,7 +49,7 @@ window.addEventListener('message', (e) => { if (msg.message.mcpExport !== mcpExportSeq) return; const d = msg.data; const isPng = pendingMcpExport === 'png' && d.startsWith('data:image/png'); - const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith(' { if (!sessionId || !isReady) return; + // Local date as YYYY-MM-DD, like the web app's default name + saveFilename.value = 'diagram-' + new Date().toLocaleDateString('sv-SE'); saveModal.classList.add('open'); saveFilename.focus(); saveFilename.select(); }; +saveFilename.onkeydown = (e) => { + if (e.key === 'Enter' && !e.isComposing && !saveConfirmBtn.disabled) saveConfirmBtn.onclick(); +}; + +document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + if (saveModal.classList.contains('open')) saveCancelBtn.onclick(); + if (historyModal.classList.contains('open')) cancelBtn.onclick(); +}); + saveFormat.onchange = () => { saveExt.textContent = extMap[saveFormat.value] || '.drawio'; }; @@ -291,9 +303,10 @@ saveConfirmBtn.onclick = () => { pendingDownload = { format: 'png', filename }; iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2, currentPage: true, dlExport: true }), '*'); 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', dlExport: true }), '*'); + } else { + // svg, or xmlsvg: an SVG with the diagram embedded, which draw.io can open again + pendingDownload = { format, filename }; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format, dlExport: true }), '*'); setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000); } }; @@ -358,7 +371,7 @@ restoreBtn.onclick = async () => { body: JSON.stringify({ sessionId, id: selectedId }) }); if (r.ok) { cancelBtn.onclick(); await poll(); } - else { alert('Restore failed'); } - } catch { alert('Restore failed'); } + else { showNotice('Restore failed. Please try again.'); } + } catch { showNotice('Restore failed. Please try again.'); } restoreBtn.textContent = 'Restore'; };