@next-ai-drawio/mcp-server 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/history.d.ts +16 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +48 -0
- package/dist/history.js.map +1 -0
- package/dist/http-server.d.ts +6 -19
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +329 -148
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +53 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/api-client.d.ts +0 -30
- package/dist/api-client.d.ts.map +0 -1
- package/dist/api-client.js +0 -87
- package/dist/api-client.js.map +0 -1
- package/dist/session.d.ts +0 -59
- package/dist/session.d.ts.map +0 -1
- package/dist/session.js +0 -120
- package/dist/session.js.map +0 -1
package/dist/http-server.js
CHANGED
|
@@ -1,40 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Embedded HTTP Server for MCP
|
|
3
|
-
*
|
|
4
|
-
* Serves a static HTML page with draw.io embed and handles state sync.
|
|
5
|
-
* This eliminates the need for an external Next.js app.
|
|
3
|
+
* Serves draw.io embed with state sync and history UI
|
|
6
4
|
*/
|
|
7
5
|
import http from "node:http";
|
|
6
|
+
import { addHistory, clearHistory, getHistory, getHistoryEntry, updateLastHistorySvg, } from "./history.js";
|
|
8
7
|
import { log } from "./logger.js";
|
|
9
|
-
// In-memory state store (shared with MCP server in same process)
|
|
10
8
|
export const stateStore = new Map();
|
|
11
9
|
let server = null;
|
|
12
10
|
let serverPort = 6002;
|
|
13
|
-
const MAX_PORT = 6020;
|
|
14
|
-
const SESSION_TTL = 60 * 60 * 1000;
|
|
15
|
-
/**
|
|
16
|
-
* Get state for a session
|
|
17
|
-
*/
|
|
11
|
+
const MAX_PORT = 6020;
|
|
12
|
+
const SESSION_TTL = 60 * 60 * 1000;
|
|
18
13
|
export function getState(sessionId) {
|
|
19
14
|
return stateStore.get(sessionId);
|
|
20
15
|
}
|
|
21
|
-
|
|
22
|
-
* Set state for a session
|
|
23
|
-
*/
|
|
24
|
-
export function setState(sessionId, xml) {
|
|
16
|
+
export function setState(sessionId, xml, svg) {
|
|
25
17
|
const existing = stateStore.get(sessionId);
|
|
26
18
|
const newVersion = (existing?.version || 0) + 1;
|
|
27
19
|
stateStore.set(sessionId, {
|
|
28
20
|
xml,
|
|
29
21
|
version: newVersion,
|
|
30
22
|
lastUpdated: new Date(),
|
|
23
|
+
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
|
24
|
+
syncRequested: undefined, // Clear sync request when browser pushes state
|
|
31
25
|
});
|
|
32
26
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`);
|
|
33
27
|
return newVersion;
|
|
34
28
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
export function requestSync(sessionId) {
|
|
30
|
+
const state = stateStore.get(sessionId);
|
|
31
|
+
if (state) {
|
|
32
|
+
state.syncRequested = Date.now();
|
|
33
|
+
log.debug(`Sync requested for session=${sessionId}`);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
log.debug(`Sync requested for non-existent session=${sessionId}`);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
export async function waitForSync(sessionId, timeoutMs = 3000) {
|
|
40
|
+
const start = Date.now();
|
|
41
|
+
while (Date.now() - start < timeoutMs) {
|
|
42
|
+
const state = stateStore.get(sessionId);
|
|
43
|
+
if (!state?.syncRequested)
|
|
44
|
+
return true; // Sync completed
|
|
45
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
46
|
+
}
|
|
47
|
+
log.warn(`Sync timeout for session=${sessionId}`);
|
|
48
|
+
return false; // Timeout
|
|
49
|
+
}
|
|
38
50
|
export function startHttpServer(port = 6002) {
|
|
39
51
|
return new Promise((resolve, reject) => {
|
|
40
52
|
if (server) {
|
|
@@ -61,46 +73,33 @@ export function startHttpServer(port = 6002) {
|
|
|
61
73
|
});
|
|
62
74
|
server.listen(port, () => {
|
|
63
75
|
serverPort = port;
|
|
64
|
-
log.info(`
|
|
76
|
+
log.info(`HTTP server running on http://localhost:${port}`);
|
|
65
77
|
resolve(port);
|
|
66
78
|
});
|
|
67
79
|
});
|
|
68
80
|
}
|
|
69
|
-
/**
|
|
70
|
-
* Stop the HTTP server
|
|
71
|
-
*/
|
|
72
81
|
export function stopHttpServer() {
|
|
73
82
|
if (server) {
|
|
74
83
|
server.close();
|
|
75
84
|
server = null;
|
|
76
85
|
}
|
|
77
86
|
}
|
|
78
|
-
/**
|
|
79
|
-
* Clean up expired sessions
|
|
80
|
-
*/
|
|
81
87
|
function cleanupExpiredSessions() {
|
|
82
88
|
const now = Date.now();
|
|
83
89
|
for (const [sessionId, state] of stateStore) {
|
|
84
90
|
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
|
|
85
91
|
stateStore.delete(sessionId);
|
|
92
|
+
clearHistory(sessionId);
|
|
86
93
|
log.info(`Cleaned up expired session: ${sessionId}`);
|
|
87
94
|
}
|
|
88
95
|
}
|
|
89
96
|
}
|
|
90
|
-
// Run cleanup every 5 minutes
|
|
91
97
|
setInterval(cleanupExpiredSessions, 5 * 60 * 1000);
|
|
92
|
-
/**
|
|
93
|
-
* Get the current server port
|
|
94
|
-
*/
|
|
95
98
|
export function getServerPort() {
|
|
96
99
|
return serverPort;
|
|
97
100
|
}
|
|
98
|
-
/**
|
|
99
|
-
* Handle HTTP requests
|
|
100
|
-
*/
|
|
101
101
|
function handleRequest(req, res) {
|
|
102
102
|
const url = new URL(req.url || "/", `http://localhost:${serverPort}`);
|
|
103
|
-
// CORS headers for local development
|
|
104
103
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
105
104
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
106
105
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
@@ -109,35 +108,27 @@ function handleRequest(req, res) {
|
|
|
109
108
|
res.end();
|
|
110
109
|
return;
|
|
111
110
|
}
|
|
112
|
-
// Route handling
|
|
113
111
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
114
|
-
|
|
112
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
113
|
+
res.end(getHtmlPage(url.searchParams.get("mcp") || ""));
|
|
115
114
|
}
|
|
116
|
-
else if (url.pathname === "/api/state"
|
|
117
|
-
url.pathname === "/api/mcp/state") {
|
|
115
|
+
else if (url.pathname === "/api/state") {
|
|
118
116
|
handleStateApi(req, res, url);
|
|
119
117
|
}
|
|
120
|
-
else if (url.pathname === "/api/
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
else if (url.pathname === "/api/history") {
|
|
119
|
+
handleHistoryApi(req, res, url);
|
|
120
|
+
}
|
|
121
|
+
else if (url.pathname === "/api/restore") {
|
|
122
|
+
handleRestoreApi(req, res);
|
|
123
|
+
}
|
|
124
|
+
else if (url.pathname === "/api/history-svg") {
|
|
125
|
+
handleHistorySvgApi(req, res);
|
|
124
126
|
}
|
|
125
127
|
else {
|
|
126
128
|
res.writeHead(404);
|
|
127
129
|
res.end("Not Found");
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
|
-
/**
|
|
131
|
-
* Serve the HTML page with draw.io embed
|
|
132
|
-
*/
|
|
133
|
-
function serveHtml(req, res, url) {
|
|
134
|
-
const sessionId = url.searchParams.get("mcp") || "";
|
|
135
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
136
|
-
res.end(getHtmlPage(sessionId));
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* Handle state API requests
|
|
140
|
-
*/
|
|
141
132
|
function handleStateApi(req, res, url) {
|
|
142
133
|
if (req.method === "GET") {
|
|
143
134
|
const sessionId = url.searchParams.get("sessionId");
|
|
@@ -151,7 +142,7 @@ function handleStateApi(req, res, url) {
|
|
|
151
142
|
res.end(JSON.stringify({
|
|
152
143
|
xml: state?.xml || null,
|
|
153
144
|
version: state?.version || 0,
|
|
154
|
-
|
|
145
|
+
syncRequested: !!state?.syncRequested,
|
|
155
146
|
}));
|
|
156
147
|
}
|
|
157
148
|
else if (req.method === "POST") {
|
|
@@ -161,13 +152,13 @@ function handleStateApi(req, res, url) {
|
|
|
161
152
|
});
|
|
162
153
|
req.on("end", () => {
|
|
163
154
|
try {
|
|
164
|
-
const { sessionId, xml } = JSON.parse(body);
|
|
155
|
+
const { sessionId, xml, svg } = JSON.parse(body);
|
|
165
156
|
if (!sessionId) {
|
|
166
157
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
167
158
|
res.end(JSON.stringify({ error: "sessionId required" }));
|
|
168
159
|
return;
|
|
169
160
|
}
|
|
170
|
-
const version = setState(sessionId, xml);
|
|
161
|
+
const version = setState(sessionId, xml, svg);
|
|
171
162
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
172
163
|
res.end(JSON.stringify({ success: true, version }));
|
|
173
164
|
}
|
|
@@ -182,35 +173,155 @@ function handleStateApi(req, res, url) {
|
|
|
182
173
|
res.end("Method Not Allowed");
|
|
183
174
|
}
|
|
184
175
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
176
|
+
function handleHistoryApi(req, res, url) {
|
|
177
|
+
if (req.method !== "GET") {
|
|
178
|
+
res.writeHead(405);
|
|
179
|
+
res.end("Method Not Allowed");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
183
|
+
if (!sessionId) {
|
|
184
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
185
|
+
res.end(JSON.stringify({ error: "sessionId required" }));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const history = getHistory(sessionId);
|
|
189
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
190
|
+
res.end(JSON.stringify({
|
|
191
|
+
entries: history.map((entry, i) => ({ index: i, svg: entry.svg })),
|
|
192
|
+
count: history.length,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
function handleRestoreApi(req, res) {
|
|
196
|
+
if (req.method !== "POST") {
|
|
197
|
+
res.writeHead(405);
|
|
198
|
+
res.end("Method Not Allowed");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
let body = "";
|
|
202
|
+
req.on("data", (chunk) => {
|
|
203
|
+
body += chunk;
|
|
204
|
+
});
|
|
205
|
+
req.on("end", () => {
|
|
206
|
+
try {
|
|
207
|
+
const { sessionId, index } = JSON.parse(body);
|
|
208
|
+
if (!sessionId || index === undefined) {
|
|
209
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
210
|
+
res.end(JSON.stringify({ error: "sessionId and index required" }));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const entry = getHistoryEntry(sessionId, index);
|
|
214
|
+
if (!entry) {
|
|
215
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
216
|
+
res.end(JSON.stringify({ error: "Entry not found" }));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const newVersion = setState(sessionId, entry.xml);
|
|
220
|
+
addHistory(sessionId, entry.xml, entry.svg);
|
|
221
|
+
log.info(`Restored session ${sessionId} to index ${index}`);
|
|
222
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
223
|
+
res.end(JSON.stringify({ success: true, newVersion }));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
227
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function handleHistorySvgApi(req, res) {
|
|
232
|
+
if (req.method !== "POST") {
|
|
233
|
+
res.writeHead(405);
|
|
234
|
+
res.end("Method Not Allowed");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
let body = "";
|
|
238
|
+
req.on("data", (chunk) => {
|
|
239
|
+
body += chunk;
|
|
240
|
+
});
|
|
241
|
+
req.on("end", () => {
|
|
242
|
+
try {
|
|
243
|
+
const { sessionId, svg } = JSON.parse(body);
|
|
244
|
+
if (!sessionId || !svg) {
|
|
245
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
246
|
+
res.end(JSON.stringify({ error: "sessionId and svg required" }));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
updateLastHistorySvg(sessionId, svg);
|
|
250
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
251
|
+
res.end(JSON.stringify({ success: true }));
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
255
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
188
259
|
function getHtmlPage(sessionId) {
|
|
189
260
|
return `<!DOCTYPE html>
|
|
190
261
|
<html lang="en">
|
|
191
262
|
<head>
|
|
192
263
|
<meta charset="UTF-8">
|
|
193
264
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
194
|
-
<title>Draw.io MCP
|
|
265
|
+
<title>Draw.io MCP</title>
|
|
195
266
|
<style>
|
|
196
267
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
197
268
|
html, body { width: 100%; height: 100%; overflow: hidden; }
|
|
198
269
|
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
|
199
270
|
#header {
|
|
200
|
-
padding: 8px 16px;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
font-family: system-ui, sans-serif;
|
|
204
|
-
font-size: 14px;
|
|
205
|
-
display: flex;
|
|
206
|
-
justify-content: space-between;
|
|
207
|
-
align-items: center;
|
|
271
|
+
padding: 8px 16px; background: #1a1a2e; color: #eee;
|
|
272
|
+
font-family: system-ui, sans-serif; font-size: 14px;
|
|
273
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
208
274
|
}
|
|
209
275
|
#header .session { color: #888; font-size: 12px; }
|
|
210
276
|
#header .status { font-size: 12px; }
|
|
211
277
|
#header .status.connected { color: #4ade80; }
|
|
212
278
|
#header .status.disconnected { color: #f87171; }
|
|
213
279
|
#drawio { flex: 1; border: none; }
|
|
280
|
+
#history-btn {
|
|
281
|
+
position: fixed; bottom: 24px; right: 24px;
|
|
282
|
+
width: 48px; height: 48px; border-radius: 50%;
|
|
283
|
+
background: #3b82f6; color: white; border: none; cursor: pointer;
|
|
284
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
285
|
+
display: flex; align-items: center; justify-content: center;
|
|
286
|
+
z-index: 1000;
|
|
287
|
+
}
|
|
288
|
+
#history-btn:hover { background: #2563eb; }
|
|
289
|
+
#history-btn:disabled { background: #6b7280; cursor: not-allowed; }
|
|
290
|
+
#history-btn svg { width: 24px; height: 24px; }
|
|
291
|
+
#history-modal {
|
|
292
|
+
display: none; position: fixed; inset: 0;
|
|
293
|
+
background: rgba(0,0,0,0.5); z-index: 2000;
|
|
294
|
+
align-items: center; justify-content: center;
|
|
295
|
+
}
|
|
296
|
+
#history-modal.open { display: flex; }
|
|
297
|
+
.modal-content {
|
|
298
|
+
background: white; border-radius: 12px;
|
|
299
|
+
width: 90%; max-width: 500px; max-height: 70vh;
|
|
300
|
+
display: flex; flex-direction: column;
|
|
301
|
+
}
|
|
302
|
+
.modal-header { padding: 16px; border-bottom: 1px solid #e5e7eb; }
|
|
303
|
+
.modal-header h2 { font-size: 18px; margin: 0; }
|
|
304
|
+
.modal-body { flex: 1; overflow-y: auto; padding: 16px; }
|
|
305
|
+
.modal-footer { padding: 12px 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; }
|
|
306
|
+
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
|
307
|
+
.history-item {
|
|
308
|
+
border: 2px solid #e5e7eb; border-radius: 8px; padding: 8px;
|
|
309
|
+
cursor: pointer; text-align: center;
|
|
310
|
+
}
|
|
311
|
+
.history-item:hover { border-color: #3b82f6; }
|
|
312
|
+
.history-item.selected { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.3); }
|
|
313
|
+
.history-item .thumb {
|
|
314
|
+
aspect-ratio: 4/3; background: #f3f4f6; border-radius: 4px;
|
|
315
|
+
display: flex; align-items: center; justify-content: center;
|
|
316
|
+
margin-bottom: 4px; overflow: hidden;
|
|
317
|
+
}
|
|
318
|
+
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
|
319
|
+
.history-item .label { font-size: 12px; color: #666; }
|
|
320
|
+
.btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; }
|
|
321
|
+
.btn-primary { background: #3b82f6; color: white; }
|
|
322
|
+
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
|
323
|
+
.btn-secondary { background: #f3f4f6; color: #374151; }
|
|
324
|
+
.empty { text-align: center; padding: 40px; color: #666; }
|
|
214
325
|
</style>
|
|
215
326
|
</head>
|
|
216
327
|
<body>
|
|
@@ -218,121 +329,191 @@ function getHtmlPage(sessionId) {
|
|
|
218
329
|
<div id="header">
|
|
219
330
|
<div>
|
|
220
331
|
<strong>Draw.io MCP</strong>
|
|
221
|
-
<span class="session">${sessionId ? `Session: ${sessionId}` : "No
|
|
332
|
+
<span class="session">${sessionId ? `Session: ${sessionId}` : "No session"}</span>
|
|
222
333
|
</div>
|
|
223
334
|
<div id="status" class="status disconnected">Connecting...</div>
|
|
224
335
|
</div>
|
|
225
336
|
<iframe id="drawio" src="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
|
|
226
337
|
</div>
|
|
227
|
-
|
|
338
|
+
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
|
|
339
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
340
|
+
<circle cx="12" cy="12" r="10"></circle>
|
|
341
|
+
<polyline points="12 6 12 12 16 14"></polyline>
|
|
342
|
+
</svg>
|
|
343
|
+
</button>
|
|
344
|
+
<div id="history-modal">
|
|
345
|
+
<div class="modal-content">
|
|
346
|
+
<div class="modal-header"><h2>History</h2></div>
|
|
347
|
+
<div class="modal-body">
|
|
348
|
+
<div id="history-grid" class="history-grid"></div>
|
|
349
|
+
<div id="history-empty" class="empty" style="display:none;">No history yet</div>
|
|
350
|
+
</div>
|
|
351
|
+
<div class="modal-footer">
|
|
352
|
+
<button class="btn btn-secondary" id="cancel-btn">Cancel</button>
|
|
353
|
+
<button class="btn btn-primary" id="restore-btn" disabled>Restore</button>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
228
357
|
<script>
|
|
229
358
|
const sessionId = "${sessionId}";
|
|
230
359
|
const iframe = document.getElementById('drawio');
|
|
231
360
|
const statusEl = document.getElementById('status');
|
|
361
|
+
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
|
362
|
+
let pendingSvgExport = null;
|
|
363
|
+
let pendingAiSvg = false;
|
|
232
364
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
let pendingXml = null;
|
|
236
|
-
let lastLoadedXml = null;
|
|
237
|
-
|
|
238
|
-
// Listen for messages from draw.io
|
|
239
|
-
window.addEventListener('message', (event) => {
|
|
240
|
-
if (event.origin !== 'https://embed.diagrams.net') return;
|
|
241
|
-
|
|
365
|
+
window.addEventListener('message', (e) => {
|
|
366
|
+
if (e.origin !== 'https://embed.diagrams.net') return;
|
|
242
367
|
try {
|
|
243
|
-
const msg = JSON.parse(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
368
|
+
const msg = JSON.parse(e.data);
|
|
369
|
+
if (msg.event === 'init') {
|
|
370
|
+
isReady = true;
|
|
371
|
+
statusEl.textContent = 'Ready';
|
|
372
|
+
statusEl.className = 'status connected';
|
|
373
|
+
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
|
374
|
+
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
|
375
|
+
// Request SVG export, then push state with SVG
|
|
376
|
+
pendingSvgExport = msg.xml;
|
|
377
|
+
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
|
378
|
+
// Fallback if export doesn't respond
|
|
379
|
+
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
|
380
|
+
} else if (msg.event === 'export' && msg.data) {
|
|
381
|
+
// Handle sync export (XML format) - server requested fresh state
|
|
382
|
+
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
|
|
383
|
+
pendingSyncExport = false;
|
|
384
|
+
pushState(msg.data, '');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// Handle SVG export
|
|
388
|
+
let svg = msg.data;
|
|
389
|
+
if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
|
|
390
|
+
if (pendingSvgExport) {
|
|
391
|
+
const xml = pendingSvgExport;
|
|
392
|
+
pendingSvgExport = null;
|
|
393
|
+
pushState(xml, svg);
|
|
394
|
+
} else if (pendingAiSvg) {
|
|
395
|
+
pendingAiSvg = false;
|
|
396
|
+
fetch('/api/history-svg', {
|
|
397
|
+
method: 'POST',
|
|
398
|
+
headers: { 'Content-Type': 'application/json' },
|
|
399
|
+
body: JSON.stringify({ sessionId, svg })
|
|
400
|
+
}).catch(() => {});
|
|
401
|
+
}
|
|
260
402
|
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (msg.xml && msg.xml !== lastLoadedXml) {
|
|
264
|
-
pushState(msg.xml);
|
|
265
|
-
}
|
|
266
|
-
} else if (msg.event === 'export') {
|
|
267
|
-
// Export completed
|
|
268
|
-
if (msg.data) {
|
|
269
|
-
pushState(msg.data);
|
|
270
|
-
}
|
|
271
|
-
} else if (msg.event === 'autosave') {
|
|
272
|
-
// Autosave - push to state
|
|
273
|
-
if (msg.xml && msg.xml !== lastLoadedXml) {
|
|
274
|
-
pushState(msg.xml);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
403
|
+
} catch {}
|
|
404
|
+
});
|
|
278
405
|
|
|
279
|
-
function loadDiagram(xml) {
|
|
280
|
-
if (!
|
|
281
|
-
|
|
282
|
-
|
|
406
|
+
function loadDiagram(xml, capturePreview = false) {
|
|
407
|
+
if (!isReady) { pendingXml = xml; return; }
|
|
408
|
+
lastXml = xml;
|
|
409
|
+
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
|
|
410
|
+
if (capturePreview) {
|
|
411
|
+
setTimeout(() => {
|
|
412
|
+
pendingAiSvg = true;
|
|
413
|
+
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
|
414
|
+
}, 500);
|
|
283
415
|
}
|
|
284
|
-
|
|
285
|
-
lastLoadedXml = xml;
|
|
286
|
-
iframe.contentWindow.postMessage(JSON.stringify({
|
|
287
|
-
action: 'load',
|
|
288
|
-
xml: xml,
|
|
289
|
-
autosave: 1
|
|
290
|
-
}), '*');
|
|
291
416
|
}
|
|
292
417
|
|
|
293
|
-
async function pushState(xml) {
|
|
418
|
+
async function pushState(xml, svg = '') {
|
|
294
419
|
if (!sessionId) return;
|
|
295
|
-
|
|
296
420
|
try {
|
|
297
|
-
const
|
|
421
|
+
const r = await fetch('/api/state', {
|
|
298
422
|
method: 'POST',
|
|
299
423
|
headers: { 'Content-Type': 'application/json' },
|
|
300
|
-
body: JSON.stringify({ sessionId, xml })
|
|
424
|
+
body: JSON.stringify({ sessionId, xml, svg })
|
|
301
425
|
});
|
|
426
|
+
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
|
|
427
|
+
} catch (e) { console.error('Push failed:', e); }
|
|
428
|
+
}
|
|
302
429
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
430
|
+
let pendingSyncExport = false;
|
|
431
|
+
|
|
432
|
+
async function poll() {
|
|
433
|
+
if (!sessionId) return;
|
|
434
|
+
try {
|
|
435
|
+
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
|
436
|
+
if (!r.ok) return;
|
|
437
|
+
const s = await r.json();
|
|
438
|
+
// Handle sync request - server needs fresh state
|
|
439
|
+
if (s.syncRequested && !pendingSyncExport) {
|
|
440
|
+
pendingSyncExport = true;
|
|
441
|
+
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
|
307
442
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
443
|
+
// Load new diagram from server
|
|
444
|
+
if (s.version > currentVersion && s.xml) {
|
|
445
|
+
currentVersion = s.version;
|
|
446
|
+
loadDiagram(s.xml, true);
|
|
447
|
+
}
|
|
448
|
+
} catch {}
|
|
311
449
|
}
|
|
312
450
|
|
|
313
|
-
|
|
314
|
-
|
|
451
|
+
if (sessionId) { poll(); setInterval(poll, 2000); }
|
|
452
|
+
|
|
453
|
+
// History UI
|
|
454
|
+
const historyBtn = document.getElementById('history-btn');
|
|
455
|
+
const historyModal = document.getElementById('history-modal');
|
|
456
|
+
const historyGrid = document.getElementById('history-grid');
|
|
457
|
+
const historyEmpty = document.getElementById('history-empty');
|
|
458
|
+
const restoreBtn = document.getElementById('restore-btn');
|
|
459
|
+
const cancelBtn = document.getElementById('cancel-btn');
|
|
460
|
+
let historyData = [], selectedIdx = null;
|
|
315
461
|
|
|
462
|
+
historyBtn.onclick = async () => {
|
|
463
|
+
if (!sessionId) return;
|
|
316
464
|
try {
|
|
317
|
-
const
|
|
318
|
-
if (
|
|
465
|
+
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
|
|
466
|
+
if (r.ok) {
|
|
467
|
+
const d = await r.json();
|
|
468
|
+
historyData = d.entries || [];
|
|
469
|
+
renderHistory();
|
|
470
|
+
}
|
|
471
|
+
} catch {}
|
|
472
|
+
historyModal.classList.add('open');
|
|
473
|
+
};
|
|
319
474
|
|
|
320
|
-
|
|
475
|
+
cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedIdx = null; restoreBtn.disabled = true; };
|
|
476
|
+
historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); };
|
|
321
477
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
console.error('Failed to poll state:', e);
|
|
478
|
+
function renderHistory() {
|
|
479
|
+
if (historyData.length === 0) {
|
|
480
|
+
historyGrid.style.display = 'none';
|
|
481
|
+
historyEmpty.style.display = 'block';
|
|
482
|
+
return;
|
|
328
483
|
}
|
|
484
|
+
historyGrid.style.display = 'grid';
|
|
485
|
+
historyEmpty.style.display = 'none';
|
|
486
|
+
historyGrid.innerHTML = historyData.map((e, i) => \`
|
|
487
|
+
<div class="history-item" data-idx="\${e.index}">
|
|
488
|
+
<div class="thumb">\${e.svg ? \`<img src="\${e.svg}">\` : '#' + e.index}</div>
|
|
489
|
+
<div class="label">#\${e.index}</div>
|
|
490
|
+
</div>
|
|
491
|
+
\`).join('');
|
|
492
|
+
historyGrid.querySelectorAll('.history-item').forEach(item => {
|
|
493
|
+
item.onclick = () => {
|
|
494
|
+
const idx = parseInt(item.dataset.idx);
|
|
495
|
+
if (selectedIdx === idx) { selectedIdx = null; restoreBtn.disabled = true; }
|
|
496
|
+
else { selectedIdx = idx; restoreBtn.disabled = false; }
|
|
497
|
+
historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.idx) === selectedIdx));
|
|
498
|
+
};
|
|
499
|
+
});
|
|
329
500
|
}
|
|
330
501
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
502
|
+
restoreBtn.onclick = async () => {
|
|
503
|
+
if (selectedIdx === null) return;
|
|
504
|
+
restoreBtn.disabled = true;
|
|
505
|
+
restoreBtn.textContent = 'Restoring...';
|
|
506
|
+
try {
|
|
507
|
+
const r = await fetch('/api/restore', {
|
|
508
|
+
method: 'POST',
|
|
509
|
+
headers: { 'Content-Type': 'application/json' },
|
|
510
|
+
body: JSON.stringify({ sessionId, index: selectedIdx })
|
|
511
|
+
});
|
|
512
|
+
if (r.ok) { cancelBtn.onclick(); await poll(); }
|
|
513
|
+
else { alert('Restore failed'); }
|
|
514
|
+
} catch { alert('Restore failed'); }
|
|
515
|
+
restoreBtn.textContent = 'Restore';
|
|
516
|
+
};
|
|
336
517
|
</script>
|
|
337
518
|
</body>
|
|
338
519
|
</html>`;
|