@civilization/dsh-drawio 0.2.1 → 0.3.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
@@ -10,9 +10,11 @@
10
10
  - 编辑器资源由 DSH Host 从本机提供,不连接 diagrams.net。
11
11
  - 用户保存和 Agent 工具修改的是同一份 XML 文件。
12
12
  - 文件读取、写入、路径限制和审批继续经过 DSH 服务。
13
+ - 提供 `drawio-operation` Skill 和 3 个模型专属工具。
13
14
 
14
- ## Agent 工具
15
+ ## Agent 工具与 Skill
15
16
 
17
+ - `drawio-operation` Skill:向模型注入 Draw.io 画板整体架构、离线导出机制与最佳绘图工作流。
16
18
  - `drawio_inspect`:把画板读取为页面、节点、连线和几何信息。
17
19
  - `drawio_edit`:批量新增、更新、删除节点和连线。
18
20
  - `drawio_write`:校验并写入完整的 Draw.io XML。
@@ -20,6 +22,19 @@
20
22
  工具统一保存为未压缩 XML,便于 Agent 继续修改,也便于 Git 查看差异。画板会
21
23
  自动保存,并轮询同步 Agent 对同一文件的修改。
22
24
 
25
+ ## 本地离线图片导出 (Export)
26
+
27
+ 由于 Draw.io 原版导出功能依赖云端渲染服务(`export.diagrams.net`),在离线及局域网环境下无法正常工作。本插件基于 Draw.io 离线内核的 Embed 消息通道与 Canvas / SVG 渲染协议,实现了**100% 纯本地离线图片导出引擎**:
28
+
29
+ - **支持格式**:
30
+ - **PNG (超清 2x)**:采用高倍率抗锯齿光栅化渲染,支持透明背景切换,适合插入文档和交流汇报;
31
+ - **SVG (矢量图)**:提取无损矢量图形,任意放大不失真;
32
+ - **XML-PNG (可编辑图)**:在 PNG 图像中内嵌图表 XML 原数据,既能作为常规图片展示,又可随时拖回 Draw.io 继续二次编辑。
33
+ - **三大输出方式**:
34
+ - **保存到工作区**:在当前画板文件同级目录下自动生成同名图片文件(如 `arch.drawio` -> `arch.png`),自动触发 DSH 文件系统感知;
35
+ - **复制到剪贴板**:一键将 PNG 图片写入操作系统剪贴板(`navigator.clipboard`),方便在 Markdown、PRD 或聊天工具中直接 `Ctrl + V` 粘贴;
36
+ - **下载图片文件**:触发浏览器原生下载弹窗保存到本地磁盘。
37
+
23
38
  ## 开发验证
24
39
 
