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