2026-10-04 06:46:09 +09:00
|
|
|
const iframe = document.getElementById('drawio');
|
|
|
|
|
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
2026-10-05 17:33:36 +09:00
|
|
|
// The server state this tab is in step with (see stateId in http-server.ts);
|
|
|
|
|
// null until the first poll
|
|
|
|
|
let stateId = null;
|
|
|
|
|
// The newest diagram on the canvas, saved to the server or not: lastXml is
|
|
|
|
|
// the last one the server has
|
|
|
|
|
let latestXml = null;
|
|
|
|
|
let pushFailing = false; // the last push could not reach the server
|
|
|
|
|
let pollSeq = 0, lastHandledPoll = 0; // polls overlap; older answers are dropped
|
2026-10-04 06:46:09 +09:00
|
|
|
let pendingSvgExport = null;
|
|
|
|
|
let pendingSvgBase = 0; // version the pending autosave was based on
|
2026-10-05 17:33:36 +09:00
|
|
|
let pendingSvgStateId = null; // and the state it belonged to
|
2026-10-04 06:46:09 +09:00
|
|
|
let pendingAiSvg = false;
|
2026-10-04 07:10:01 +09:00
|
|
|
let pendingMcpExport = null; // 'png', 'svg' or 'xmlsvg' when MCP requested export
|
2026-10-04 06:46:09 +09:00
|
|
|
let mcpExportSeq = 0; // number of the latest MCP export
|
2026-10-04 22:52:41 +09:00
|
|
|
let mcpExportId = null; // the server's id for it, sent back with the result
|
2026-10-04 06:46:09 +09:00
|
|
|
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; }
|
2026-10-05 17:33:36 +09:00
|
|
|
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml) {
|
2026-10-04 06:46:09 +09:00
|
|
|
// 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;
|
2026-10-05 17:33:36 +09:00
|
|
|
// Also an edit undone back to what the server has
|
|
|
|
|
latestXml = msg.xml;
|
|
|
|
|
if (msg.xml === lastXml) return;
|
2026-10-04 06:46:09 +09:00
|
|
|
// Request SVG export, then push state with SVG. Remember the
|
2026-10-05 17:33:36 +09:00
|
|
|
// version and state this edit is based on, so the server can
|
|
|
|
|
// reject it if the AI wrote a newer version that is not loaded
|
|
|
|
|
// yet, or if it lost that state.
|
2026-10-04 06:46:09 +09:00
|
|
|
pendingSvgExport = msg.xml;
|
|
|
|
|
pendingSvgBase = currentVersion;
|
2026-10-05 17:33:36 +09:00
|
|
|
pendingSvgStateId = stateId;
|
2026-10-04 06:46:09 +09:00
|
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
|
|
|
|
// Fallback if export doesn't respond
|
2026-10-05 17:33:36 +09:00
|
|
|
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase, 'edit', pendingSvgStateId); pendingSvgExport = null; } }, 2000);
|
2026-10-04 06:46:09 +09:00
|
|
|
} else if (msg.event === 'export' && msg.format === 'xml') {
|
|
|
|
|
// Sync export requested by the server (get_diagram).
|
2026-10-05 10:52:20 +09:00
|
|
|
// draw.io returns the XML in msg.xml, with no msg.data. A late
|
|
|
|
|
// reply to an earlier request was taken at another version.
|
|
|
|
|
if (pendingSyncExport && msg.xml && msg.message?.syncExport === syncExportSeq) {
|
2026-10-04 06:46:09 +09:00
|
|
|
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.
|
2026-10-05 17:33:36 +09:00
|
|
|
pushState(msg.xml, '', pendingSyncBase, 'sync', pendingSyncStateId);
|
2026-10-04 06:46:09 +09:00
|
|
|
}
|
|
|
|
|
} 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');
|
2026-10-04 07:10:01 +09:00
|
|
|
const isSvg = (pendingMcpExport === 'svg' || pendingMcpExport === 'xmlsvg') && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
|
2026-10-04 06:46:09 +09:00
|
|
|
if (isPng || isSvg) {
|
2026-10-04 07:38:17 +09:00
|
|
|
// Keep pendingMcpExport set until the server has the
|
|
|
|
|
// result: a poll answered before that still sees the
|
|
|
|
|
// request and would start the same export again.
|
|
|
|
|
const seq = msg.message.mcpExport;
|
2026-10-04 06:46:09 +09:00
|
|
|
fetch('/api/state', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2026-10-04 22:52:41 +09:00
|
|
|
body: JSON.stringify({ sessionId, exportData: d, exportId: mcpExportId })
|
2026-10-04 07:38:17 +09:00
|
|
|
}).catch(() => {}).finally(() => {
|
|
|
|
|
// The timeout already ended this export
|
|
|
|
|
if (seq !== mcpExportSeq) return;
|
|
|
|
|
pendingMcpExport = null;
|
|
|
|
|
// Page-targeted export: restore the user's real
|
|
|
|
|
// multi-page document now that we have the image.
|
|
|
|
|
restoreFromProjection();
|
|
|
|
|
});
|
2026-10-04 06:46:09 +09:00
|
|
|
}
|
|
|
|
|
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;
|
2026-10-05 17:33:36 +09:00
|
|
|
pushState(xml, svg, pendingSvgBase, 'edit', pendingSvgStateId);
|
2026-10-04 06:46:09 +09:00
|
|
|
} 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;
|
2026-10-05 17:33:36 +09:00
|
|
|
latestXml = xml;
|
2026-10-04 06:46:09 +09:00
|
|
|
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.
|
2026-10-05 10:52:20 +09:00
|
|
|
// projectionExportActive stays set until the poll loads the document:
|
|
|
|
|
// an edit on the projection before that must not be pushed.
|
2026-10-04 06:46:09 +09:00
|
|
|
function restoreFromProjection() {
|
|
|
|
|
if (!projectionExportActive) return;
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 13:04:32 +09:00
|
|
|
// source is 'sync' for replies to a server sync request, 'recover' for the
|
2026-10-05 17:33:36 +09:00
|
|
|
// tab's copy after the server recovered the session, else 'edit'. sid is the
|
|
|
|
|
// server state the push is based on.
|
|
|
|
|
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit', sid = stateId) {
|
2026-10-04 06:46:09 +09:00
|
|
|
if (!sessionId) return;
|
|
|
|
|
try {
|
|
|
|
|
const r = await fetch('/api/state', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2026-10-05 17:33:36 +09:00
|
|
|
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source, stateId: sid })
|
2026-10-04 06:46:09 +09:00
|
|
|
});
|
2026-10-05 17:33:36 +09:00
|
|
|
pushFailing = false;
|
|
|
|
|
if (r.ok) {
|
|
|
|
|
const d = await r.json();
|
|
|
|
|
// An answer about a state this tab has left since
|
|
|
|
|
if (sid !== stateId) return;
|
|
|
|
|
currentVersion = d.version;
|
|
|
|
|
lastXml = xml;
|
|
|
|
|
}
|
|
|
|
|
// 409: the AI wrote a newer version, or the server lost the state
|
|
|
|
|
// this push was based on; the next poll sorts it out
|
2026-10-04 06:46:09 +09:00
|
|
|
else if (r.status === 409) {
|
|
|
|
|
const d = await r.json().catch(() => ({}));
|
2026-10-05 13:04:32 +09:00
|
|
|
if (d.savedToHistory) {
|
|
|
|
|
showNotice(source === 'recover'
|
|
|
|
|
? 'The diagram was restored from its saved file. What this tab showed before is in History.'
|
|
|
|
|
: 'The AI changed the diagram while you were editing. Your last change was saved in History.');
|
|
|
|
|
}
|
2026-10-04 06:46:09 +09:00
|
|
|
poll();
|
|
|
|
|
}
|
2026-10-05 17:33:36 +09:00
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Push failed:', e);
|
|
|
|
|
if (!pushFailing) {
|
|
|
|
|
pushFailing = true;
|
|
|
|
|
showNotice("Can't reach the MCP server. Your changes are only in this tab for now; use Download to keep a copy.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The server made a new state for this session: it expired, or the MCP
|
|
|
|
|
// process restarted. Decide whose diagram wins.
|
|
|
|
|
function recoverState(s) {
|
|
|
|
|
stateId = s.stateId;
|
|
|
|
|
// The old state's pending work is gone with it
|
|
|
|
|
const projectionShown = projectionExportActive;
|
|
|
|
|
projectionExportActive = false;
|
|
|
|
|
forceReload = false;
|
|
|
|
|
pendingMcpExport = null;
|
|
|
|
|
pendingSyncExport = false;
|
|
|
|
|
const mine = latestXml;
|
|
|
|
|
currentVersion = s.version;
|
|
|
|
|
if (s.blank || s.xml === lastXml) {
|
|
|
|
|
// The server knows nothing, or exactly what this tab last saved:
|
|
|
|
|
// the canvas can only be newer, so it wins (edits made while the
|
|
|
|
|
// server was down are saved now)
|
|
|
|
|
if (projectionShown && mine) {
|
|
|
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: mine, autosave: 1 }), '*');
|
|
|
|
|
}
|
|
|
|
|
if (mine && mine !== s.xml) pushState(mine, '', s.version);
|
|
|
|
|
} else {
|
|
|
|
|
// The server has a diagram this tab never showed (an AI write it
|
|
|
|
|
// missed, a saved file): show that, and keep this tab's copy in
|
|
|
|
|
// History unless it is the same
|
|
|
|
|
loadDiagram(s.xml, true);
|
|
|
|
|
if (mine && mine !== s.xml) pushState(mine, '', s.version, 'recover');
|
|
|
|
|
}
|
2026-10-04 06:46:09 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let pendingSyncExport = false;
|
|
|
|
|
let pendingSyncBase = 0; // version the pending sync export was taken at
|
2026-10-05 17:33:36 +09:00
|
|
|
let pendingSyncStateId = null; // and the state it belonged to
|
2026-10-05 10:52:20 +09:00
|
|
|
let syncExportSeq = 0; // number of the latest sync export
|
2026-10-04 06:46:09 +09:00
|
|
|
|
|
|
|
|
async function poll() {
|
|
|
|
|
if (!sessionId) return;
|
2026-10-05 17:33:36 +09:00
|
|
|
const seq = ++pollSeq;
|
2026-10-04 06:46:09 +09:00
|
|
|
try {
|
|
|
|
|
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
|
|
|
|
if (!r.ok) return;
|
|
|
|
|
const s = await r.json();
|
2026-10-05 17:33:36 +09:00
|
|
|
// An older answer than one already handled (the interval, the 409
|
|
|
|
|
// handler and the projection restore each poll): it could name a
|
|
|
|
|
// state that is gone
|
|
|
|
|
if (seq < lastHandledPoll) return;
|
|
|
|
|
lastHandledPoll = seq;
|
|
|
|
|
if (stateId === null) stateId = s.stateId;
|
|
|
|
|
else if (s.stateId && s.stateId !== stateId) recoverState(s);
|
2026-10-04 06:46:09 +09:00
|
|
|
// Load new diagram from server (before export, so we export latest).
|
2026-10-05 10:52:20 +09:00
|
|
|
// While a page-targeted projection is on screen, only the restore
|
|
|
|
|
// (forceReload) replaces it, so a new version doesn't fight the
|
|
|
|
|
// projection; currentVersion stays unadvanced until then, so the
|
|
|
|
|
// bump is applied with the real document.
|
|
|
|
|
if ((forceReload || (s.version > currentVersion && !projectionExportActive)) && s.xml) {
|
2026-10-04 06:46:09 +09:00
|
|
|
forceReload = false;
|
2026-10-05 10:52:20 +09:00
|
|
|
projectionExportActive = false;
|
2026-10-04 06:46:09 +09:00
|
|
|
currentVersion = s.version;
|
|
|
|
|
loadDiagram(s.xml, true);
|
|
|
|
|
}
|
2026-10-04 22:52:41 +09:00
|
|
|
// Handle sync request - server needs fresh state. After the load
|
|
|
|
|
// above, so draw.io exports what it just loaded; never while a
|
|
|
|
|
// one-page projection is on screen, which would be sent as the
|
|
|
|
|
// whole document. Reset after a while in case draw.io never
|
|
|
|
|
// answers, so later syncs still run.
|
|
|
|
|
if (s.syncRequested && !pendingSyncExport && isReady && !projectionExportActive) {
|
|
|
|
|
pendingSyncExport = true;
|
|
|
|
|
pendingSyncBase = currentVersion;
|
2026-10-05 17:33:36 +09:00
|
|
|
pendingSyncStateId = stateId;
|
2026-10-05 10:52:20 +09:00
|
|
|
// draw.io echoes the request in msg.message, so the reply can
|
|
|
|
|
// be matched to this request
|
|
|
|
|
const seq = ++syncExportSeq;
|
|
|
|
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml', syncExport: seq }), '*');
|
|
|
|
|
setTimeout(() => { if (seq === syncExportSeq) pendingSyncExport = false; }, 5000);
|
2026-10-04 22:52:41 +09:00
|
|
|
}
|
2026-10-04 06:46:09 +09:00
|
|
|
// 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;
|
2026-10-04 22:52:41 +09:00
|
|
|
mcpExportId = s.exportId;
|
2026-10-04 06:46:09 +09:00
|
|
|
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 }
|
2026-10-04 07:10:01 +09:00
|
|
|
: { action: 'export', format: pendingMcpExport, mcpExport: seq };
|
2026-10-04 06:46:09 +09:00
|
|
|
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;
|
|
|
|
|
|
2026-10-04 07:10:01 +09:00
|
|
|
const extMap = { drawio: '.drawio', png: '.png', svg: '.svg', xmlsvg: '.drawio.svg' };
|
2026-10-04 06:46:09 +09:00
|
|
|
|
|
|
|
|
saveBtn.onclick = () => {
|
|
|
|
|
if (!sessionId || !isReady) return;
|
2026-10-04 07:10:01 +09:00
|
|
|
// Local date as YYYY-MM-DD, like the web app's default name
|
|
|
|
|
saveFilename.value = 'diagram-' + new Date().toLocaleDateString('sv-SE');
|
2026-10-04 06:46:09 +09:00
|
|
|
saveModal.classList.add('open');
|
|
|
|
|
saveFilename.focus();
|
|
|
|
|
saveFilename.select();
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-04 07:10:01 +09:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-04 06:46:09 +09:00
|
|
|
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).
|
2026-10-05 17:33:36 +09:00
|
|
|
// The canvas as it is, also edits not saved to the server yet
|
|
|
|
|
let xmlData = latestXml || lastXml || '';
|
2026-10-04 06:46:09 +09:00
|
|
|
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);
|
2026-10-04 07:10:01 +09:00
|
|
|
} 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 }), '*');
|
2026-10-04 06:46:09 +09:00
|
|
|
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';
|
2026-10-05 17:02:06 +09:00
|
|
|
// Built element by element: a stored image is never read as HTML, and
|
|
|
|
|
// only an SVG data URL is shown as one
|
|
|
|
|
historyGrid.replaceChildren(...historyData.map((e) => {
|
|
|
|
|
const item = document.createElement('div');
|
|
|
|
|
item.className = 'history-item';
|
|
|
|
|
item.dataset.id = String(e.id);
|
|
|
|
|
const thumb = document.createElement('div');
|
|
|
|
|
thumb.className = 'thumb';
|
|
|
|
|
if (typeof e.svg === 'string' && e.svg.startsWith('data:image/svg+xml;base64,')) {
|
|
|
|
|
const img = document.createElement('img');
|
|
|
|
|
img.src = e.svg;
|
|
|
|
|
thumb.appendChild(img);
|
|
|
|
|
} else {
|
|
|
|
|
thumb.textContent = '#' + e.index;
|
|
|
|
|
}
|
|
|
|
|
const label = document.createElement('div');
|
|
|
|
|
label.className = 'label';
|
|
|
|
|
label.textContent = '#' + e.index;
|
|
|
|
|
item.append(thumb, label);
|
|
|
|
|
return item;
|
|
|
|
|
}));
|
2026-10-04 06:46:09 +09:00
|
|
|
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(); }
|
2026-10-04 07:10:01 +09:00
|
|
|
else { showNotice('Restore failed. Please try again.'); }
|
|
|
|
|
} catch { showNotice('Restore failed. Please try again.'); }
|
2026-10-04 06:46:09 +09:00
|
|
|
restoreBtn.textContent = 'Restore';
|
|
|
|
|
};
|