25
40
  ```powershell
@@ -38,4 +53,4 @@ dsh web --port 3082 --no-open
38
53
  已在 DSH 0.1.5-rc.2 上验证构建、XML 操作、本地静态资源加载和官方右侧栏注册。
39
54
 
40
55
  当前内置的精简 Draw.io runtime 固定为 31.4.5。包含完整满血版内置图库(通用、流程图、UML、ER、BPMN、网络、Kubernetes、AWS、GCP、Cisco、电子、平面图等全部分类均可自由开启)以及 PlantUML 离线渲染模块;
41
- 云盘、在线图库、模板库、VSDX/Gliffy 导入和服务端导出未包含。
56
+ 云盘、在线图库、模板库、VSDX/Gliffy 导入未包含;已全面支持纯本地离线 Canvas + SVG 导出引擎。
package/lib/client.js CHANGED
@@ -23,7 +23,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
23
23
  var index_exports = {};
24
24
  __export(index_exports, {
25
25
  apply: () => apply,
26
- inject: () => inject
26
+ inject: () => inject,
27
+ parseDrawioAddress: () => parseDrawioAddress
27
28
  });
28
29
  module.exports = __toCommonJS(index_exports);
29
30
  var import_react = require("react");
@@ -51,34 +52,55 @@ function apply(ctx) {
51
52
  }
52
53
  function OfficialDrawioTabTitle({ useTabInfo }) {
53
54
  const { tab } = useTabInfo();
55
+ const title = tab?.title || (tab?.navigation?.address ? fileName(parseDrawioAddress(tab.navigation.address)?.path ?? "Draw.io") : "Draw.io");
54
56
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
55
57
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CanvasIcon, { size: 15 }),
56
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: tab.title })
58
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: title })
57
59
  ] });
58
60
  }
59
61
  function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
60
62
  const { tab } = useTabInfo();
61
- const cwd = useSessions((sessions) => sessions?.byId?.[sessionId]?.cwd);
62
- const resource = (0, import_react.useMemo)(() => parseDrawioAddress(tab.navigation.address), [tab.navigation.address]);
63
+ const rawAddress = tab?.navigation?.address;
64
+ const resource = (0, import_react.useMemo)(() => parseDrawioAddress(rawAddress, sessionId), [rawAddress, sessionId]);
65
+ const activeCwd = useSessions((sessions) => sessions?.byId?.[sessionId]?.cwd);
66
+ const resourceCwd = useSessions((sessions) => resource?.sessionId ? sessions?.byId?.[resource.sessionId]?.cwd : void 0);
67
+ const cwd = activeCwd || resourceCwd;
68
+ const effectiveSessionId = sessionId || resource?.sessionId || "";
63
69
  const [file, setFile] = (0, import_react.useState)(null);
64
70
  const [error, setError] = (0, import_react.useState)(null);
65
- const key = `${sessionId}\0${cwd ?? ""}\0${resource?.path ?? ""}`;
71
+ const key = `${effectiveSessionId}\0${cwd ?? ""}\0${resource?.path ?? ""}`;
66
72
  (0, import_react.useEffect)(() => {
67
- if (!resource || resource.sessionId !== sessionId || !cwd) return;
73
+ if (!resource?.path || !effectiveSessionId || !cwd) return;
68
74
  const controller = new AbortController();
69
75
  setError(null);
70
- void fsRead({ sessionId, cwd }, resource.path, controller.signal).then((content) => {
76
+ void fsRead({ sessionId: effectiveSessionId, cwd }, resource.path, controller.signal).then((content) => {
71
77
  if (!controller.signal.aborted) setFile({ key, content });
72
78
  }).catch((reason) => {
73
79
  if (!controller.signal.aborted) setError(messageOf(reason));
74
80
  });
75
81
  return () => controller.abort();
76
- }, [key, resource?.sessionId, resource?.path, sessionId, cwd]);
77
- if (!resource || resource.sessionId !== sessionId) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F", detail: "\u6587\u4EF6\u5730\u5740\u4E0D\u662F\u5F53\u524D\u4F1A\u8BDD\u4E2D\u7684 Draw.io \u6587\u4EF6\u3002" });
78
- if (!cwd) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F", detail: "\u5F53\u524D\u4F1A\u8BDD\u6CA1\u6709\u53EF\u7528\u7684\u5DE5\u4F5C\u533A\u3002" });
82
+ }, [key, resource?.path, effectiveSessionId, cwd]);
83
+ if (!resource || !resource.path) {
84
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
85
+ CenteredMessage,
86
+ {
87
+ title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F",
88
+ detail: rawAddress ? `\u65E0\u6CD5\u89E3\u6790 Draw.io \u6587\u4EF6\u5730\u5740\uFF1A${rawAddress}` : "\u65E0\u6CD5\u83B7\u53D6\u6587\u4EF6\u5BFC\u822A\u5730\u5740\u3002"
89
+ }
90
+ );
91
+ }
92
+ if (!cwd) {
93
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
94
+ CenteredMessage,
95
+ {
96
+ title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F",
97
+ detail: "\u5F53\u524D\u4F1A\u8BDD\u6CA1\u6709\u5173\u8054\u53EF\u7528\u7684\u5DE5\u4F5C\u533A\uFF0C\u8BF7\u5148\u5728 DSH \u4E2D\u6253\u5F00\u6216\u9009\u62E9\u4E00\u4E2A\u5DE5\u4F5C\u533A\u6587\u4EF6\u5939\u3002"
98
+ }
99
+ );
100
+ }
79
101
  if (error) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u753B\u677F\u8F7D\u5165\u5931\u8D25", detail: error });
80
102
  if (file?.key !== key) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u6B63\u5728\u8F7D\u5165\u753B\u677F", detail: fileName(resource.path) });
81
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawioCanvas, { content: file.content, path: resource.path, scope: { sessionId, cwd } });
103
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawioCanvas, { content: file.content, path: resource.path, scope: { sessionId: effectiveSessionId, cwd } });
82
104
  }
83
105
  function DrawioCanvas({ content, truncated, path, scope }) {
84
106
  const frame = (0, import_react.useRef)(null);
@@ -162,6 +184,76 @@ function DrawioCanvas({ content, truncated, path, scope }) {
162
184
  const timer = window.setInterval(handleFrameAttach, 800);
163
185
  return () => window.clearInterval(timer);
164
186
  }, []);
187
+ const [menuOpen, setMenuOpen] = (0, import_react.useState)(false);
188
+ const [exportFormat, setExportFormat] = (0, import_react.useState)("png");
189
+ const [isTransparent, setIsTransparent] = (0, import_react.useState)(false);
190
+ const [isExporting, setIsExporting] = (0, import_react.useState)(false);
191
+ const [toast, setToast] = (0, import_react.useState)(null);
192
+ const pendingExport = (0, import_react.useRef)(null);
193
+ const menuRef = (0, import_react.useRef)(null);
194
+ const showToast = (0, import_react.useCallback)((msg) => {
195
+ setToast(msg);
196
+ window.setTimeout(() => setToast((current) => current === msg ? null : current), 3200);
197
+ }, []);
198
+ const startExport = (0, import_react.useCallback)((target) => {
199
+ if (!ready || isExporting) return;
200
+ const ext = exportFormat === "svg" ? ".svg" : exportFormat === "xmlpng" ? ".drawio.png" : ".png";
201
+ const base = path.replace(/\.drawio$/i, "");
202
+ const targetPath = `${base}${ext}`;
203
+ pendingExport.current = {
204
+ format: exportFormat,
205
+ target,
206
+ targetPath
207
+ };
208
+ setIsExporting(true);
209
+ setMenuOpen(false);
210
+ post({
211
+ action: "export",
212
+ format: exportFormat,
213
+ scale: exportFormat === "svg" ? 1 : 2,
214
+ transparent: isTransparent,
215
+ border: 10,
216
+ spin: "\u6B63\u5728\u6E32\u67D3\u5BFC\u51FA\u56FE\u7247..."
217
+ });
218
+ }, [ready, isExporting, exportFormat, isTransparent, path, post]);
219
+ const handleExportResult = (0, import_react.useCallback)(async (pending, data) => {
220
+ try {
221
+ if (pending.target === "download") {
222
+ const link = document.createElement("a");
223
+ link.download = fileName(pending.targetPath);
224
+ link.href = data;
225
+ document.body.appendChild(link);
226
+ link.click();
227
+ document.body.removeChild(link);
228
+ showToast(`\u5DF2\u5F00\u59CB\u4E0B\u8F7D\uFF1A${fileName(pending.targetPath)}`);
229
+ } 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");
239
+ }
240
+ } else if (pending.target === "workspace") {
241
+ const result = await fsSaveImage(scope, pending.targetPath, data);
242
+ showToast(`\u5DF2\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A\uFF1A${fileName(result.path)} (${Math.round(result.bytes / 1024)} KB)`);
243
+ }
244
+ } catch (reason) {
245
+ setError(`\u5BFC\u51FA\u5931\u8D25\uFF1A${messageOf(reason)}`);
246
+ }
247
+ }, [scope, showToast]);
248
+ (0, import_react.useEffect)(() => {
249
+ const handleOutsideClick = (e) => {
250
+ if (menuOpen && menuRef.current && !menuRef.current.contains(e.target)) {
251
+ setMenuOpen(false);
252
+ }
253
+ };
254
+ window.addEventListener("pointerdown", handleOutsideClick);
255
+ return () => window.removeEventListener("pointerdown", handleOutsideClick);
256
+ }, [menuOpen]);
165
257
  (0, import_react.useEffect)(() => {
166
258
  setReady(false);
167
259
  setSaveState("loading");
@@ -181,11 +273,18 @@ function DrawioCanvas({ content, truncated, path, scope }) {
181
273
  setSaveState("saved");
182
274
  } else if ((message.event === "autosave" || message.event === "save") && typeof message.xml === "string") {
183
275
  scheduleSave(message.xml, message.event === "save");
276
+ } else if (message.event === "export" && typeof message.data === "string") {
277
+ const pending = pendingExport.current;
278
+ pendingExport.current = null;
279
+ setIsExporting(false);
280
+ if (pending) {
281
+ void handleExportResult(pending, message.data);
282
+ }
184
283
  }
185
284
  };
186
285
  window.addEventListener("message", receive);
187
286
  return () => window.removeEventListener("message", receive);
188
- }, [src, dark, path, post, scheduleSave]);
287
+ }, [src, dark, path, post, scheduleSave, handleExportResult]);
189
288
  (0, import_react.useEffect)(() => {
190
289
  if (!ready || truncated) return;
191
290
  let cancelled = false;
@@ -222,8 +321,104 @@ function DrawioCanvas({ content, truncated, path, scope }) {
222
321
  if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, true);
223
322
  }, [scope.sessionId, scope.cwd, path]);
224
323
  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
+ const targetExt = exportFormat === "svg" ? ".svg" : exportFormat === "xmlpng" ? ".drawio.png" : ".png";
325
+ const previewName = `${fileName(path).replace(/\.drawio$/i, "")}${targetExt}`;
225
326
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.root, children: [
226
327
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("iframe", { ref: frame, title: `${fileName(path)} Draw.io \u753B\u677F`, src, style: styles.frame }),
328
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { ref: menuRef, style: styles.toolbar, children: [
329
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
330
+ "button",
331
+ {
332
+ type: "button",
333
+ onClick: () => setMenuOpen((open) => !open),
334
+ disabled: !ready || isExporting,
335
+ style: { ...styles.exportTrigger, ...isExporting ? styles.triggerActive : {} },
336
+ title: "\u5BFC\u51FA\u5F53\u524D\u753B\u677F\u4E3A\u56FE\u7247",
337
+ children: [
338
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CameraIcon, { size: 14 }),
339
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: isExporting ? "\u6B63\u5728\u5BFC\u51FA..." : "\u5BFC\u51FA\u56FE\u7247" }),
340
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: styles.arrowIcon, children: "\u25BE" })
341
+ ]
342
+ }
343
+ ),
344
+ menuOpen ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.exportMenu, children: [
345
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.menuHeader, children: [
346
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: styles.menuTitle, children: "\u5BFC\u51FA\u753B\u677F\u56FE\u7247" }),
347
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: styles.menuSub, children: previewName })
348
+ ] }),
349
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: styles.formatRow, children: ["png", "svg", "xmlpng"].map((fmt) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
350
+ "button",
351
+ {
352
+ type: "button",
353
+ onClick: () => setExportFormat(fmt),
354
+ style: {
355
+ ...styles.formatBtn,
356
+ ...exportFormat === fmt ? { ...styles.formatBtnActive, borderColor: accentColor } : {}
357
+ },
358
+ children: fmt === "png" ? "PNG (\u8D85\u6E05)" : fmt === "svg" ? "SVG (\u77E2\u91CF)" : "XML-PNG"
359
+ },
360
+ fmt
361
+ )) }),
362
+ exportFormat !== "svg" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { style: styles.checkRow, children: [
363
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
364
+ "input",
365
+ {
366
+ type: "checkbox",
367
+ checked: isTransparent,
368
+ onChange: (e) => setIsTransparent(e.target.checked),
369
+ style: styles.checkbox
370
+ }
371
+ ),
372
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u900F\u660E\u80CC\u666F" })
373
+ ] }) : null,
374
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.actionCol, children: [
375
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
376
+ "button",
377
+ {
378
+ type: "button",
379
+ onClick: () => startExport("workspace"),
380
+ style: { ...styles.actionBtn, ...styles.actionBtnPrimary, background: accentColor },
381
+ children: [
382
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaveIcon, { size: 13 }),
383
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
384
+ "\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A (",
385
+ targetExt,
386
+ ")"
387
+ ] })
388
+ ]
389
+ }
390
+ ),
391
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
392
+ "button",
393
+ {
394
+ type: "button",
395
+ onClick: () => startExport("clipboard"),
396
+ style: styles.actionBtn,
397
+ children: [
398
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CopyIcon, { size: 13 }),
399
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u590D\u5236\u5230\u526A\u8D34\u677F" })
400
+ ]
401
+ }
402
+ ),
403
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
404
+ "button",
405
+ {
406
+ type: "button",
407
+ onClick: () => startExport("download"),
408
+ style: styles.actionBtn,
409
+ children: [
410
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DownloadIcon, { size: 13 }),
411
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u4E0B\u8F7D\u56FE\u7247\u6587\u4EF6" })
412
+ ]
413
+ }
414
+ )
415
+ ] })
416
+ ] }) : null
417
+ ] }),
418
+ toast ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.toast, children: [
419
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CheckIcon, { size: 14 }),
420
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: toast })
421
+ ] }) : null,
227
422
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { role: "status", "aria-live": "polite", style: { ...styles.status, ...saveState === "error" ? styles.statusError : {} }, children: [
228
423
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...styles.dot, background: accentColor } }),
229
424
  statusText(saveState)
@@ -276,6 +471,9 @@ async function fsRead(scope, path, signal) {
276
471
  async function fsWrite(scope, path, content, keepalive = false) {
277
472
  await call("write", scope, { path, content }, keepalive);
278
473
  }
474
+ async function fsSaveImage(scope, path, data) {
475
+ return call("save-image", scope, { path, data });
476
+ }
279
477
  async function call(method, scope, extra, keepalive = false, signal) {
280
478
  const response = await fetch(`/dsh-drawio/api/${method}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: scope.sessionId, ...scope.cwd ? { cwd: scope.cwd } : {}, ...extra }), keepalive, signal });
