@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 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((content) => {
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 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=${dark ? "1" : "0"}`, [dark]);
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
- setSaveState("error");
134
- setError(messageOf(reason));
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
- }, [content, path]);
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
- if (pending.format === "svg") {
231
- const svgText = data.startsWith("data:image/svg+xml") ? decodeURIComponent(data.split(",")[1] || "") : data;
232
- await navigator.clipboard.writeText(svgText);
233
- showToast("\u5DF2\u590D\u5236 SVG \u4EE3\u7801\u5230\u526A\u8D34\u677F");
234
- } else {
235
- const res = await fetch(data);
236
- const blob = await res.blob();
237
- await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
238
- showToast("\u5DF2\u590D\u5236\u56FE\u7247\u5230\u526A\u8D34\u677F");
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 xml = await fsRead(scope, path);
331
+ const stat = await fsStat(scope, path);
298
332
  if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return;
299
- if (xml === latestFile.current || xml === latestEditor.current) return;
300
- latestFile.current = xml;
301
- latestEditor.current = xml;
302
- post({ action: "load", xml, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 });
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)("button", { type: "button", title: error, onClick: () => setError(null), style: styles.error, children: "\u4FDD\u5B58\u5931\u8D25 \xB7 \u70B9\u51FB\u5173\u95ED" }) : null
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
- const value = await call("read", scope, { path }, false, signal);
469
- return value.content;
524
+ return call("read", scope, { path }, false, signal);
470
525
  }
471
- async function fsWrite(scope, path, content, keepalive = false) {
472
- await call("write", scope, { path, content }, keepalive);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@civilization/dsh-drawio",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Local draw.io canvas and model tools for DeepSeek Harness",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,53 +1,80 @@
1
1
  ---
2
2
  name: drawio-operation
3
- description: Read, inspect, create, and semantically edit Draw.io diagrams and architecture flows, collaborate with the native offline canvas, and guide export workflows.
3
+ description: 读取、检查、创建和语义化编辑 Draw.io 架构图与流程图,协同原生离线画板,并指导导出与排版工作流。
4
4
  ---
5
5
 
6
- # Draw.io operation
7
-
8
- The `dsh-drawio` plugin integrates a 100% local, offline Draw.io diagram editor into DeepSeek Harness (DSH) right sidebar and equips the model with structured diagram editing tools.
9
-
10
- When the user asks about Draw.io capabilities or diagram tools, describe the available tools and features directly without scanning or probing the workspace.
11
-
12
- ## Core Model Tools
13
-
14
- 1. **`drawio_inspect`**:
15
- - Reads an existing `.drawio` file into structured pages, nodes, edges, labels, and geometry bounds.
16
- - Always call this before modifying an existing diagram to understand existing cell IDs, hierarchy, and connection points.
17
- - Set `include_xml: true` only when raw XML attributes or specific unparsed mxCell metadata are needed.
18
-
19
- 2. **`drawio_edit`**:
20
- - Applies targeted semantic operations to an existing `.drawio` file.
21
- - Supported batch operations:
22
- - `add_node`: Add a new vertex with `id`, `label`, `style`, `x`, `y`, `width`, `height`.
23
- - `add_edge`: Connect vertices with `id`, `source`, `target`, `label`, and routing `style`.
24
- - `update`: Modify existing node or edge properties without disturbing unchanged geometry.
25
- - `delete`: Safely remove a cell and automatically cascade clean-up of connected edges.
26
- - Maintain stable, unique, descriptive cell IDs (e.g. `client`, `api_gateway`, `db_cluster`).
27
-
28
- 3. **`drawio_write`**:
29
- - Validates and writes a complete Draw.io `mxfile` or `mxGraphModel` XML document.
30
- - Automatically normalizes the XML to uncompressed, human-readable format for reliable Git diffs and future AI edits.
31
- - Prefer `drawio_write` when creating a brand-new diagram from scratch, and prefer `drawio_edit` for incremental revisions.
32
-
33
- ## Canvas & Offline Export Capabilities
34
-
35
- - **Interactive Right-Sidebar Canvas**:
36
- - Double-clicking any `.drawio` file in the DSH workspace opens the full-featured Draw.io canvas.
37
- - Changes made by the model via `drawio_edit` or `drawio_write` are polled and synchronized into the canvas automatically without overwriting active user dragging.
38
- - **Pure Local Image Export**:
39
- - The canvas includes a built-in offline export engine in the top-right toolbar:
40
- - **PNG (2x Ultra HD)**: High-resolution rasterization with optional transparent background.
41
- - **SVG (Vector)**: Infinite resolution vector diagram.
42
- - **XML-PNG**: Dual-mode image embedding editable diagram XML metadata.
43
- - Supports saving directly back into the current workspace (e.g. `architecture.png`), copying directly to the system clipboard, or downloading to local disk.
44
-
45
- ## Recommended Workflows
46
-
47
- - **Creating a new diagram**:
48
- - Propose clear architecture/flowchart structure.
49
- - Call `drawio_write` with a clean `mxfile` XML document at a workspace path ending in `.drawio`.
50
- - Inform the user that the diagram is immediately viewable and editable in the right sidebar tab and can be exported to PNG/SVG from the canvas toolbar.
51
- - **Updating an existing diagram**:
52
- - Call `drawio_inspect` first.
53
- - Formulate precise `operations` and call `drawio_edit`.
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
+ - 支持一键**保存到工作区**(自动生成同名图片)、**写入系统剪贴板**(便于直接粘贴到文档或聊天窗口)或**下载到本地磁盘**。
@@ -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(content => { if (!controller.signal.aborted) setFile({ key, content }) })
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 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=${dark ? '1' : '0'}`, [dark])
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
- setSaveState('error')
113
- setError(messageOf(reason))
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
- }, [content, path])
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
- if (pending.format === 'svg') {
219
- const svgText = data.startsWith('data:image/svg+xml')
220
- ? decodeURIComponent(data.split(',')[1] || '')
221
- : data
222
- await navigator.clipboard.writeText(svgText)
223
- showToast('已复制 SVG 代码到剪贴板')
224
- } else {
225
- const res = await fetch(data)
226
- const blob = await res.blob()
227
- await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
228
- showToast('已复制图片到剪贴板')
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
- const xml = await fsRead(scope, path)
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 (xml === latestFile.current || xml === latestEditor.current) return
336
+ if (res.content === latestFile.current || res.content === latestEditor.current) {
337
+ latestVersion.current = res.version
338
+ return
339
+ }
296
340
 
297
- latestFile.current = xml
298
- latestEditor.current = xml
299
- post({ action: 'load', xml, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
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 ? <button type="button" title={error} onClick={() => setError(null)} style={styles.error}>保存失败 · 点击关闭</button> : null}
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 fsRead(scope: SessionScope, path: string, signal?: AbortSignal): Promise<string> {
464
- const value = await call<{ content: string }>('read', scope, { path }, false, signal)
465
- return value.content
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<void> {
469
- await call('write', scope, { path, content }, keepalive)
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 are add_node, add_edge, update and delete. Cell ids must be stable and unique. Inspect the file first when modifying an existing diagram.',
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', required: true }, label: { type: 'string' }, style: { type: 'string' }, parent: { 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 === 'read') return respondJson(res, 200, { ok: true, value: { content: await ctx.fs.readText(target) } })
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: cells.filter(cell => cell.getAttribute('vertex') === '1').map(cell => cellSummary(cell)),
18
- edges: cells.filter(cell => cell.getAttribute('edge') === '1').map(cell => ({
19
- id: cell.getAttribute('id'),
20
- label: cell.getAttribute('value') || '',
21
- source: cell.getAttribute('source') || '',
22
- target: cell.getAttribute('target') || '',
23
- style: cell.getAttribute('style') || '',
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
- const pages = pageNodes(document)
33
- const page = selectPage(pages, pageSelector)
34
- const model = modelForPage(page)
35
- const root = model.getElementsByTagName('root')[0]
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) applyOperation(document, root, operation)
39
- replacePageModel(document, page, model)
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(/&nbsp;/g, ' ')
118
+ .replace(/&amp;/g, '&')
119
+ .replace(/&lt;/g, '<')
120
+ .replace(/&gt;/g, '>')
121
+ .replace(/&quot;/g, '"')
122
+ .replace(/&#39;/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)) {