@civilization/dsh-drawio 0.3.0 → 0.4.0
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 +4 -5
- package/lib/client.js +87 -32
- package/package.json +1 -1
- package/skills/drawio-operation/SKILL.md +76 -49
- package/src/client/index.tsx +97 -37
- package/src/index.js +12 -7
- package/src/xml.js +119 -14
package/README.md
CHANGED
|
@@ -14,13 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
## Agent 工具与 Skill
|
|
16
16
|
|
|
17
|
-
- `drawio-operation` Skill:向模型注入 Draw.io
|
|
18
|
-
- `drawio_inspect
|
|
19
|
-
- `drawio_edit
|
|
17
|
+
- `drawio-operation` Skill:向模型注入 Draw.io 画板整体架构、工业级排版规范、现代配色方案、离线导出机制与最佳绘图工作流。
|
|
18
|
+
- `drawio_inspect`:把画板读取为页面、节点、连线和几何信息,自动提取去除 HTML 的纯文本标题与容器子节点列表。
|
|
19
|
+
- `drawio_edit`:批量新增、更新、删除节点与连线,支持 `add_page`、`rename_page`、`delete_page` 多页面管理,以及 `stylePatch` 局部增量更新样式。
|
|
20
20
|
- `drawio_write`:校验并写入完整的 Draw.io XML。
|
|
21
21
|
|
|
22
|
-
工具统一保存为未压缩 XML,便于 Agent 继续修改,也便于 Git
|
|
23
|
-
自动保存,并轮询同步 Agent 对同一文件的修改。
|
|
22
|
+
工具统一保存为未压缩 XML,便于 Agent 继续修改,也便于 Git 查看差异。画板具备真乐观并发控制(防静默覆盖冲突),并采用轻量版本感知机制自动同步 Agent 对同一文件的修改。
|
|
24
23
|
|
|
25
24
|
## 本地离线图片导出 (Export)
|
|
26
25
|
|
package/lib/client.js
CHANGED
|
@@ -73,8 +73,8 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
|
|
|
73
73
|
if (!resource?.path || !effectiveSessionId || !cwd) return;
|
|
74
74
|
const controller = new AbortController();
|
|
75
75
|
setError(null);
|
|
76
|
-
void fsRead({ sessionId: effectiveSessionId, cwd }, resource.path, controller.signal).then((
|
|
77
|
-
if (!controller.signal.aborted) setFile({ key, content });
|
|
76
|
+
void fsRead({ sessionId: effectiveSessionId, cwd }, resource.path, controller.signal).then((res) => {
|
|
77
|
+
if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version });
|
|
78
78
|
}).catch((reason) => {
|
|
79
79
|
if (!controller.signal.aborted) setError(messageOf(reason));
|
|
80
80
|
});
|
|
@@ -100,12 +100,13 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
|
|
|
100
100
|
}
|
|
101
101
|
if (error) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u753B\u677F\u8F7D\u5165\u5931\u8D25", detail: error });
|
|
102
102
|
if (file?.key !== key) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u6B63\u5728\u8F7D\u5165\u753B\u677F", detail: fileName(resource.path) });
|
|
103
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawioCanvas, { content: file.content, path: resource.path, scope: { sessionId: effectiveSessionId, cwd } });
|
|
103
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawioCanvas, { content: file.content, version: file.version, path: resource.path, scope: { sessionId: effectiveSessionId, cwd } });
|
|
104
104
|
}
|
|
105
|
-
function DrawioCanvas({ content, truncated, path, scope }) {
|
|
105
|
+
function DrawioCanvas({ content, version, truncated, path, scope }) {
|
|
106
106
|
const frame = (0, import_react.useRef)(null);
|
|
107
107
|
const latestFile = (0, import_react.useRef)(content ?? "");
|
|
108
108
|
const latestEditor = (0, import_react.useRef)(content ?? "");
|
|
109
|
+
const latestVersion = (0, import_react.useRef)(version);
|
|
109
110
|
const saveTimer = (0, import_react.useRef)(void 0);
|
|
110
111
|
const saveGeneration = (0, import_react.useRef)(0);
|
|
111
112
|
const [ready, setReady] = (0, import_react.useState)(false);
|
|
@@ -114,24 +115,46 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
114
115
|
const lastUserActivity = (0, import_react.useRef)(0);
|
|
115
116
|
const isInteracting = (0, import_react.useRef)(false);
|
|
116
117
|
const { dark, accent: accentColor } = useDshTheme();
|
|
117
|
-
const
|
|
118
|
+
const initialDark = (0, import_react.useRef)(dark);
|
|
119
|
+
const src = (0, import_react.useMemo)(() => `/dsh-drawio/runtime/index.html?embed=1&proto=json&spin=1&offline=1&local=1&ui=min&libraries=1&configure=1&noExitBtn=1&saveAndExit=0&dark=${initialDark.current ? "1" : "0"}`, []);
|
|
118
120
|
const post = (0, import_react.useCallback)((message) => {
|
|
119
121
|
frame.current?.contentWindow?.postMessage(JSON.stringify(message), window.location.origin);
|
|
120
122
|
}, []);
|
|
123
|
+
const reloadFromDisk = (0, import_react.useCallback)(async () => {
|
|
124
|
+
try {
|
|
125
|
+
setSaveState("loading");
|
|
126
|
+
const result = await fsRead(scope, path);
|
|
127
|
+
latestFile.current = result.content;
|
|
128
|
+
latestEditor.current = result.content;
|
|
129
|
+
latestVersion.current = result.version;
|
|
130
|
+
post({ action: "load", xml: result.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 });
|
|
131
|
+
setSaveState("saved");
|
|
132
|
+
setError(null);
|
|
133
|
+
} catch (reason) {
|
|
134
|
+
setError(messageOf(reason));
|
|
135
|
+
}
|
|
136
|
+
}, [scope, path, dark, post]);
|
|
121
137
|
const persist = (0, import_react.useCallback)(async (xml) => {
|
|
122
138
|
const generation = ++saveGeneration.current;
|
|
123
139
|
setSaveState("saving");
|
|
124
140
|
try {
|
|
125
|
-
await fsWrite(scope, path, xml);
|
|
141
|
+
const outcome = await fsWrite(scope, path, xml, latestVersion.current);
|
|
126
142
|
if (generation !== saveGeneration.current) return;
|
|
127
143
|
latestFile.current = xml;
|
|
128
144
|
latestEditor.current = xml;
|
|
145
|
+
if (outcome?.version) latestVersion.current = outcome.version;
|
|
129
146
|
setSaveState("saved");
|
|
130
147
|
setError(null);
|
|
131
148
|
} catch (reason) {
|
|
132
149
|
if (generation !== saveGeneration.current) return;
|
|
133
|
-
|
|
134
|
-
|
|
150
|
+
const msg = messageOf(reason);
|
|
151
|
+
if (msg.includes("modified by an external process") || msg.includes("409")) {
|
|
152
|
+
setSaveState("conflict");
|
|
153
|
+
setError("\u6587\u4EF6\u5DF2\u88AB\u5916\u90E8\u6216 AI \u4FEE\u6539\uFF0C\u5F53\u524D\u5B58\u5728\u5E76\u53D1\u51B2\u7A81\uFF0C\u70B9\u51FB\u53EF\u91CD\u65B0\u8F7D\u5165\u6700\u65B0\u5185\u5BB9");
|
|
154
|
+
} else {
|
|
155
|
+
setSaveState("error");
|
|
156
|
+
setError(msg);
|
|
157
|
+
}
|
|
135
158
|
}
|
|
136
159
|
}, [scope.sessionId, scope.cwd, path]);
|
|
137
160
|
const scheduleSave = (0, import_react.useCallback)((xml, immediate = false) => {
|
|
@@ -153,7 +176,8 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
153
176
|
(0, import_react.useEffect)(() => {
|
|
154
177
|
latestFile.current = content ?? "";
|
|
155
178
|
latestEditor.current = content ?? "";
|
|
156
|
-
|
|
179
|
+
latestVersion.current = version;
|
|
180
|
+
}, [content, version, path]);
|
|
157
181
|
const markActivity = (0, import_react.useCallback)(() => {
|
|
158
182
|
lastUserActivity.current = Date.now();
|
|
159
183
|
}, []);
|
|
@@ -227,15 +251,25 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
227
251
|
document.body.removeChild(link);
|
|
228
252
|
showToast(`\u5DF2\u5F00\u59CB\u4E0B\u8F7D\uFF1A${fileName(pending.targetPath)}`);
|
|
229
253
|
} else if (pending.target === "clipboard") {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
254
|
+
try {
|
|
255
|
+
if (pending.format === "svg") {
|
|
256
|
+
const svgText = data.startsWith("data:image/svg+xml") ? decodeURIComponent(data.split(",")[1] || "") : data;
|
|
257
|
+
await navigator.clipboard.writeText(svgText);
|
|
258
|
+
showToast("\u5DF2\u590D\u5236 SVG \u4EE3\u7801\u5230\u526A\u8D34\u677F");
|
|
259
|
+
} else {
|
|
260
|
+
const res = await fetch(data);
|
|
261
|
+
const blob = await res.blob();
|
|
262
|
+
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
|
263
|
+
showToast("\u5DF2\u590D\u5236\u56FE\u7247\u5230\u526A\u8D34\u677F");
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
const link = document.createElement("a");
|
|
267
|
+
link.download = fileName(pending.targetPath);
|
|
268
|
+
link.href = data;
|
|
269
|
+
document.body.appendChild(link);
|
|
270
|
+
link.click();
|
|
271
|
+
document.body.removeChild(link);
|
|
272
|
+
showToast(`\u526A\u8D34\u677F\u65E0\u6743\u9650\uFF0C\u5DF2\u81EA\u52A8\u4E0B\u8F7D\uFF1A${fileName(pending.targetPath)}`);
|
|
239
273
|
}
|
|
240
274
|
} else if (pending.target === "workspace") {
|
|
241
275
|
const result = await fsSaveImage(scope, pending.targetPath, data);
|
|
@@ -294,12 +328,19 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
294
328
|
if (saveTimer.current !== void 0) return;
|
|
295
329
|
if (typeof document !== "undefined" && document.hidden) return;
|
|
296
330
|
try {
|
|
297
|
-
const
|
|
331
|
+
const stat = await fsStat(scope, path);
|
|
298
332
|
if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return;
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
333
|
+
if (stat.version && latestVersion.current && stat.version === latestVersion.current) return;
|
|
334
|
+
const res = await fsRead(scope, path);
|
|
335
|
+
if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return;
|
|
336
|
+
if (res.content === latestFile.current || res.content === latestEditor.current) {
|
|
337
|
+
latestVersion.current = res.version;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
latestFile.current = res.content;
|
|
341
|
+
latestEditor.current = res.content;
|
|
342
|
+
latestVersion.current = res.version;
|
|
343
|
+
post({ action: "load", xml: res.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 });
|
|
303
344
|
setSaveState("external");
|
|
304
345
|
window.setTimeout(() => {
|
|
305
346
|
if (!cancelled) setSaveState("saved");
|
|
@@ -318,7 +359,7 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
318
359
|
}, [ready, truncated, scope.sessionId, scope.cwd, path, dark, post]);
|
|
319
360
|
(0, import_react.useEffect)(() => () => {
|
|
320
361
|
if (saveTimer.current !== void 0) window.clearTimeout(saveTimer.current);
|
|
321
|
-
if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, true);
|
|
362
|
+
if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, latestVersion.current, true);
|
|
322
363
|
}, [scope.sessionId, scope.cwd, path]);
|
|
323
364
|
if (truncated) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F", detail: "\u6587\u4EF6\u5185\u5BB9\u5DF2\u88AB\u622A\u65AD\uFF0C\u7EE7\u7EED\u7F16\u8F91\u53EF\u80FD\u635F\u574F\u539F\u6587\u4EF6\u3002" });
|
|
324
365
|
const targetExt = exportFormat === "svg" ? ".svg" : exportFormat === "xmlpng" ? ".drawio.png" : ".png";
|
|
@@ -419,11 +460,23 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
419
460
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(CheckIcon, { size: 14 }),
|
|
420
461
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: toast })
|
|
421
462
|
] }) : null,
|
|
422
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { role: "status", "aria-live": "polite", style: { ...styles.status, ...saveState === "error" ? styles.statusError : {} }, children: [
|
|
423
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...styles.dot, background: accentColor } }),
|
|
463
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { role: "status", "aria-live": "polite", style: { ...styles.status, ...saveState === "error" || saveState === "conflict" ? styles.statusError : {} }, children: [
|
|
464
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...styles.dot, background: saveState === "conflict" ? "var(--dsw-alias-warning, #e6a23c)" : accentColor } }),
|
|
424
465
|
statusText(saveState)
|
|
425
466
|
] }),
|
|
426
|
-
error ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
467
|
+
error ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
468
|
+
"button",
|
|
469
|
+
{
|
|
470
|
+
type: "button",
|
|
471
|
+
title: error,
|
|
472
|
+
onClick: () => {
|
|
473
|
+
if (saveState === "conflict") void reloadFromDisk();
|
|
474
|
+
setError(null);
|
|
475
|
+
},
|
|
476
|
+
style: styles.error,
|
|
477
|
+
children: saveState === "conflict" ? "\u26A0\uFE0F \u68C0\u6D4B\u5230\u5916\u90E8\u4FEE\u6539 \xB7 \u70B9\u51FB\u91CD\u65B0\u8F7D\u5165" : "\u4FDD\u5B58\u5931\u8D25 \xB7 \u70B9\u51FB\u5173\u95ED"
|
|
478
|
+
}
|
|
479
|
+
) : null
|
|
427
480
|
] });
|
|
428
481
|
}
|
|
429
482
|
function useDshTheme() {
|
|
@@ -464,12 +517,14 @@ function detectDark() {
|
|
|
464
517
|
if (numbers?.length === 3) return numbers[0] * 0.299 + numbers[1] * 0.587 + numbers[2] * 0.114 < 128;
|
|
465
518
|
return document.documentElement.classList.contains("dark") || window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
466
519
|
}
|
|
520
|
+
async function fsStat(scope, path, signal) {
|
|
521
|
+
return call("stat", scope, { path }, false, signal);
|
|
522
|
+
}
|
|
467
523
|
async function fsRead(scope, path, signal) {
|
|
468
|
-
|
|
469
|
-
return value.content;
|
|
524
|
+
return call("read", scope, { path }, false, signal);
|
|
470
525
|
}
|
|
471
|
-
async function fsWrite(scope, path, content, keepalive = false) {
|
|
472
|
-
|
|
526
|
+
async function fsWrite(scope, path, content, version, keepalive = false) {
|
|
527
|
+
return call("write", scope, { path, content, ...version ? { version } : {} }, keepalive);
|
|
473
528
|
}
|
|
474
529
|
async function fsSaveImage(scope, path, data) {
|
|
475
530
|
return call("save-image", scope, { path, data });
|
|
@@ -559,7 +614,7 @@ function parseDrawioAddress(address, fallbackSessionId) {
|
|
|
559
614
|
}
|
|
560
615
|
}
|
|
561
616
|
var messageOf = (reason) => reason instanceof Error ? reason.message : String(reason);
|
|
562
|
-
var statusText = (state) => state === "loading" ? "\u6B63\u5728\u8F7D\u5165" : state === "saving" ? "\u6B63\u5728\u4FDD\u5B58" : state === "external" ? "\u5DF2\u540C\u6B65 AI \u4FEE\u6539" : state === "error" ? "\u4FDD\u5B58\u5931\u8D25" : "\u5DF2\u4FDD\u5B58";
|
|
617
|
+
var statusText = (state) => state === "loading" ? "\u6B63\u5728\u8F7D\u5165" : state === "saving" ? "\u6B63\u5728\u4FDD\u5B58" : state === "external" ? "\u5DF2\u540C\u6B65 AI \u4FEE\u6539" : state === "conflict" ? "\u68C0\u6D4B\u5230\u51B2\u7A81" : state === "error" ? "\u4FDD\u5B58\u5931\u8D25" : "\u5DF2\u4FDD\u5B58";
|
|
563
618
|
var fg = "var(--dsw-alias-label-primary, currentColor)";
|
|
564
619
|
var muted = "var(--dsw-alias-label-secondary, rgba(127,127,127,.9))";
|
|
565
620
|
var layer = "var(--dsw-alias-container-bg, Canvas)";
|
package/package.json
CHANGED
|
@@ -1,53 +1,80 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: drawio-operation
|
|
3
|
-
description:
|
|
3
|
+
description: 读取、检查、创建和语义化编辑 Draw.io 架构图与流程图,协同原生离线画板,并指导导出与排版工作流。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Draw.io
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
##
|
|
13
|
-
|
|
14
|
-
1.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
-
|
|
6
|
+
# Draw.io 画板与图表协同指南
|
|
7
|
+
|
|
8
|
+
`dsh-drawio` 插件为 DeepSeek Harness (DSH) 提供 100% 本地离线的 Draw.io 画板,并向 Agent 注册结构化图表编辑工具。
|
|
9
|
+
|
|
10
|
+
当用户询问 Draw.io 功能或需要绘制图表时,直接说明支持的画板功能与工具,无需探测工作区无关文件。
|
|
11
|
+
|
|
12
|
+
## 核心模型工具
|
|
13
|
+
|
|
14
|
+
### 1. `drawio_inspect`
|
|
15
|
+
- **功能**:将现有 `.drawio` 文件解析为结构化的页面、节点、连线、文本内容及几何坐标。
|
|
16
|
+
- **最佳实践**:修改已有图表前必须先调用 `drawio_inspect`,获取已有 cell ID、父子层级关系及连线端点。
|
|
17
|
+
- **字段**:返回的 `plainText` 是已去除 HTML 格式的纯净标签;`children` 标识该容器节点包含的子节点 ID。
|
|
18
|
+
- 仅在需要查看原始底层 XML 属性时设置 `include_xml: true`。
|
|
19
|
+
|
|
20
|
+
### 2. `drawio_edit`
|
|
21
|
+
- **功能**:对现有 `.drawio` 文件应用批量语义化增量修改,支持多页面管理。
|
|
22
|
+
- **支持的批量操作类型**:
|
|
23
|
+
- `add_node`:新增节点。属性:`id`, `label`, `style`, `parent`, `x`, `y`, `width`, `height`。
|
|
24
|
+
- `add_edge`:新增连线。属性:`id`, `source`, `target`, `label`, `style`, `parent`。
|
|
25
|
+
- `update`:更新已有节点或连线属性。
|
|
26
|
+
- 推荐使用 `stylePatch: { fillColor: '#dae8fc', strokeColor: '#6c8ebf' }` 局部增量修改颜色或边框,避免冲掉节点的已有样式(如圆角、对齐等)。
|
|
27
|
+
- 也支持整体更新 `label`, `style`, `x`, `y`, `width`, `height`。
|
|
28
|
+
- `delete`:安全删除节点,并自动级联清理连接到该节点的孤立连线。
|
|
29
|
+
- `add_page`:在当前文档中新增图表页面。属性:`id`, `name`(如 `部署拓扑`、`时序交互`)。
|
|
30
|
+
- `rename_page`:重命名页面。属性:`page` (索引或ID), `name`。
|
|
31
|
+
- `delete_page`:删除指定页面。属性:`page`。
|
|
32
|
+
- **规范**:节点 ID 应具备稳定且语义化的英文标识(例如 `client`, `api_gateway`, `auth_service`, `db_cluster`),严禁使用易冲突的纯数字 ID。
|
|
33
|
+
|
|
34
|
+
### 3. `drawio_write`
|
|
35
|
+
- **功能**:校验并写入完整的 Draw.io `mxfile` 或 `mxGraphModel` XML 文档。
|
|
36
|
+
- 自动格式化为未压缩的规范 XML,便于后续 AI 审查与 Git diff 跟踪。
|
|
37
|
+
- **建议**:从零构建新图表时首选 `drawio_write`;对现有图表进行局部修改或扩展时首选 `drawio_edit`。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 架构图排版与设计规范
|
|
42
|
+
|
|
43
|
+
为了保证大模型生成的图表整洁、专业、无重叠,请遵循以下工业级排版规范:
|
|
44
|
+
|
|
45
|
+
### 1. 坐标与网格系统
|
|
46
|
+
- **基准网格**:Draw.io 默认 `gridSize=10`,所有坐标 `x`, `y`, `width`, `height` 请取 10 的整数倍。
|
|
47
|
+
- **标准节点尺寸**:
|
|
48
|
+
- 常规服务/模块:`width: 140, height: 60`
|
|
49
|
+
- 数据库/存储:`width: 120, height: 70`
|
|
50
|
+
- 外部系统/客户端:`width: 130, height: 50`
|
|
51
|
+
- 分组容器(Group/VPC):根据内部节点数量自适应(如 `width: 600, height: 320`)
|
|
52
|
+
- **间距推荐**:
|
|
53
|
+
- 横向流式布局:节点水平间距保持在 `80px ~ 120px`(如 `x1=40, x2=240, x3=440`);
|
|
54
|
+
- 纵向分层架构:分层垂直间距保持在 `90px ~ 130px`(如 Layer 1: `y=40`,Layer 2: `y=160`,Layer 3: `y=280`)。
|
|
55
|
+
|
|
56
|
+
### 2. 经典高质感现代配色方案(Flat Palette)
|
|
57
|
+
|
|
58
|
+
| 分类/层级 | 填充色 `fillColor` | 边框色 `strokeColor` | 字体色 `fontColor` | 推荐使用场景 |
|
|
59
|
+
| :--- | :--- | :--- | :--- | :--- |
|
|
60
|
+
| **接入/网关** | `#dae8fc` (浅蓝) | `#6c8ebf` (深蓝) | `#000000` | Client, Nginx, API Gateway, CDN |
|
|
61
|
+
| **业务服务** | `#d5e8d4` (浅绿) | `#82b366` (深绿) | `#000000` | OrderService, UserService, K8s Pods |
|
|
62
|
+
| **存储/数据库** | `#ffe6cc` (暖橙) | `#d79b00` (深橙) | `#000000` | MySQL, Redis, MongoDB, S3 |
|
|
63
|
+
| **异步队列/通信** | `#e1d5e7` (浅紫) | `#9673a6` (深紫) | `#000000` | Kafka, RabbitMQ, EventBus |
|
|
64
|
+
| **告警/高风险** | `#f8cecc` (浅红) | `#b85450` (深红) | `#000000` | ErrorHandler, DeadLetter, Alert |
|
|
65
|
+
| **容器/分组边框** | `#f5f5f5` (极浅灰) | `#cccccc` (浅灰) | `#333333` | VPC, Kubernetes Cluster, Subnet |
|
|
66
|
+
|
|
67
|
+
### 3. 连线样式(Edge Styles)
|
|
68
|
+
- 统一使用正交折线:`edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;`
|
|
69
|
+
- 若要强调特定流向,可增加线宽 `strokeWidth=2;`,异步/订阅连线推荐增加虚线 `dashed=1;`。
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 离线画板协同与图片导出
|
|
74
|
+
|
|
75
|
+
- **右侧栏实时双向协同**:
|
|
76
|
+
- 用户在 DSH 中打开任何 `.drawio` 文件即可在右侧栏全功能画板中直观交互;
|
|
77
|
+
- 模型调用 `drawio_edit` 或 `drawio_write` 保存后,画板会自动轻量检测版本变化并同步刷新,且不会中断用户正在进行的画布拖拽。
|
|
78
|
+
- **纯本地离线图片导出**:
|
|
79
|
+
- 画板右上角内置纯本地导出面板,支持导出为 **PNG (2x 超清)**、**SVG (矢量无损)** 和 **XML-PNG (双模可编辑图)**;
|
|
80
|
+
- 支持一键**保存到工作区**(自动生成同名图片)、**写入系统剪贴板**(便于直接粘贴到文档或聊天窗口)或**下载到本地磁盘**。
|
package/src/client/index.tsx
CHANGED
|
@@ -6,7 +6,7 @@ const TAB_ID = '@civilization/dsh-drawio'
|
|
|
6
6
|
const TAB_KIND = 'drawio'
|
|
7
7
|
|
|
8
8
|
interface SessionScope { sessionId: string; cwd?: string }
|
|
9
|
-
interface FileViewerProps { content?: string; truncated?: boolean; path: string; scope: SessionScope }
|
|
9
|
+
interface FileViewerProps { content?: string; version?: string; truncated?: boolean; path: string; scope: SessionScope }
|
|
10
10
|
interface OfficialContext extends Context { slots: any; sidebarRightTabs: any }
|
|
11
11
|
|
|
12
12
|
export function apply(ctx: Context): void {
|
|
@@ -44,7 +44,7 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX
|
|
|
44
44
|
const cwd = activeCwd || resourceCwd
|
|
45
45
|
const effectiveSessionId = sessionId || resource?.sessionId || ''
|
|
46
46
|
|
|
47
|
-
const [file, setFile] = useState<{ key: string; content: string } | null>(null)
|
|
47
|
+
const [file, setFile] = useState<{ key: string; content: string; version?: string } | null>(null)
|
|
48
48
|
const [error, setError] = useState<string | null>(null)
|
|
49
49
|
const key = `${effectiveSessionId}\0${cwd ?? ''}\0${resource?.path ?? ''}`
|
|
50
50
|
|
|
@@ -53,7 +53,7 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX
|
|
|
53
53
|
const controller = new AbortController()
|
|
54
54
|
setError(null)
|
|
55
55
|
void fsRead({ sessionId: effectiveSessionId, cwd }, resource.path, controller.signal)
|
|
56
|
-
.then(
|
|
56
|
+
.then(res => { if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version }) })
|
|
57
57
|
.catch(reason => { if (!controller.signal.aborted) setError(messageOf(reason)) })
|
|
58
58
|
return () => controller.abort()
|
|
59
59
|
}, [key, resource?.path, effectiveSessionId, cwd])
|
|
@@ -76,41 +76,65 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX
|
|
|
76
76
|
}
|
|
77
77
|
if (error) return <CenteredMessage title="画板载入失败" detail={error} />
|
|
78
78
|
if (file?.key !== key) return <CenteredMessage title="正在载入画板" detail={fileName(resource.path)} />
|
|
79
|
-
return <DrawioCanvas content={file.content} path={resource.path} scope={{ sessionId: effectiveSessionId, cwd }} />
|
|
79
|
+
return <DrawioCanvas content={file.content} version={file.version} path={resource.path} scope={{ sessionId: effectiveSessionId, cwd }} />
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX.Element {
|
|
82
|
+
function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerProps): JSX.Element {
|
|
83
83
|
const frame = useRef<HTMLIFrameElement>(null)
|
|
84
84
|
const latestFile = useRef(content ?? '')
|
|
85
85
|
const latestEditor = useRef(content ?? '')
|
|
86
|
+
const latestVersion = useRef(version)
|
|
86
87
|
const saveTimer = useRef<number | undefined>(undefined)
|
|
87
88
|
const saveGeneration = useRef(0)
|
|
88
89
|
const [ready, setReady] = useState(false)
|
|
89
|
-
const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'external' | 'error'>('loading')
|
|
90
|
+
const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'external' | 'conflict' | 'error'>('loading')
|
|
90
91
|
const [error, setError] = useState<string | null>(truncated ? '文件过大,DSH 只返回了截断内容,无法安全打开。' : null)
|
|
91
92
|
const lastUserActivity = useRef(0)
|
|
92
93
|
const isInteracting = useRef(false)
|
|
93
94
|
const { dark, accent: accentColor } = useDshTheme()
|
|
94
|
-
const
|
|
95
|
+
const initialDark = useRef(dark)
|
|
96
|
+
const src = useMemo(() => `/dsh-drawio/runtime/index.html?embed=1&proto=json&spin=1&offline=1&local=1&ui=min&libraries=1&configure=1&noExitBtn=1&saveAndExit=0&dark=${initialDark.current ? '1' : '0'}`, [])
|
|
95
97
|
|
|
96
98
|
const post = useCallback((message: unknown) => {
|
|
97
99
|
frame.current?.contentWindow?.postMessage(JSON.stringify(message), window.location.origin)
|
|
98
100
|
}, [])
|
|
99
101
|
|
|
102
|
+
const reloadFromDisk = useCallback(async () => {
|
|
103
|
+
try {
|
|
104
|
+
setSaveState('loading')
|
|
105
|
+
const result = await fsRead(scope, path)
|
|
106
|
+
latestFile.current = result.content
|
|
107
|
+
latestEditor.current = result.content
|
|
108
|
+
latestVersion.current = result.version
|
|
109
|
+
post({ action: 'load', xml: result.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
|
|
110
|
+
setSaveState('saved')
|
|
111
|
+
setError(null)
|
|
112
|
+
} catch (reason) {
|
|
113
|
+
setError(messageOf(reason))
|
|
114
|
+
}
|
|
115
|
+
}, [scope, path, dark, post])
|
|
116
|
+
|
|
100
117
|
const persist = useCallback(async (xml: string) => {
|
|
101
118
|
const generation = ++saveGeneration.current
|
|
102
119
|
setSaveState('saving')
|
|
103
120
|
try {
|
|
104
|
-
await fsWrite(scope, path, xml)
|
|
121
|
+
const outcome = await fsWrite(scope, path, xml, latestVersion.current)
|
|
105
122
|
if (generation !== saveGeneration.current) return
|
|
106
123
|
latestFile.current = xml
|
|
107
124
|
latestEditor.current = xml
|
|
125
|
+
if (outcome?.version) latestVersion.current = outcome.version
|
|
108
126
|
setSaveState('saved')
|
|
109
127
|
setError(null)
|
|
110
128
|
} catch (reason) {
|
|
111
129
|
if (generation !== saveGeneration.current) return
|
|
112
|
-
|
|
113
|
-
|
|
130
|
+
const msg = messageOf(reason)
|
|
131
|
+
if (msg.includes('modified by an external process') || msg.includes('409')) {
|
|
132
|
+
setSaveState('conflict')
|
|
133
|
+
setError('文件已被外部或 AI 修改,当前存在并发冲突,点击可重新载入最新内容')
|
|
134
|
+
} else {
|
|
135
|
+
setSaveState('error')
|
|
136
|
+
setError(msg)
|
|
137
|
+
}
|
|
114
138
|
}
|
|
115
139
|
}, [scope.sessionId, scope.cwd, path])
|
|
116
140
|
|
|
@@ -134,7 +158,8 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
134
158
|
useEffect(() => {
|
|
135
159
|
latestFile.current = content ?? ''
|
|
136
160
|
latestEditor.current = content ?? ''
|
|
137
|
-
|
|
161
|
+
latestVersion.current = version
|
|
162
|
+
}, [content, version, path])
|
|
138
163
|
|
|
139
164
|
const markActivity = useCallback(() => {
|
|
140
165
|
lastUserActivity.current = Date.now()
|
|
@@ -215,17 +240,28 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
215
240
|
document.body.removeChild(link)
|
|
216
241
|
showToast(`已开始下载:${fileName(pending.targetPath)}`)
|
|
217
242
|
} else if (pending.target === 'clipboard') {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
243
|
+
try {
|
|
244
|
+
if (pending.format === 'svg') {
|
|
245
|
+
const svgText = data.startsWith('data:image/svg+xml')
|
|
246
|
+
? decodeURIComponent(data.split(',')[1] || '')
|
|
247
|
+
: data
|
|
248
|
+
await navigator.clipboard.writeText(svgText)
|
|
249
|
+
showToast('已复制 SVG 代码到剪贴板')
|
|
250
|
+
} else {
|
|
251
|
+
const res = await fetch(data)
|
|
252
|
+
const blob = await res.blob()
|
|
253
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
|
254
|
+
showToast('已复制图片到剪贴板')
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// 剪贴板受限或报错时降级为自动下载
|
|
258
|
+
const link = document.createElement('a')
|
|
259
|
+
link.download = fileName(pending.targetPath)
|
|
260
|
+
link.href = data
|
|
261
|
+
document.body.appendChild(link)
|
|
262
|
+
link.click()
|
|
263
|
+
document.body.removeChild(link)
|
|
264
|
+
showToast(`剪贴板无权限,已自动下载:${fileName(pending.targetPath)}`)
|
|
229
265
|
}
|
|
230
266
|
} else if (pending.target === 'workspace') {
|
|
231
267
|
const result = await fsSaveImage(scope, pending.targetPath, data)
|
|
@@ -289,14 +325,23 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
289
325
|
if (typeof document !== 'undefined' && document.hidden) return
|
|
290
326
|
|
|
291
327
|
try {
|
|
292
|
-
|
|
328
|
+
// 轻量检查版本号
|
|
329
|
+
const stat = await fsStat(scope, path)
|
|
330
|
+
if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return
|
|
331
|
+
if (stat.version && latestVersion.current && stat.version === latestVersion.current) return
|
|
332
|
+
|
|
333
|
+
const res = await fsRead(scope, path)
|
|
293
334
|
// 5. 响应返回后二次防护:若这期间用户按下了鼠标或开始拖动,果断丢弃本次结果
|
|
294
335
|
if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return
|
|
295
|
-
if (
|
|
336
|
+
if (res.content === latestFile.current || res.content === latestEditor.current) {
|
|
337
|
+
latestVersion.current = res.version
|
|
338
|
+
return
|
|
339
|
+
}
|
|
296
340
|
|
|
297
|
-
latestFile.current =
|
|
298
|
-
latestEditor.current =
|
|
299
|
-
|
|
341
|
+
latestFile.current = res.content
|
|
342
|
+
latestEditor.current = res.content
|
|
343
|
+
latestVersion.current = res.version
|
|
344
|
+
post({ action: 'load', xml: res.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
|
|
300
345
|
setSaveState('external')
|
|
301
346
|
window.setTimeout(() => { if (!cancelled) setSaveState('saved') }, 1200)
|
|
302
347
|
} catch (reason) {
|
|
@@ -310,7 +355,7 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
310
355
|
|
|
311
356
|
useEffect(() => () => {
|
|
312
357
|
if (saveTimer.current !== undefined) window.clearTimeout(saveTimer.current)
|
|
313
|
-
if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, true)
|
|
358
|
+
if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, latestVersion.current, true)
|
|
314
359
|
}, [scope.sessionId, scope.cwd, path])
|
|
315
360
|
|
|
316
361
|
if (truncated) return <CenteredMessage title="无法打开画板" detail="文件内容已被截断,继续编辑可能损坏原文件。" />
|
|
@@ -411,10 +456,22 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
411
456
|
</div>
|
|
412
457
|
) : null}
|
|
413
458
|
|
|
414
|
-
<div role="status" aria-live="polite" style={{ ...styles.status, ...(saveState === 'error' ? styles.statusError : {}) }}>
|
|
415
|
-
<span style={{ ...styles.dot, background: accentColor }} />{statusText(saveState)}
|
|
459
|
+
<div role="status" aria-live="polite" style={{ ...styles.status, ...(saveState === 'error' || saveState === 'conflict' ? styles.statusError : {}) }}>
|
|
460
|
+
<span style={{ ...styles.dot, background: saveState === 'conflict' ? 'var(--dsw-alias-warning, #e6a23c)' : accentColor }} />{statusText(saveState)}
|
|
416
461
|
</div>
|
|
417
|
-
{error ?
|
|
462
|
+
{error ? (
|
|
463
|
+
<button
|
|
464
|
+
type="button"
|
|
465
|
+
title={error}
|
|
466
|
+
onClick={() => {
|
|
467
|
+
if (saveState === 'conflict') void reloadFromDisk()
|
|
468
|
+
setError(null)
|
|
469
|
+
}}
|
|
470
|
+
style={styles.error}
|
|
471
|
+
>
|
|
472
|
+
{saveState === 'conflict' ? '⚠️ 检测到外部修改 · 点击重新载入' : '保存失败 · 点击关闭'}
|
|
473
|
+
</button>
|
|
474
|
+
) : null}
|
|
418
475
|
</div>
|
|
419
476
|
)
|
|
420
477
|
}
|
|
@@ -460,13 +517,16 @@ function detectDark(): boolean {
|
|
|
460
517
|
return document.documentElement.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
461
518
|
}
|
|
462
519
|
|
|
463
|
-
async function
|
|
464
|
-
|
|
465
|
-
|
|
520
|
+
async function fsStat(scope: SessionScope, path: string, signal?: AbortSignal): Promise<{ version: string; size: number }> {
|
|
521
|
+
return call<{ version: string; size: number }>('stat', scope, { path }, false, signal)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function fsRead(scope: SessionScope, path: string, signal?: AbortSignal): Promise<{ content: string; version: string }> {
|
|
525
|
+
return call<{ content: string; version: string }>('read', scope, { path }, false, signal)
|
|
466
526
|
}
|
|
467
527
|
|
|
468
|
-
async function fsWrite(scope: SessionScope, path: string, content: string, keepalive = false): Promise<
|
|
469
|
-
|
|
528
|
+
async function fsWrite(scope: SessionScope, path: string, content: string, version?: string, keepalive = false): Promise<{ version?: string }> {
|
|
529
|
+
return call<{ version?: string }>('write', scope, { path, content, ...(version ? { version } : {}) }, keepalive)
|
|
470
530
|
}
|
|
471
531
|
|
|
472
532
|
async function fsSaveImage(scope: SessionScope, path: string, data: string): Promise<{ path: string; bytes: number }> {
|
|
@@ -555,7 +615,7 @@ export function parseDrawioAddress(address?: string, fallbackSessionId?: string)
|
|
|
555
615
|
}
|
|
556
616
|
}
|
|
557
617
|
const messageOf = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason)
|
|
558
|
-
const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'error' ? '保存失败' : '已保存'
|
|
618
|
+
const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'conflict' ? '检测到冲突' : state === 'error' ? '保存失败' : '已保存'
|
|
559
619
|
const fg = 'var(--dsw-alias-label-primary, currentColor)'
|
|
560
620
|
const muted = 'var(--dsw-alias-label-secondary, rgba(127,127,127,.9))'
|
|
561
621
|
const layer = 'var(--dsw-alias-container-bg, Canvas)'
|
package/src/index.js
CHANGED
|
@@ -46,7 +46,7 @@ export async function apply(ctx) {
|
|
|
46
46
|
}))
|
|
47
47
|
register(ctx, defineTool({
|
|
48
48
|
name: 'drawio_edit',
|
|
49
|
-
description: 'Apply a batch of semantic edits to an existing .drawio file. Supported operations
|
|
49
|
+
description: 'Apply a batch of semantic edits to an existing .drawio file. Supported operations: add_node, add_edge, update, delete, add_page, delete_page, rename_page. Cell ids must be stable and unique. Inspect the file first when modifying an existing diagram.',
|
|
50
50
|
parameters: {
|
|
51
51
|
path: pathParameter,
|
|
52
52
|
page: { oneOf: [{ type: 'string' }, { type: 'number' }] },
|
|
@@ -55,8 +55,9 @@ export async function apply(ctx) {
|
|
|
55
55
|
items: {
|
|
56
56
|
type: 'object', additionalProperties: false,
|
|
57
57
|
properties: {
|
|
58
|
-
type: { type: 'string', enum: ['add_node', 'add_edge', 'update', 'delete'], required: true },
|
|
59
|
-
id: { type: 'string'
|
|
58
|
+
type: { type: 'string', enum: ['add_node', 'add_edge', 'update', 'delete', 'add_page', 'delete_page', 'rename_page'], required: true },
|
|
59
|
+
id: { type: 'string' }, label: { type: 'string' }, name: { type: 'string' }, page: { oneOf: [{ type: 'string' }, { type: 'number' }] },
|
|
60
|
+
style: { type: 'string' }, stylePatch: { type: 'object', additionalProperties: true }, parent: { type: 'string' },
|
|
60
61
|
source: { type: 'string' }, target: { type: 'string' },
|
|
61
62
|
x: { type: 'number' }, y: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' },
|
|
62
63
|
},
|
|
@@ -100,7 +101,7 @@ async function serveCanvasApi(ctx, req, res) {
|
|
|
100
101
|
try {
|
|
101
102
|
const pathname = new URL(req.url || '/', 'http://dsh.internal').pathname
|
|
102
103
|
const method = pathname.slice('/dsh-drawio/api/'.length)
|
|
103
|
-
if (!['read', 'write', 'save-image'].includes(method)) return respondJson(res, 404, { ok: false, error: { message: 'Unknown Draw.io operation.' } })
|
|
104
|
+
if (!['stat', 'read', 'write', 'save-image'].includes(method)) return respondJson(res, 404, { ok: false, error: { message: 'Unknown Draw.io operation.' } })
|
|
104
105
|
const payload = await readJsonBody(req)
|
|
105
106
|
|
|
106
107
|
if (method === 'save-image') {
|
|
@@ -131,12 +132,16 @@ async function serveCanvasApi(ctx, req, res) {
|
|
|
131
132
|
const info = await ctx.fs.stat(target)
|
|
132
133
|
if (!info || info.type !== 'file') throw new Error('Draw.io file was not found.')
|
|
133
134
|
if (Number(info.size) > MAX_DIAGRAM_BYTES) throw new Error('Draw.io file is too large to open safely.')
|
|
134
|
-
if (method === '
|
|
135
|
+
if (method === 'stat') return respondJson(res, 200, { ok: true, value: { version: info.version, size: Number(info.size), mtime: info.mtime } })
|
|
136
|
+
if (method === 'read') return respondJson(res, 200, { ok: true, value: { content: await ctx.fs.readText(target), version: info.version } })
|
|
135
137
|
if (typeof payload.content !== 'string' || Buffer.byteLength(payload.content) > MAX_DIAGRAM_BYTES) throw new Error('Draw.io content is too large to save safely.')
|
|
138
|
+
if (payload.version !== undefined && info.version !== undefined && String(payload.version) !== String(info.version)) {
|
|
139
|
+
return respondJson(res, 409, { ok: false, conflict: true, error: { message: 'File has been modified by an external process or agent.' } })
|
|
140
|
+
}
|
|
136
141
|
const xml = normalizeDrawio(payload.content)
|
|
137
142
|
const sandboxPolicy = { mode: 'workspace-write', workspaceRoot: payload.cwd }
|
|
138
|
-
await ctx.fs.writeText(target, xml, { kind: 'replaceIfVersion', version: info.version }, undefined, sandboxPolicy)
|
|
139
|
-
return respondJson(res, 200, { ok: true, value: { ok: true } })
|
|
143
|
+
const outcome = await ctx.fs.writeText(target, xml, { kind: 'replaceIfVersion', version: payload.version ?? info.version }, undefined, sandboxPolicy)
|
|
144
|
+
return respondJson(res, 200, { ok: true, value: { ok: true, version: outcome?.version ?? info.version } })
|
|
140
145
|
} catch (error) {
|
|
141
146
|
return respondJson(res, 400, { ok: false, error: { message: String(error?.message || error).slice(0, 500) } })
|
|
142
147
|
}
|
package/src/xml.js
CHANGED
|
@@ -10,18 +10,42 @@ export function inspectDrawio(source, includeXml = false) {
|
|
|
10
10
|
const pages = pageNodes(document).map((page, index) => {
|
|
11
11
|
const model = modelForPage(page)
|
|
12
12
|
const cells = Array.from(model.getElementsByTagName('mxCell'))
|
|
13
|
+
const nodeCells = cells.filter(cell => cell.getAttribute('vertex') === '1')
|
|
14
|
+
const edgeCells = cells.filter(cell => cell.getAttribute('edge') === '1')
|
|
15
|
+
const childMap = new Map()
|
|
16
|
+
for (const cell of nodeCells) {
|
|
17
|
+
childMap.set(cell.getAttribute('id'), [])
|
|
18
|
+
}
|
|
19
|
+
for (const cell of nodeCells) {
|
|
20
|
+
const parentId = cell.getAttribute('parent')
|
|
21
|
+
if (parentId && childMap.has(parentId)) {
|
|
22
|
+
childMap.get(parentId).push(cell.getAttribute('id'))
|
|
23
|
+
}
|
|
24
|
+
}
|
|
13
25
|
return {
|
|
14
26
|
index,
|
|
15
27
|
id: page.getAttribute('id') || `page-${index + 1}`,
|
|
16
28
|
name: page.getAttribute('name') || `Page-${index + 1}`,
|
|
17
|
-
nodes:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
nodes: nodeCells.map(cell => {
|
|
30
|
+
const id = cell.getAttribute('id')
|
|
31
|
+
const rawValue = cell.getAttribute('value') || ''
|
|
32
|
+
return {
|
|
33
|
+
...cellSummary(cell),
|
|
34
|
+
plainText: stripHtml(rawValue),
|
|
35
|
+
children: childMap.get(id) || [],
|
|
36
|
+
}
|
|
37
|
+
}),
|
|
38
|
+
edges: edgeCells.map(cell => {
|
|
39
|
+
const rawValue = cell.getAttribute('value') || ''
|
|
40
|
+
return {
|
|
41
|
+
id: cell.getAttribute('id'),
|
|
42
|
+
label: rawValue,
|
|
43
|
+
plainText: stripHtml(rawValue),
|
|
44
|
+
source: cell.getAttribute('source') || '',
|
|
45
|
+
target: cell.getAttribute('target') || '',
|
|
46
|
+
style: cell.getAttribute('style') || '',
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
25
49
|
}
|
|
26
50
|
})
|
|
27
51
|
return { pages, ...(includeXml ? { xml: serializer.serializeToString(document) } : {}) }
|
|
@@ -29,14 +53,51 @@ export function inspectDrawio(source, includeXml = false) {
|
|
|
29
53
|
|
|
30
54
|
export function editDrawio(source, operations, pageSelector) {
|
|
31
55
|
const document = parseDrawio(source || EMPTY_DRAWIO)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
56
|
+
let pages = pageNodes(document)
|
|
57
|
+
let currentPage = selectPage(pages, pageSelector)
|
|
58
|
+
let model = modelForPage(currentPage)
|
|
59
|
+
let root = model.getElementsByTagName('root')[0]
|
|
36
60
|
if (!root) throw new Error('The selected page has no mxGraphModel/root element.')
|
|
37
61
|
|
|
38
|
-
for (const operation of operations)
|
|
39
|
-
|
|
62
|
+
for (const operation of operations) {
|
|
63
|
+
if (!operation || typeof operation !== 'object') throw new Error('Every operation must be an object.')
|
|
64
|
+
const { type } = operation
|
|
65
|
+
if (type === 'add_page') {
|
|
66
|
+
const newPageId = operation.id || `page-${Date.now().toString(36)}`
|
|
67
|
+
const newPageName = operation.name || `Page-${pages.length + 1}`
|
|
68
|
+
const newDiagram = document.createElement('diagram')
|
|
69
|
+
newDiagram.setAttribute('id', newPageId)
|
|
70
|
+
newDiagram.setAttribute('name', newPageName)
|
|
71
|
+
const templateDoc = parseDrawio(EMPTY_DRAWIO)
|
|
72
|
+
const templateModel = modelForPage(pageNodes(templateDoc)[0])
|
|
73
|
+
newDiagram.appendChild(document.importNode(templateModel, true))
|
|
74
|
+
document.documentElement.appendChild(newDiagram)
|
|
75
|
+
pages = pageNodes(document)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (type === 'delete_page') {
|
|
79
|
+
if (pages.length <= 1) throw new Error('Cannot delete the only remaining diagram page.')
|
|
80
|
+
const targetPage = selectPage(pages, operation.page)
|
|
81
|
+
document.documentElement.removeChild(targetPage)
|
|
82
|
+
pages = pageNodes(document)
|
|
83
|
+
if (currentPage === targetPage) {
|
|
84
|
+
currentPage = pages[0]
|
|
85
|
+
model = modelForPage(currentPage)
|
|
86
|
+
root = model.getElementsByTagName('root')[0]
|
|
87
|
+
}
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (type === 'rename_page') {
|
|
91
|
+
const targetPage = operation.page !== undefined ? selectPage(pages, operation.page) : currentPage
|
|
92
|
+
const newName = required(operation.name, 'rename_page.name')
|
|
93
|
+
targetPage.setAttribute('name', newName)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
applyOperation(document, root, operation)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
replacePageModel(document, currentPage, model)
|
|
40
101
|
document.documentElement.setAttribute('compressed', 'false')
|
|
41
102
|
document.documentElement.setAttribute('modified', new Date().toISOString())
|
|
42
103
|
return serializer.serializeToString(document)
|
|
@@ -49,6 +110,46 @@ export function normalizeDrawio(source) {
|
|
|
49
110
|
return serializer.serializeToString(document)
|
|
50
111
|
}
|
|
51
112
|
|
|
113
|
+
export function stripHtml(input) {
|
|
114
|
+
if (!input || typeof input !== 'string') return ''
|
|
115
|
+
return input
|
|
116
|
+
.replace(/<[^>]+>/g, ' ')
|
|
117
|
+
.replace(/ /g, ' ')
|
|
118
|
+
.replace(/&/g, '&')
|
|
119
|
+
.replace(/</g, '<')
|
|
120
|
+
.replace(/>/g, '>')
|
|
121
|
+
.replace(/"/g, '"')
|
|
122
|
+
.replace(/'/g, "'")
|
|
123
|
+
.replace(/\s+/g, ' ')
|
|
124
|
+
.trim()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function patchStyle(currentStyle = '', patch = {}) {
|
|
128
|
+
const parts = currentStyle.split(';').map(s => s.trim()).filter(Boolean)
|
|
129
|
+
const map = new Map()
|
|
130
|
+
const bareKeys = []
|
|
131
|
+
for (const part of parts) {
|
|
132
|
+
const eqIdx = part.indexOf('=')
|
|
133
|
+
if (eqIdx >= 0) {
|
|
134
|
+
map.set(part.slice(0, eqIdx), part.slice(eqIdx + 1))
|
|
135
|
+
} else {
|
|
136
|
+
bareKeys.push(part)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const [key, val] of Object.entries(patch)) {
|
|
140
|
+
if (val === null || val === undefined || val === '') {
|
|
141
|
+
map.delete(key)
|
|
142
|
+
} else {
|
|
143
|
+
map.set(key, String(val))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const serialized = [
|
|
147
|
+
...bareKeys,
|
|
148
|
+
...Array.from(map.entries()).map(([k, v]) => `${k}=${v}`),
|
|
149
|
+
].join(';')
|
|
150
|
+
return serialized ? `${serialized};` : ''
|
|
151
|
+
}
|
|
152
|
+
|
|
52
153
|
function applyOperation(document, root, operation) {
|
|
53
154
|
if (!operation || typeof operation !== 'object') throw new Error('Every operation must be an object.')
|
|
54
155
|
const type = operation.type
|
|
@@ -97,6 +198,10 @@ function applyOperation(document, root, operation) {
|
|
|
97
198
|
const cell = assertCell(root, required(operation.id, 'update.id'))
|
|
98
199
|
if (operation.label !== undefined) cell.setAttribute('value', stringValue(operation.label))
|
|
99
200
|
if (operation.style !== undefined) cell.setAttribute('style', stringValue(operation.style))
|
|
201
|
+
if (operation.stylePatch && typeof operation.stylePatch === 'object') {
|
|
202
|
+
const current = cell.getAttribute('style') || ''
|
|
203
|
+
cell.setAttribute('style', patchStyle(current, operation.stylePatch))
|
|
204
|
+
}
|
|
100
205
|
if (operation.parent !== undefined) cell.setAttribute('parent', stringValue(operation.parent))
|
|
101
206
|
const geometryKeys = ['x', 'y', 'width', 'height']
|
|
102
207
|
if (geometryKeys.some(key => operation[key] !== undefined)) {
|