281
479
  const envelope = await response.json().catch(() => null);
@@ -290,6 +488,34 @@ function CanvasIcon({ size = 16, className }) {
290
488
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M8.4 7.8l3.2 4.4M8.2 6.2h3.6M6.2 8.2v3.6", stroke: "currentColor", strokeLinecap: "round" })
291
489
  ] });
292
490
  }
491
+ function CameraIcon({ size = 16, className }) {
492
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { className, "aria-hidden": true, viewBox: "0 0 20 20", width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
493
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M4 6.5A1.5 1.5 0 0 1 5.5 5h1.8a1.5 1.5 0 0 0 1.2-.6l.5-.7A1.5 1.5 0 0 1 10.2 3h1.6a1.5 1.5 0 0 1 1.2.7l.5.7a1.5 1.5 0 0 0 1.2.6h1.8A1.5 1.5 0 0 1 18 6.5v9a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 2 15.5v-9z" }),
494
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: "10", cy: "11", r: "3.5" })
495
+ ] });
496
+ }
497
+ function SaveIcon({ size = 16, className }) {
498
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { className, "aria-hidden": true, viewBox: "0 0 20 20", width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
499
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M16 17H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h9.5l3.5 3.5V16a1 1 0 0 1-1 1z" }),
500
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M13 17v-6H7v6" }),
501
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M7 3v4h6" })
502
+ ] });
503
+ }
504
+ function CopyIcon({ size = 16, className }) {
505
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { className, "aria-hidden": true, viewBox: "0 0 20 20", width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
506
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", { x: "7", y: "7", width: "10", height: "10", rx: "1.5" }),
507
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M4 13H3.5A1.5 1.5 0 0 1 2 11.5v-8A1.5 1.5 0 0 1 3.5 2h8A1.5 1.5 0 0 1 13 3.5V4" })
508
+ ] });
509
+ }
510
+ function DownloadIcon({ size = 16, className }) {
511
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { className, "aria-hidden": true, viewBox: "0 0 20 20", width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
512
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M10 3v10m0 0l-3.5-3.5M10 13l3.5-3.5" }),
513
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M3 14v2a1.5 1.5 0 0 0 1.5 1.5h11a1.5 1.5 0 0 0 1.5-1.5v-2" })
514
+ ] });
515
+ }
516
+ function CheckIcon({ size = 16, className }) {
517
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { className, "aria-hidden": true, viewBox: "0 0 20 20", width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M4 10.5l4 4 8-9" }) });
518
+ }
293
519
  function CenteredMessage({ title, detail }) {
294
520
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.center, children: [
295
521
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: title }),
@@ -297,15 +523,37 @@ function CenteredMessage({ title, detail }) {
297
523
  ] });
