mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-05 01:07:56 +08:00
refactor(mcp-server): move the preview page into src/preview
The 580-line page template in http-server.ts becomes index.html,
preview.css and preview.js, copied to dist/preview by the build and
filled at request time. The rendered page is unchanged apart from the
session id and draw.io origin now coming from a small config script.
Biome skips the folder because of the {{placeholders}}, as it never
linted the old template string either.
This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!public"]
|
||||
"includes": ["**", "!public", "!packages/mcp-server/src/preview"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
// Run after `npm run build`.
|
||||
import { execSync } from "node:child_process"
|
||||
|
||||
const REQUIRED = ["dist/index.js", "dist/shape-libraries/aws4.md"]
|
||||
const REQUIRED = [
|
||||
"dist/index.js",
|
||||
"dist/shape-libraries/aws4.md",
|
||||
"dist/preview/index.html",
|
||||
"dist/preview/preview.css",
|
||||
"dist/preview/preview.js",
|
||||
]
|
||||
|
||||
const [pack] = JSON.parse(
|
||||
execSync("npm pack --dry-run --json", { encoding: "utf8" }),
|
||||
|
||||
@@ -15,3 +15,8 @@ for (const file of readdirSync(libSrc)) {
|
||||
cpSync(join(libSrc, file), join(libDest, file))
|
||||
}
|
||||
}
|
||||
|
||||
// Browser preview page (HTML, CSS and script)
|
||||
cpSync(join(pkg, "src/preview"), join(pkg, "dist/preview"), {
|
||||
recursive: true,
|
||||
})
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
* Serves draw.io embed with state sync and history UI
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import http from "node:http"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
|
||||
|
||||
@@ -586,624 +589,34 @@ function handleHistorySvgApi(
|
||||
})
|
||||
}
|
||||
|
||||
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; }
|
||||
#notice {
|
||||
display: none; position: fixed; left: 50%; bottom: 24px;
|
||||
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;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
#notice.open { display: block; }
|
||||
.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="notice" role="status"></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 = ${JSON.stringify(sessionId).replace(/</g, "\\u003c")};
|
||||
const iframe = document.getElementById('drawio');
|
||||
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 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
|
||||
let noticeTimer = null;
|
||||
// The preview page lives in src/preview (the build copies it to dist/preview)
|
||||
const PREVIEW_DIR = join(dirname(fileURLToPath(import.meta.url)), "preview")
|
||||
let previewTemplate: string | null = null
|
||||
|
||||
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. Remember the
|
||||
// version this edit is based on, so the server can reject it
|
||||
// if the AI wrote a newer version that is not loaded yet.
|
||||
pendingSvgExport = msg.xml;
|
||||
pendingSvgBase = currentVersion;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||
// Fallback if export doesn't respond
|
||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase); pendingSvgExport = null; } }, 2000);
|
||||
} else if (msg.event === 'export' && msg.format === 'xml') {
|
||||
// Sync export requested by the server (get_diagram).
|
||||
// draw.io returns the XML in msg.xml, with no msg.data.
|
||||
if (pendingSyncExport && msg.xml) {
|
||||
pendingSyncExport = false;
|
||||
// Push with the version the export was taken at: a
|
||||
// newer AI write may have loaded meanwhile, and this
|
||||
// older XML must not overwrite it.
|
||||
pushState(msg.xml, '', pendingSyncBase, 'sync');
|
||||
}
|
||||
} else if (msg.event === 'export' && msg.data) {
|
||||
// Handle MCP server export request (png/svg). fireExport tags
|
||||
// the request with mcpExport and draw.io echoes the request
|
||||
// back in msg.message, which tells it apart from autosave and
|
||||
// preview SVG exports.
|
||||
if (msg.message && msg.message.mcpExport) {
|
||||
// A late reply to an export that already timed out
|
||||
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'));
|
||||
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). Tagged with dlExport like mcpExport,
|
||||
// so an autosave SVG export can never be saved instead.
|
||||
if (msg.message && msg.message.dlExport) {
|
||||
if (!pendingDownload) return;
|
||||
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 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, pendingSvgBase);
|
||||
} 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 by reloading the server state. The server also has any
|
||||
// autosave that was still in flight when the projection started,
|
||||
// which a copy taken at that moment would miss. A flag is used
|
||||
// because a push finishing meanwhile may update currentVersion.
|
||||
function restoreFromProjection() {
|
||||
if (!projectionExportActive) return;
|
||||
projectionExportActive = false;
|
||||
forceReload = true;
|
||||
poll();
|
||||
}
|
||||
|
||||
function showNotice(text) {
|
||||
const el = document.getElementById('notice');
|
||||
el.textContent = text;
|
||||
el.classList.add('open');
|
||||
clearTimeout(noticeTimer);
|
||||
noticeTimer = setTimeout(() => el.classList.remove('open'), 8000);
|
||||
}
|
||||
|
||||
// source is 'sync' for replies to a server sync request, else 'edit'
|
||||
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit') {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const r = await fetch('/api/state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source })
|
||||
});
|
||||
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
|
||||
// 409: the AI wrote a newer version; load it now
|
||||
else if (r.status === 409) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (d.savedToHistory) showNotice('The AI changed the diagram while you were editing. Your last change was saved in History.');
|
||||
poll();
|
||||
}
|
||||
} catch (e) { console.error('Push failed:', e); }
|
||||
}
|
||||
|
||||
let pendingSyncExport = false;
|
||||
let pendingSyncBase = 0; // version the pending sync export was taken at
|
||||
|
||||
async function poll() {
|
||||
if (!sessionId) return;
|
||||
const knownVersion = currentVersion;
|
||||
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. Reset after a
|
||||
// while in case draw.io never answers, so later syncs still run.
|
||||
if (s.syncRequested && !pendingSyncExport && isReady) {
|
||||
pendingSyncExport = true;
|
||||
pendingSyncBase = currentVersion;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
||||
setTimeout(() => { pendingSyncExport = false; }, 5000);
|
||||
}
|
||||
// The server lost this session (e.g. it expired) and rebuilt it
|
||||
// with a blank diagram: push back what the browser shows.
|
||||
if (s.version < knownVersion && lastXml) {
|
||||
pushState(lastXml);
|
||||
}
|
||||
// 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 ((forceReload || s.version > currentVersion) && s.xml && !projectionExportActive) {
|
||||
forceReload = false;
|
||||
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 seq = ++mcpExportSeq;
|
||||
const extra = s.exportOptions || {};
|
||||
const fireExport = () => {
|
||||
// mcpExport carries this export's number and is echoed
|
||||
// back in msg.message (see the handler). PNG: width
|
||||
// caps the size, pageId picks a page; without one
|
||||
// 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 };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
};
|
||||
if (s.exportXml) {
|
||||
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. Only for
|
||||
// this export: a later one may be running by then.
|
||||
setTimeout(() => {
|
||||
if (pendingMcpExport && seq === mcpExportSeq) {
|
||||
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, 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 }), '*');
|
||||
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 = [], selectedId = 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'); selectedId = 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-id="\${e.id}">
|
||||
<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 id = parseInt(item.dataset.id);
|
||||
if (selectedId === id) { selectedId = null; restoreBtn.disabled = true; }
|
||||
else { selectedId = id; restoreBtn.disabled = false; }
|
||||
historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.id) === selectedId));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
restoreBtn.onclick = async () => {
|
||||
if (selectedId === 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, id: selectedId })
|
||||
});
|
||||
if (r.ok) { cancelBtn.onclick(); await poll(); }
|
||||
else { alert('Restore failed'); }
|
||||
} catch { alert('Restore failed'); }
|
||||
restoreBtn.textContent = 'Restore';
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
function loadPreviewTemplate(): string {
|
||||
if (!previewTemplate) {
|
||||
const read = (file: string) =>
|
||||
readFileSync(join(PREVIEW_DIR, file), "utf-8")
|
||||
previewTemplate = read("index.html")
|
||||
.replace("{{CSS}}", () => read("preview.css"))
|
||||
.replace("{{SCRIPT}}", () => read("preview.js"))
|
||||
}
|
||||
return previewTemplate
|
||||
}
|
||||
|
||||
/** A JSON string literal that is safe inside a <script> element */
|
||||
const scriptJson = (value: string) =>
|
||||
JSON.stringify(value).replace(/</g, "\\u003c")
|
||||
|
||||
function getHtmlPage(sessionId: string): string {
|
||||
return loadPreviewTemplate()
|
||||
.replace("{{SESSION_BADGE}}", () =>
|
||||
sessionId
|
||||
? `<span class="session">${sessionId.slice(-8)}</span>`
|
||||
: "",
|
||||
)
|
||||
.replaceAll("{{DISABLED}}", sessionId ? "" : "disabled")
|
||||
.replace("{{DRAWIO_URL}}", () => normalizeUrl(DRAWIO_BASE_URL))
|
||||
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
|
||||
.replace("{{ORIGIN_JSON}}", () => scriptJson(DRAWIO_ORIGIN))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<!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>
|
||||
{{CSS}}
|
||||
</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>
|
||||
{{SESSION_BADGE}}
|
||||
</div>
|
||||
<div class="right">
|
||||
<button id="history-btn" title="History" {{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" {{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="{{DRAWIO_URL}}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0"></iframe>
|
||||
</div>
|
||||
<div id="notice" role="status"></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 = {{SESSION_JSON}};
|
||||
const DRAWIO_ORIGIN = {{ORIGIN_JSON}};
|
||||
</script>
|
||||
<script>
|
||||
{{SCRIPT}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,167 @@
|
||||
@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; }
|
||||
#notice {
|
||||
display: none; position: fixed; left: 50%; bottom: 24px;
|
||||
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;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
#notice.open { display: block; }
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
const iframe = document.getElementById('drawio');
|
||||
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 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
|
||||
let noticeTimer = null;
|
||||
|
||||
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. Remember the
|
||||
// version this edit is based on, so the server can reject it
|
||||
// if the AI wrote a newer version that is not loaded yet.
|
||||
pendingSvgExport = msg.xml;
|
||||
pendingSvgBase = currentVersion;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||
// Fallback if export doesn't respond
|
||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase); pendingSvgExport = null; } }, 2000);
|
||||
} else if (msg.event === 'export' && msg.format === 'xml') {
|
||||
// Sync export requested by the server (get_diagram).
|
||||
// draw.io returns the XML in msg.xml, with no msg.data.
|
||||
if (pendingSyncExport && msg.xml) {
|
||||
pendingSyncExport = false;
|
||||
// Push with the version the export was taken at: a
|
||||
// newer AI write may have loaded meanwhile, and this
|
||||
// older XML must not overwrite it.
|
||||
pushState(msg.xml, '', pendingSyncBase, 'sync');
|
||||
}
|
||||
} else if (msg.event === 'export' && msg.data) {
|
||||
// Handle MCP server export request (png/svg). fireExport tags
|
||||
// the request with mcpExport and draw.io echoes the request
|
||||
// back in msg.message, which tells it apart from autosave and
|
||||
// preview SVG exports.
|
||||
if (msg.message && msg.message.mcpExport) {
|
||||
// A late reply to an export that already timed out
|
||||
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'));
|
||||
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). Tagged with dlExport like mcpExport,
|
||||
// so an autosave SVG export can never be saved instead.
|
||||
if (msg.message && msg.message.dlExport) {
|
||||
if (!pendingDownload) return;
|
||||
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 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, pendingSvgBase);
|
||||
} 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 by reloading the server state. The server also has any
|
||||
// autosave that was still in flight when the projection started,
|
||||
// which a copy taken at that moment would miss. A flag is used
|
||||
// because a push finishing meanwhile may update currentVersion.
|
||||
function restoreFromProjection() {
|
||||
if (!projectionExportActive) return;
|
||||
projectionExportActive = false;
|
||||
forceReload = true;
|
||||
poll();
|
||||
}
|
||||
|
||||
function showNotice(text) {
|
||||
const el = document.getElementById('notice');
|
||||
el.textContent = text;
|
||||
el.classList.add('open');
|
||||
clearTimeout(noticeTimer);
|
||||
noticeTimer = setTimeout(() => el.classList.remove('open'), 8000);
|
||||
}
|
||||
|
||||
// source is 'sync' for replies to a server sync request, else 'edit'
|
||||
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit') {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const r = await fetch('/api/state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source })
|
||||
});
|
||||
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
|
||||
// 409: the AI wrote a newer version; load it now
|
||||
else if (r.status === 409) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (d.savedToHistory) showNotice('The AI changed the diagram while you were editing. Your last change was saved in History.');
|
||||
poll();
|
||||
}
|
||||
} catch (e) { console.error('Push failed:', e); }
|
||||
}
|
||||
|
||||
let pendingSyncExport = false;
|
||||
let pendingSyncBase = 0; // version the pending sync export was taken at
|
||||
|
||||
async function poll() {
|
||||
if (!sessionId) return;
|
||||
const knownVersion = currentVersion;
|
||||
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. Reset after a
|
||||
// while in case draw.io never answers, so later syncs still run.
|
||||
if (s.syncRequested && !pendingSyncExport && isReady) {
|
||||
pendingSyncExport = true;
|
||||
pendingSyncBase = currentVersion;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
||||
setTimeout(() => { pendingSyncExport = false; }, 5000);
|
||||
}
|
||||
// The server lost this session (e.g. it expired) and rebuilt it
|
||||
// with a blank diagram: push back what the browser shows.
|
||||
if (s.version < knownVersion && lastXml) {
|
||||
pushState(lastXml);
|
||||
}
|
||||
// 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 ((forceReload || s.version > currentVersion) && s.xml && !projectionExportActive) {
|
||||
forceReload = false;
|
||||
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 seq = ++mcpExportSeq;
|
||||
const extra = s.exportOptions || {};
|
||||
const fireExport = () => {
|
||||
// mcpExport carries this export's number and is echoed
|
||||
// back in msg.message (see the handler). PNG: width
|
||||
// caps the size, pageId picks a page; without one
|
||||
// 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 };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
};
|
||||
if (s.exportXml) {
|
||||
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. Only for
|
||||
// this export: a later one may be running by then.
|
||||
setTimeout(() => {
|
||||
if (pendingMcpExport && seq === mcpExportSeq) {
|
||||
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, 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 }), '*');
|
||||
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 = [], selectedId = 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'); selectedId = 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-id="${e.id}">
|
||||
<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 id = parseInt(item.dataset.id);
|
||||
if (selectedId === id) { selectedId = null; restoreBtn.disabled = true; }
|
||||
else { selectedId = id; restoreBtn.disabled = false; }
|
||||
historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.id) === selectedId));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
restoreBtn.onclick = async () => {
|
||||
if (selectedId === 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, id: selectedId })
|
||||
});
|
||||
if (r.ok) { cancelBtn.onclick(); await poll(); }
|
||||
else { alert('Restore failed'); }
|
||||
} catch { alert('Restore failed'); }
|
||||
restoreBtn.textContent = 'Restore';
|
||||
};
|
||||
@@ -254,11 +254,15 @@ describe("export requests", () => {
|
||||
})
|
||||
|
||||
describe("preview page", () => {
|
||||
it("serves a script that parses", async () => {
|
||||
it("serves scripts that parse, with every placeholder filled", async () => {
|
||||
const res = await request("/?mcp=mcp-test-script")
|
||||
const script = res.body.match(/<script>([\s\S]*)<\/script>/)?.[1]
|
||||
expect(script).toBeTruthy()
|
||||
expect(() => new Function(script as string)).not.toThrow()
|
||||
expect(res.body).not.toContain("{{")
|
||||
// Both scripts share one global scope in the page
|
||||
const scripts = [...res.body.matchAll(/<script>([\s\S]*?)<\/script>/g)]
|
||||
.map((m) => m[1])
|
||||
.join("\n")
|
||||
expect(scripts).toContain('const sessionId = "mcp-test-script";')
|
||||
expect(() => new Function(scripts)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user