feat(mcp-server): add theme menu, dark mode and editable SVG to the preview

- Pass themes=1 and dark=auto to the draw.io iframe, so the Extras menu
  offers the Theme submenu and draw.io follows the system dark mode; the
  header and dialogs follow it too through prefers-color-scheme
- Download dialog: new Editable SVG (.drawio.svg) format through draw.io's
  xmlsvg export, default name diagram-YYYY-MM-DD, Enter saves and Escape
  closes it and the history dialog
- export_diagram accepts format "drawio.svg" and detects the .drawio.svg
  extension
- Use system fonts instead of Google Fonts, so the page works offline with
  a self-hosted draw.io (DRAWIO_BASE_URL)
This commit is contained in:
dayuan.jiang
2026-10-04 07:10:01 +09:00
parent f52f95025f
commit 127562b0e5
7 changed files with 86 additions and 33 deletions
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+5 -2
View File
@@ -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 {
+26 -13
View File
@@ -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<unknown> = Promise.resolve()
*/
function exportViaBrowser(
sessionId: string,
format: "png" | "svg",
format: ExportFormat,
projectionXml?: string,
options?: ExportOptions,
): Promise<string | undefined> {
@@ -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 <mxfile> (all pages).\n" +
"- .drawio with a page selector: writes a single-page <mxfile> 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,
)
+3 -2
View File
@@ -40,7 +40,7 @@
</button>
</div>
</div>
<iframe id="drawio" src="{{DRAWIO_URL}}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0"></iframe>
<iframe id="drawio" src="{{DRAWIO_URL}}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&dark=auto" title="draw.io editor"></iframe>
</div>
<div id="notice" role="status"></div>
<div id="history-modal">
@@ -66,12 +66,13 @@
<option value="drawio">Draw.io (.drawio)</option>
<option value="png">PNG Image (.png)</option>
<option value="svg">SVG Vector (.svg)</option>
<option value="xmlsvg">Editable SVG (.drawio.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">
<input type="text" id="save-filename" value="diagram" placeholder="Enter filename" autocomplete="off">
<span class="ext" id="save-ext">.drawio</span>
</div>
</div>
+27 -4
View File
@@ -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; }
}
+22 -9
View File
@@ -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('<svg'));
const isSvg = (pendingMcpExport === 'svg' || pendingMcpExport === 'xmlsvg') && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
if (isPng || isSvg) {
pendingMcpExport = null;
fetch('/api/state', {
@@ -208,7 +208,7 @@ async function poll() {
// draw.io would use the first page.
const exportOpts = pendingMcpExport === 'png'
? { action: 'export', format: 'png', scale: 2, currentPage: !extra.pageId, ...extra, mcpExport: seq }
: { action: 'export', format: 'svg', mcpExport: seq };
: { action: 'export', format: pendingMcpExport, mcpExport: seq };
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
};
if (s.exportXml) {
@@ -246,15 +246,27 @@ 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' };
const extMap = { drawio: '.drawio', png: '.png', svg: '.svg', xmlsvg: '.drawio.svg' };
saveBtn.onclick = () => {
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';
};