298
524
  }
299
525
  var fileName = (path) => path.replace(/\\/g, "/").split("/").pop() || path;
300
- function parseDrawioAddress(address) {
301
- const prefix = "dsh-resource://file/session/";
302
- if (!address.startsWith(prefix)) return null;
526
+ function parseDrawioAddress(address, fallbackSessionId) {
527
+ if (!address || typeof address !== "string") return null;
303
528
  try {
304
- const parts = address.slice(prefix.length).split("/").map(decodeURIComponent);
305
- const sessionId = parts.shift() ?? "";
306
- const path = parts.join("/");
307
- if (!sessionId || !path || !/\.drawio$/i.test(path) || parts.some((part) => part.includes("/") || part.includes("\\") || part.includes("\0"))) return null;
308
- return { sessionId, path };
529
+ let clean = address.trim();
530
+ let parsedSessionId = "";
531
+ if (clean.startsWith("dsh-resource://file/session/")) {
532
+ const rest = clean.slice("dsh-resource://file/session/".length);
533
+ const slashIdx = rest.indexOf("/");
534
+ if (slashIdx >= 0) {
535
+ parsedSessionId = decodeURIComponent(rest.slice(0, slashIdx));
536
+ clean = rest.slice(slashIdx + 1);
537
+ } else {
538
+ clean = rest;
539
+ }
540
+ } else if (clean.startsWith("dsh-resource://file/")) {
541
+ clean = clean.slice("dsh-resource://file/".length);
542
+ } else if (clean.startsWith("file:///")) {
543
+ clean = clean.slice("file:///".length);
544
+ }
545
+ let decodedPath = clean;
546
+ try {
547
+ decodedPath = decodeURIComponent(clean);
548
+ } catch {
549
+ }
550
+ decodedPath = decodedPath.replace(/\\/g, "/").replace(/^\/+/, "");
551
+ if (!/\.drawio$/i.test(decodedPath)) return null;
552
+ if (decodedPath.includes("\0")) return null;
553
+ return {
554
+ sessionId: parsedSessionId || fallbackSessionId || "",
555
+ path: decodedPath
556
+ };
309
557
  } catch {
310
558
  return null;
311
559
  }
@@ -320,6 +568,23 @@ var accent = "var(--dsw-alias-brand-primary, #ed67ad)";
320
568
  var styles = {
321
569
  root: { position: "relative", width: "100%", height: "100%", minHeight: 0, overflow: "hidden", color: fg, background: layer },
322
570
  frame: { display: "block", width: "100%", height: "100%", border: 0, background: layer },
571
+ toolbar: { position: "absolute", right: 12, top: 12, zIndex: 10, display: "flex", flexDirection: "column", alignItems: "flex-end" },
572
+ exportTrigger: { display: "flex", alignItems: "center", gap: 6, padding: "5px 10px", border: `1px solid ${border}`, borderRadius: 6, color: fg, background: layer, boxShadow: "var(--dsw-alias-shadow-sm, 0 2px 8px rgba(0,0,0,.12))", fontSize: 12, fontWeight: 500, cursor: "pointer", backdropFilter: "blur(8px)", transition: "all 0.15s ease" },
573
+ triggerActive: { opacity: 0.75, cursor: "wait" },
574
+ arrowIcon: { fontSize: 10, opacity: 0.6, marginLeft: 2 },
575
+ exportMenu: { marginTop: 6, width: 220, padding: 12, border: `1px solid ${border}`, borderRadius: 8, background: layer, boxShadow: "var(--dsw-alias-shadow-md, 0 6px 20px rgba(0,0,0,.18))", backdropFilter: "blur(12px)", display: "flex", flexDirection: "column", gap: 10 },
576
+ menuHeader: { display: "flex", flexDirection: "column", gap: 2, paddingBottom: 6, borderBottom: `1px solid ${border}` },
577
+ menuTitle: { fontSize: 12, fontWeight: 600, color: fg },
578
+ menuSub: { fontSize: 10, color: muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
579
+ formatRow: { display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 4 },
580
+ formatBtn: { padding: "4px 2px", border: `1px solid ${border}`, borderRadius: 4, background: "transparent", color: muted, fontSize: 10.5, cursor: "pointer", textAlign: "center", transition: "all 0.1s ease" },
581
+ formatBtnActive: { color: fg, fontWeight: 600, background: "var(--dsw-alias-fill-quaternary, rgba(127,127,127,0.12))" },
582
+ checkRow: { display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: muted, cursor: "pointer", userSelect: "none" },
583
+ checkbox: { cursor: "pointer", margin: 0 },
584
+ actionCol: { display: "flex", flexDirection: "column", gap: 6, paddingTop: 4 },
585
+ actionBtn: { display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "6px 10px", border: `1px solid ${border}`, borderRadius: 5, background: "transparent", color: fg, fontSize: 11.5, cursor: "pointer", transition: "all 0.12s ease" },
586
+ actionBtnPrimary: { color: "#ffffff", border: "none", fontWeight: 500 },
587
+ toast: { position: "absolute", top: 12, left: "50%", transform: "translateX(-50%)", zIndex: 12, display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", border: `1px solid ${border}`, borderRadius: 20, background: layer, color: fg, boxShadow: "var(--dsw-alias-shadow-md, 0 4px 14px rgba(0,0,0,.16))", fontSize: 12, backdropFilter: "blur(10px)", pointerEvents: "none" },
323
588
  status: { position: "absolute", right: 10, bottom: 9, zIndex: 2, display: "flex", alignItems: "center", gap: 6, padding: "4px 8px", border: `1px solid ${border}`, borderRadius: 999, color: muted, background: layer, boxShadow: "var(--dsw-alias-shadow-sm, 0 2px 8px rgba(0,0,0,.12))", fontSize: 11, pointerEvents: "none" },
324
589
  statusError: { color: "var(--dsw-alias-danger, #d84f5f)" },
325
590
  dot: { width: 6, height: 6, borderRadius: 99, background: accent },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civilization/dsh-drawio",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Local draw.io canvas and model tools for DeepSeek Harness",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -14,6 +14,7 @@
14
14
  "src",
15
15
  "lib",
16
16
  "vendor",
17
+ "skills",
17
18
  "cordis.patch.yml",
18
19
  "README.md",
19
20
  "THIRD_PARTY_NOTICES.md",
@@ -0,0 +1,53 @@
1
+ ---
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.
4
+ ---
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`.
@@ -30,32 +30,53 @@ export function apply(ctx: Context): void {
30
30
 
31
31
  function OfficialDrawioTabTitle({ useTabInfo }: any): JSX.Element {
32
32
  const { tab } = useTabInfo()
33
- return <><CanvasIcon size={15} /><span>{tab.title}</span></>
33
+ const title = tab?.title || (tab?.navigation?.address ? fileName(parseDrawioAddress(tab.navigation.address)?.path ?? 'Draw.io') : 'Draw.io')
34
+ return <><CanvasIcon size={15} /><span>{title}</span></>
34
35
  }
35
36
 
36
37
  function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX.Element {
37
38
  const { tab } = useTabInfo()
38
- const cwd = useSessions((sessions: any) => sessions?.byId?.[sessionId]?.cwd) as string | undefined
39
- const resource = useMemo(() => parseDrawioAddress(tab.navigation.address), [tab.navigation.address])
39
+ const rawAddress = tab?.navigation?.address
40
+ const resource = useMemo(() => parseDrawioAddress(rawAddress, sessionId), [rawAddress, sessionId])
41
+
42
+ const activeCwd = useSessions((sessions: any) => sessions?.byId?.[sessionId]?.cwd) as string | undefined
43
+ const resourceCwd = useSessions((sessions: any) => resource?.sessionId ? sessions?.byId?.[resource.sessionId]?.cwd : undefined) as string | undefined
44
+ const cwd = activeCwd || resourceCwd
45
+ const effectiveSessionId = sessionId || resource?.sessionId || ''
46
+
40
47
  const [file, setFile] = useState<{ key: string; content: string } | null>(null)
41
48
  const [error, setError] = useState<string | null>(null)
42
- const key = `${sessionId}\0${cwd ?? ''}\0${resource?.path ?? ''}`
49
+ const key = `${effectiveSessionId}\0${cwd ?? ''}\0${resource?.path ?? ''}`
43
50
 
44
51
  useEffect(() => {
45
- if (!resource || resource.sessionId !== sessionId || !cwd) return
52
+ if (!resource?.path || !effectiveSessionId || !cwd) return
46
53
  const controller = new AbortController()
47
54
  setError(null)
48
- void fsRead({ sessionId, cwd }, resource.path, controller.signal)
55
+ void fsRead({ sessionId: effectiveSessionId, cwd }, resource.path, controller.signal)
49
56
  .then(content => { if (!controller.signal.aborted) setFile({ key, content }) })
50
57
  .catch(reason => { if (!controller.signal.aborted) setError(messageOf(reason)) })
51
58
  return () => controller.abort()
52
- }, [key, resource?.sessionId, resource?.path, sessionId, cwd])
59
+ }, [key, resource?.path, effectiveSessionId, cwd])
53
60
 
54
- if (!resource || resource.sessionId !== sessionId) return <CenteredMessage title="无法打开画板" detail="文件地址不是当前会话中的 Draw.io 文件。" />
55
- if (!cwd) return <CenteredMessage title="无法打开画板" detail="当前会话没有可用的工作区。" />
61
+ if (!resource || !resource.path) {
62
+ return (
63
+ <CenteredMessage
64
+ title="无法打开画板"
65
+ detail={rawAddress ? `无法解析 Draw.io 文件地址:${rawAddress}` : '无法获取文件导航地址。'}
66
+ />
67
+ )
68
+ }
69
+ if (!cwd) {
70
+ return (
71
+ <CenteredMessage
72
+ title="无法打开画板"
73
+ detail="当前会话没有关联可用的工作区,请先在 DSH 中打开或选择一个工作区文件夹。"
74
+ />
75
+ )
76
+ }
56
77
  if (error) return <CenteredMessage title="画板载入失败" detail={error} />
57
78
  if (file?.key !== key) return <CenteredMessage title="正在载入画板" detail={fileName(resource.path)} />
58
- return <DrawioCanvas content={file.content} path={resource.path} scope={{ sessionId, cwd }} />
79
+ return <DrawioCanvas content={file.content} path={resource.path} scope={{ sessionId: effectiveSessionId, cwd }} />
59
80
  }
60
81
 
61
82
  function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX.Element {
@@ -147,12 +168,90 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
147
168
  return () => window.clearInterval(timer)
148
169
  }, [])
149
170
 
171
+ const [menuOpen, setMenuOpen] = useState(false)
172
+ const [exportFormat, setExportFormat] = useState<'png' | 'svg' | 'xmlpng'>('png')
173
+ const [isTransparent, setIsTransparent] = useState(false)
174
+ const [isExporting, setIsExporting] = useState(false)
175
+ const [toast, setToast] = useState<string | null>(null)
176
+ const pendingExport = useRef<{ format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string } | null>(null)
177
+ const menuRef = useRef<HTMLDivElement>(null)
178
+
179
+ const showToast = useCallback((msg: string) => {
180
+ setToast(msg)
181
+ window.setTimeout(() => setToast(current => current === msg ? null : current), 3200)
182
+ }, [])
183
+
184
+ const startExport = useCallback((target: 'workspace' | 'download' | 'clipboard') => {
185
+ if (!ready || isExporting) return
186
+ const ext = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png'
187
+ const base = path.replace(/\.drawio$/i, '')
188
+ const targetPath = `${base}${ext}`
189
+
190
+ pendingExport.current = {
191
+ format: exportFormat,
192
+ target,
193
+ targetPath,
194
+ }
195
+ setIsExporting(true)
196
+ setMenuOpen(false)
197
+ post({
198
+ action: 'export',
199
+ format: exportFormat,
200
+ scale: exportFormat === 'svg' ? 1 : 2,
201
+ transparent: isTransparent,
202
+ border: 10,
203
+ spin: '正在渲染导出图片...',
204
+ })
205
+ }, [ready, isExporting, exportFormat, isTransparent, path, post])
206
+
207
+ const handleExportResult = useCallback(async (pending: { format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string }, data: string) => {
208
+ try {
209
+ if (pending.target === 'download') {
210
+ const link = document.createElement('a')
211
+ link.download = fileName(pending.targetPath)
212
+ link.href = data
213
+ document.body.appendChild(link)
214
+ link.click()
215
+ document.body.removeChild(link)
216
+ showToast(`已开始下载:${fileName(pending.targetPath)}`)
217
+ } 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('已复制图片到剪贴板')
229
+ }
230
+ } else if (pending.target === 'workspace') {
231
+ const result = await fsSaveImage(scope, pending.targetPath, data)
232
+ showToast(`已保存到工作区:${fileName(result.path)} (${Math.round(result.bytes / 1024)} KB)`)
233
+ }
234
+ } catch (reason) {
235
+ setError(`导出失败:${messageOf(reason)}`)
236
+ }
237
+ }, [scope, showToast])
238
+
239
+ useEffect(() => {
240
+ const handleOutsideClick = (e: MouseEvent) => {
241
+ if (menuOpen && menuRef.current && !menuRef.current.contains(e.target as Node)) {
242
+ setMenuOpen(false)
243
+ }
244
+ }
245
+ window.addEventListener('pointerdown', handleOutsideClick)
246
+ return () => window.removeEventListener('pointerdown', handleOutsideClick)
247
+ }, [menuOpen])
248
+
150
249
  useEffect(() => {
151
250
  setReady(false)
152
251
  setSaveState('loading')
153
252
  const receive = (event: MessageEvent) => {
154
253
  if (event.source !== frame.current?.contentWindow || event.origin !== window.location.origin) return
155
- let message: { event?: string; xml?: string }
254
+ let message: { event?: string; xml?: string; format?: string; data?: string }
156
255
  try { message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data }
157
256
  catch { return }
158
257
  if (message.event === 'configure') {
@@ -163,11 +262,18 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
163
262
  setSaveState('saved')
164
263
  } else if ((message.event === 'autosave' || message.event === 'save') && typeof message.xml === 'string') {
165
264
  scheduleSave(message.xml, message.event === 'save')
265
+ } else if (message.event === 'export' && typeof message.data === 'string') {
266
+ const pending = pendingExport.current
267
+ pendingExport.current = null
268
+ setIsExporting(false)
269
+ if (pending) {
270
+ void handleExportResult(pending, message.data)
271
+ }
166
272
  }
167
273
  }
168
274
  window.addEventListener('message', receive)
169
275
  return () => window.removeEventListener('message', receive)
170
- }, [src, dark, path, post, scheduleSave])
276
+ }, [src, dark, path, post, scheduleSave, handleExportResult])
171
277
 
172
278
  useEffect(() => {
173
279
  if (!ready || truncated) return
@@ -208,9 +314,103 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
208
314
  }, [scope.sessionId, scope.cwd, path])
209
315
 
210
316
  if (truncated) return <CenteredMessage title="无法打开画板" detail="文件内容已被截断,继续编辑可能损坏原文件。" />
317
+
318
+ const targetExt = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png'
319
+ const previewName = `${fileName(path).replace(/\.drawio$/i, '')}${targetExt}`
320
+
211
321
  return (
212
322
  <div style={styles.root}>
213
323
  <iframe ref={frame} title={`${fileName(path)} Draw.io 画板`} src={src} style={styles.frame} />
324
+
325
+ {/* 右上角悬浮导出工具面板 */}
326
+ <div ref={menuRef} style={styles.toolbar}>
327
+ <button
328
+ type="button"
329
+ onClick={() => setMenuOpen(open => !open)}
330
+ disabled={!ready || isExporting}
331
+ style={{ ...styles.exportTrigger, ...(isExporting ? styles.triggerActive : {}) }}
332
+ title="导出当前画板为图片"
333
+ >
334
+ <CameraIcon size={14} />
335
+ <span>{isExporting ? '正在导出...' : '导出图片'}</span>
336
+ <span style={styles.arrowIcon}>▾</span>
337
+ </button>
338
+
339
+ {menuOpen ? (
340
+ <div style={styles.exportMenu}>
341
+ <div style={styles.menuHeader}>
342
+ <span style={styles.menuTitle}>导出画板图片</span>
343
+ <span style={styles.menuSub}>{previewName}</span>
344
+ </div>
345
+
346
+ <div style={styles.formatRow}>
347
+ {(['png', 'svg', 'xmlpng'] as const).map(fmt => (
348
+ <button
349
+ key={fmt}
350
+ type="button"
351
+ onClick={() => setExportFormat(fmt)}
352
+ style={{
353
+ ...styles.formatBtn,
354
+ ...(exportFormat === fmt ? { ...styles.formatBtnActive, borderColor: accentColor } : {}),
355
+ }}
356
+ >
357
+ {fmt === 'png' ? 'PNG (超清)' : fmt === 'svg' ? 'SVG (矢量)' : 'XML-PNG'}
358
+ </button>
359
+ ))}
360
+ </div>
361
+
362
+ {exportFormat !== 'svg' ? (
363
+ <label style={styles.checkRow}>
364
+ <input
365
+ type="checkbox"
366
+ checked={isTransparent}
367
+ onChange={e => setIsTransparent(e.target.checked)}
368
+ style={styles.checkbox}
369
+ />
370
+ <span>透明背景</span>
371
+ </label>
372
+ ) : null}
373
+
374
+ <div style={styles.actionCol}>
375
+ <button
376
+ type="button"
377
+ onClick={() => startExport('workspace')}
378
+ style={{ ...styles.actionBtn, ...styles.actionBtnPrimary, background: accentColor }}
379
+ >
380
+ <SaveIcon size={13} />
381
+ <span>保存到工作区 ({targetExt})</span>
382
+ </button>
383
+
384
+ <button
385
+ type="button"
386
+ onClick={() => startExport('clipboard')}
387
+ style={styles.actionBtn}
388
+ >
389
+ <CopyIcon size={13} />
390
+ <span>复制到剪贴板</span>
391
+ </button>
392
+
393
+ <button
394
+ type="button"
395
+ onClick={() => startExport('download')}
396
+ style={styles.actionBtn}
397
+ >
398
+ <DownloadIcon size={13} />
399
+ <span>下载图片文件</span>
400
+ </button>
401
+ </div>
402
+ </div>
403
+ ) : null}
404
+ </div>
405
+
406
+ {/* Toast 提示 */}
407
+ {toast ? (
408
+ <div style={styles.toast}>
409
+ <CheckIcon size={14} />
410
+ <span>{toast}</span>
411
+ </div>
412
+ ) : null}
413
+
214
414
  <div role="status" aria-live="polite" style={{ ...styles.status, ...(saveState === 'error' ? styles.statusError : {}) }}>
215
415
  <span style={{ ...styles.dot, background: accentColor }} />{statusText(saveState)}
216
416
  </div>
@@ -269,6 +469,10 @@ async function fsWrite(scope: SessionScope, path: string, content: string, keepa
269
469
  await call('write', scope, { path, content }, keepalive)
270
470
  }
271
471
 
472
+ async function fsSaveImage(scope: SessionScope, path: string, data: string): Promise<{ path: string; bytes: number }> {
473
+ return call<{ path: string; bytes: number }>('save-image', scope, { path, data })
474
+ }
475
+
272
476
  async function call<T = { ok: true }>(method: string, scope: SessionScope, extra: Record<string, unknown>, keepalive = false, signal?: AbortSignal): Promise<T> {
273
477
  const response = await fetch(`/dsh-drawio/api/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: scope.sessionId, ...(scope.cwd ? { cwd: scope.cwd } : {}), ...extra }), keepalive, signal })
274
478
  const envelope = await response.json().catch(() => null) as { ok?: boolean; value?: T; error?: { message?: string } } | null
@@ -280,21 +484,75 @@ function CanvasIcon({ size = 16, className }: { size?: number; className?: strin
280
484
  return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none"><rect x="2.5" y="2.5" width="15" height="15" rx="2.5" stroke="currentColor"/><circle cx="7" cy="7" r="1.5" fill="currentColor"/><circle cx="13" cy="13" r="1.5" fill="currentColor"/><path d="M8.4 7.8l3.2 4.4M8.2 6.2h3.6M6.2 8.2v3.6" stroke="currentColor" strokeLinecap="round"/></svg>
281
485
  }
282
486
 
487
+ function CameraIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
488
+ return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 6.5A1.5 1.5 0 0 1 5.5 5h1.8a1.5 1.5 0 0 0 1.2-.6l.5-.7A1.5 1.5 0 0 1 10.2 3h1.6a1.5 1.5 0 0 1 1.2.7l.5.7a1.5 1.5 0 0 0 1.2.6h1.8A1.5 1.5 0 0 1 18 6.5v9a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 2 15.5v-9z"/><circle cx="10" cy="11" r="3.5"/></svg>
489
+ }
490
+
491
+ function SaveIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
492
+ return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M16 17H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h9.5l3.5 3.5V16a1 1 0 0 1-1 1z"/><path d="M13 17v-6H7v6"/><path d="M7 3v4h6"/></svg>
493
+ }
494
+
495
+ function CopyIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
496
+ return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="7" y="7" width="10" height="10" rx="1.5"/><path d="M4 13H3.5A1.5 1.5 0 0 1 2 11.5v-8A1.5 1.5 0 0 1 3.5 2h8A1.5 1.5 0 0 1 13 3.5V4"/></svg>
497
+ }
498
+
499
+ function DownloadIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
500
+ return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3v10m0 0l-3.5-3.5M10 13l3.5-3.5"/><path d="M3 14v2a1.5 1.5 0 0 0 1.5 1.5h11a1.5 1.5 0 0 0 1.5-1.5v-2"/></svg>
501
+ }
502
+
503
+ function CheckIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
504
+ return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 10.5l4 4 8-9"/></svg>
505
+ }
506
+
283
507
  function CenteredMessage({ title, detail }: { title: string; detail: string }): JSX.Element {
284
508
  return <div style={styles.center}><strong>{title}</strong><span style={styles.muted}>{detail}</span></div>
285
509
  }
286
510
 
287
511
  const fileName = (path: string): string => path.replace(/\\/g, '/').split('/').pop() || path
288
- function parseDrawioAddress(address: string): { sessionId: string; path: string } | null {
289
- const prefix = 'dsh-resource://file/session/'
290
- if (!address.startsWith(prefix)) return null
512
+
513
+ export function parseDrawioAddress(address?: string, fallbackSessionId?: string): { sessionId: string; path: string } | null {
514
+ if (!address || typeof address !== 'string') return null
291
515
  try {
292
- const parts = address.slice(prefix.length).split('/').map(decodeURIComponent)
293
- const sessionId = parts.shift() ?? ''
294
- const path = parts.join('/')
295
- if (!sessionId || !path || !/\.drawio$/i.test(path) || parts.some(part => part.includes('/') || part.includes('\\') || part.includes('\0'))) return null
296
- return { sessionId, path }
297
- } catch { return null }
516
+ let clean = address.trim()
517
+ let parsedSessionId = ''
518
+
519
+ if (clean.startsWith('dsh-resource://file/session/')) {
520
+ const rest = clean.slice('dsh-resource://file/session/'.length)
521
+ const slashIdx = rest.indexOf('/')
522
+ if (slashIdx >= 0) {
523
+ parsedSessionId = decodeURIComponent(rest.slice(0, slashIdx))
524
+ clean = rest.slice(slashIdx + 1)
525
+ } else {
526
+ clean = rest
527
+ }
528
+ } else if (clean.startsWith('dsh-resource://file/')) {
529
+ clean = clean.slice('dsh-resource://file/'.length)
530
+ } else if (clean.startsWith('file:///')) {
531
+ clean = clean.slice('file:///'.length)
532
+ }
533
+
534
+ // 处理可能是 URL 编码的 path(支持嵌套目录 %2F)
535
+ let decodedPath = clean
536
+ try {
537
+ decodedPath = decodeURIComponent(clean)
538
+ } catch {}
539
+
540
+ // 规范化斜杠并去除开头的斜杠
541
+ decodedPath = decodedPath.replace(/\\/g, '/').replace(/^\/+/, '')
542
+
543
+ // 必须是 .drawio 文件
544
+ if (!/\.drawio$/i.test(decodedPath)) return null
545
+
546
+ // 防空字符
547
+ if (decodedPath.includes('\0')) return null
548
+
549
+ return {
550
+ sessionId: parsedSessionId || fallbackSessionId || '',
551
+ path: decodedPath,
552
+ }
553
+ } catch {
554
+ return null
555
+ }
298
556
  }
299
557
  const messageOf = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason)
300
558
  const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'error' ? '保存失败' : '已保存'
@@ -306,6 +564,23 @@ const accent = 'var(--dsw-alias-brand-primary, #ed67ad)'
306
564
  const styles: Record<string, React.CSSProperties> = {
307
565
  root: { position: 'relative', width: '100%', height: '100%', minHeight: 0, overflow: 'hidden', color: fg, background: layer },
308
566
  frame: { display: 'block', width: '100%', height: '100%', border: 0, background: layer },
567
+ toolbar: { position: 'absolute', right: 12, top: 12, zIndex: 10, display: 'flex', flexDirection: 'column', alignItems: 'flex-end' },
568
+ exportTrigger: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 10px', border: `1px solid ${border}`, borderRadius: 6, color: fg, background: layer, boxShadow: 'var(--dsw-alias-shadow-sm, 0 2px 8px rgba(0,0,0,.12))', fontSize: 12, fontWeight: 500, cursor: 'pointer', backdropFilter: 'blur(8px)', transition: 'all 0.15s ease' },
569
+ triggerActive: { opacity: 0.75, cursor: 'wait' },
570
+ arrowIcon: { fontSize: 10, opacity: 0.6, marginLeft: 2 },
571
+ exportMenu: { marginTop: 6, width: 220, padding: 12, border: `1px solid ${border}`, borderRadius: 8, background: layer, boxShadow: 'var(--dsw-alias-shadow-md, 0 6px 20px rgba(0,0,0,.18))', backdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', gap: 10 },
572
+ menuHeader: { display: 'flex', flexDirection: 'column', gap: 2, paddingBottom: 6, borderBottom: `1px solid ${border}` },
573
+ menuTitle: { fontSize: 12, fontWeight: 600, color: fg },
574
+ menuSub: { fontSize: 10, color: muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
575
+ formatRow: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 4 },
576
+ formatBtn: { padding: '4px 2px', border: `1px solid ${border}`, borderRadius: 4, background: 'transparent', color: muted, fontSize: 10.5, cursor: 'pointer', textAlign: 'center', transition: 'all 0.1s ease' },
577
+ formatBtnActive: { color: fg, fontWeight: 600, background: 'var(--dsw-alias-fill-quaternary, rgba(127,127,127,0.12))' },
578
+ checkRow: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: muted, cursor: 'pointer', userSelect: 'none' },
579
+ checkbox: { cursor: 'pointer', margin: 0 },
580
+ actionCol: { display: 'flex', flexDirection: 'column', gap: 6, paddingTop: 4 },
581
+ actionBtn: { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, padding: '6px 10px', border: `1px solid ${border}`, borderRadius: 5, background: 'transparent', color: fg, fontSize: 11.5, cursor: 'pointer', transition: 'all 0.12s ease' },
582
+ actionBtnPrimary: { color: '#ffffff', border: 'none', fontWeight: 500 },
583
+ toast: { position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)', zIndex: 12, display: 'flex', alignItems: 'center', gap: 6, padding: '6px 14px', border: `1px solid ${border}`, borderRadius: 20, background: layer, color: fg, boxShadow: 'var(--dsw-alias-shadow-md, 0 4px 14px rgba(0,0,0,.16))', fontSize: 12, backdropFilter: 'blur(10px)', pointerEvents: 'none' },
309
584
  status: { position: 'absolute', right: 10, bottom: 9, zIndex: 2, display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', border: `1px solid ${border}`, borderRadius: 999, color: muted, background: layer, boxShadow: 'var(--dsw-alias-shadow-sm, 0 2px 8px rgba(0,0,0,.12))', fontSize: 11, pointerEvents: 'none' },
310
585
  statusError: { color: 'var(--dsw-alias-danger, #d84f5f)' },
311
586
  dot: { width: 6, height: 6, borderRadius: 99, background: accent },
package/src/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { createReadStream } from 'node:fs'
2
- import { stat } from 'node:fs/promises'
2
+ import { stat, readFile } from 'node:fs/promises'
3
3
  import { extname, join, normalize, relative, sep } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { defineTool } from '@deepseek-ai/dsh-tools'
6
6
  import { editDrawio, inspectDrawio, normalizeDrawio } from './xml.js'
7
7
 
8
8
  export const name = 'dsh-drawio'
9
- export const inject = ['tools', 'fs', 'webServer']
9
+ export const inject = ['tools', 'fs', 'webServer', 'skills']
10
10
 
11
11
  const runtimeRoot = fileURLToPath(new URL('../vendor/drawio/', import.meta.url))
12
12
  const MAX_DIAGRAM_BYTES = 10 * 1024 * 1024
@@ -17,7 +17,20 @@ const output = {
17
17
  }
18
18
  const pathParameter = { type: 'string', required: true, description: 'Path to a .drawio file, relative to the current DSH workspace or absolute within the allowed workspace.' }
19
19
 
20
- export function apply(ctx) {
20
+ export async function apply(ctx) {
21
+ try {
22
+ const skillPath = new URL('../skills/drawio-operation/SKILL.md', import.meta.url)
23
+ const skillText = await readFile(skillPath, 'utf8')
24
+ const content = skillText.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim()
25
+ ctx.effect(() => ctx.skills?.register({
26
+ name: 'drawio-operation',
27
+ description: 'Inspect, edit, and create Draw.io diagrams with the local canvas and model tools.',
28
+ whenToUse: 'Use for creating, reading, or modifying architecture diagrams, flowcharts, or Draw.io XML files.',
29
+ source: 'bundled',
30
+ content,
31
+ }))
32
+ } catch {}
33
+
21
34
  ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-drawio/runtime', handler: serveRuntime }))
22
35
  ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-drawio/api', handler: (req, res) => serveCanvasApi(ctx, req, res) }))
23
36
  register(ctx, defineTool({
@@ -87,8 +100,33 @@ async function serveCanvasApi(ctx, req, res) {
87
100
  try {
88
101
  const pathname = new URL(req.url || '/', 'http://dsh.internal').pathname
89
102
  const method = pathname.slice('/dsh-drawio/api/'.length)
90
- if (!['read', 'write'].includes(method)) return respondJson(res, 404, { ok: false, error: { message: 'Unknown Draw.io operation.' } })
103
+ if (!['read', 'write', 'save-image'].includes(method)) return respondJson(res, 404, { ok: false, error: { message: 'Unknown Draw.io operation.' } })
91
104
  const payload = await readJsonBody(req)
105
+
106
+ if (method === 'save-image') {
107
+ if (typeof payload.data !== 'string' || !payload.data.trim()) throw new Error('Image data is required.')
108
+ const target = await resolveCanvasTarget(ctx, payload.cwd, payload.path, true)
109
+ let buffer
110
+ if (payload.data.startsWith('data:')) {
111
+ const comma = payload.data.indexOf(',')
112
+ const base64 = comma >= 0 ? payload.data.slice(comma + 1) : payload.data
113
+ buffer = Buffer.from(base64, 'base64')
114
+ } else if (payload.data.startsWith('<svg') || payload.data.startsWith('<?xml')) {
115
+ buffer = Buffer.from(payload.data, 'utf8')
116
+ } else {
117
+ buffer = Buffer.from(payload.data, 'base64')
118
+ }
119
+ if (buffer.length > MAX_REQUEST_BYTES) throw new Error('Image data is too large to save safely.')
120
+ const sandboxPolicy = { mode: 'workspace-write', workspaceRoot: payload.cwd }
121
+ const outcome = typeof ctx.fs.writeFile === 'function'
122
+ ? await ctx.fs.writeFile(target, buffer, { kind: 'replace' }, undefined, sandboxPolicy)
123
+ : typeof ctx.fs.writeBinary === 'function'
124
+ ? await ctx.fs.writeBinary(target, buffer, { kind: 'replace' }, undefined, sandboxPolicy)
125
+ : await ctx.fs.writeText(target, payload.path.endsWith('.svg') ? buffer.toString('utf8') : buffer.toString('base64'), { kind: 'replace' }, undefined, sandboxPolicy)
126
+ ctx.emit?.('fs/observed', target, { kind: 'present', version: outcome?.version }, undefined)
127
+ return respondJson(res, 200, { ok: true, value: { path: target.displayPath ?? payload.path, bytes: buffer.length } })
128
+ }
129
+
92
130
  const target = await resolveCanvasTarget(ctx, payload.cwd, payload.path)
93
131
  const info = await ctx.fs.stat(target)
94
132
  if (!info || info.type !== 'file') throw new Error('Draw.io file was not found.')
@@ -104,14 +142,22 @@ async function serveCanvasApi(ctx, req, res) {
104
142
  }
105
143
  }
106
144
 
107
- async function resolveCanvasTarget(ctx, cwd, path) {
145
+ async function resolveCanvasTarget(ctx, cwd, path, allowImage = false) {
108
146
  if (typeof cwd !== 'string' || !cwd.trim()) throw new Error('A workspace is required.')
109
- assertDrawioPath(path)
147
+ if (allowImage) {
148
+ assertExportImagePath(path)
149
+ } else {
150
+ assertDrawioPath(path)
151
+ }
110
152
  const [root, target] = await Promise.all([ctx.fs.resolve(cwd), ctx.fs.resolve(path, { cwd })])
111
- if (!ctx.fs.contains(root, target)) throw new Error('Draw.io file must stay inside the current workspace.')
153
+ if (!ctx.fs.contains(root, target)) throw new Error('File must stay inside the current workspace.')
112
154
  return target
113
155
  }
114
156
 
157
+ function assertExportImagePath(value) {
158
+ if (typeof value !== 'string' || !/\.(png|svg|xml\.png)$/i.test(value)) throw new Error('path must end with .png or .svg')
159
+ }
160
+
115
161
  async function readJsonBody(req) {
116
162
  const declared = Number(req.headers?.['content-length'])
117
163
  if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES) throw new Error('Request body is too large.')