@civilization/dsh-drawio 0.2.1 → 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
@@ -10,15 +10,29 @@
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
 
16
- - `drawio_inspect`:把画板读取为页面、节点、连线和几何信息。
17
- - `drawio_edit`:批量新增、更新、删除节点和连线。
17
+ - `drawio-operation` Skill:向模型注入 Draw.io 画板整体架构、工业级排版规范、现代配色方案、离线导出机制与最佳绘图工作流。
18
+ - `drawio_inspect`:把画板读取为页面、节点、连线和几何信息,自动提取去除 HTML 的纯文本标题与容器子节点列表。
19
+ - `drawio_edit`:批量新增、更新、删除节点与连线,支持 `add_page`、`rename_page`、`delete_page` 多页面管理,以及 `stylePatch` 局部增量更新样式。
18
20
  - `drawio_write`:校验并写入完整的 Draw.io XML。
19
21
 
20
- 工具统一保存为未压缩 XML,便于 Agent 继续修改,也便于 Git 查看差异。画板会
21
- 自动保存,并轮询同步 Agent 对同一文件的修改。
22
+ 工具统一保存为未压缩 XML,便于 Agent 继续修改,也便于 Git 查看差异。画板具备真乐观并发控制(防静默覆盖冲突),并采用轻量版本感知机制自动同步 Agent 对同一文件的修改。
23
+
24
+ ## 本地离线图片导出 (Export)
25
+
26
+ 由于 Draw.io 原版导出功能依赖云端渲染服务(`export.diagrams.net`),在离线及局域网环境下无法正常工作。本插件基于 Draw.io 离线内核的 Embed 消息通道与 Canvas / SVG 渲染协议,实现了**100% 纯本地离线图片导出引擎**:
27
+
28
+ - **支持格式**:
29
+ - **PNG (超清 2x)**:采用高倍率抗锯齿光栅化渲染,支持透明背景切换,适合插入文档和交流汇报;
30
+ - **SVG (矢量图)**:提取无损矢量图形,任意放大不失真;
31
+ - **XML-PNG (可编辑图)**:在 PNG 图像中内嵌图表 XML 原数据,既能作为常规图片展示,又可随时拖回 Draw.io 继续二次编辑。
32
+ - **三大输出方式**:
33
+ - **保存到工作区**:在当前画板文件同级目录下自动生成同名图片文件(如 `arch.drawio` -> `arch.png`),自动触发 DSH 文件系统感知;
34
+ - **复制到剪贴板**:一键将 PNG 图片写入操作系统剪贴板(`navigator.clipboard`),方便在 Markdown、PRD 或聊天工具中直接 `Ctrl + V` 粘贴;
35
+ - **下载图片文件**:触发浏览器原生下载弹窗保存到本地磁盘。
22
36
 
23
37
  ## 开发验证
24
38
 
@@ -38,4 +52,4 @@ dsh web --port 3082 --no-open
38
52
  已在 DSH 0.1.5-rc.2 上验证构建、XML 操作、本地静态资源加载和官方右侧栏注册。
39
53
 
40
54
  当前内置的精简 Draw.io runtime 固定为 31.4.5。包含完整满血版内置图库(通用、流程图、UML、ER、BPMN、网络、Kubernetes、AWS、GCP、Cisco、电子、平面图等全部分类均可自由开启)以及 PlantUML 离线渲染模块;
41
- 云盘、在线图库、模板库、VSDX/Gliffy 导入和服务端导出未包含。
55
+ 云盘、在线图库、模板库、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,39 +52,61 @@ 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) => {
71
- 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 });
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, version: file.version, path: resource.path, scope: { sessionId: effectiveSessionId, cwd } });
82
104
  }
83
- function DrawioCanvas({ content, truncated, path, scope }) {
105
+ function DrawioCanvas({ content, version, truncated, path, scope }) {
84
106
  const frame = (0, import_react.useRef)(null);
85
107
  const latestFile = (0, import_react.useRef)(content ?? "");
86
108
  const latestEditor = (0, import_react.useRef)(content ?? "");
109
+ const latestVersion = (0, import_react.useRef)(version);
87
110
  const saveTimer = (0, import_react.useRef)(void 0);
88
111
  const saveGeneration = (0, import_react.useRef)(0);
89
112
  const [ready, setReady] = (0, import_react.useState)(false);
@@ -92,24 +115,46 @@ function DrawioCanvas({ content, truncated, path, scope }) {
92
115
  const lastUserActivity = (0, import_react.useRef)(0);
93
116
  const isInteracting = (0, import_react.useRef)(false);
94
117
  const { dark, accent: accentColor } = useDshTheme();
95
- 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"}`, []);
96
120
  const post = (0, import_react.useCallback)((message) => {
97
121
  frame.current?.contentWindow?.postMessage(JSON.stringify(message), window.location.origin);
98
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]);
99
137
  const persist = (0, import_react.useCallback)(async (xml) => {
100
138
  const generation = ++saveGeneration.current;
101
139
  setSaveState("saving");
102
140
  try {
103
- await fsWrite(scope, path, xml);
141
+ const outcome = await fsWrite(scope, path, xml, latestVersion.current);
104
142
  if (generation !== saveGeneration.current) return;
105
143
  latestFile.current = xml;
106
144
  latestEditor.current = xml;
145
+ if (outcome?.version) latestVersion.current = outcome.version;
107
146
  setSaveState("saved");
108
147
  setError(null);
109
148
  } catch (reason) {
110
149
  if (generation !== saveGeneration.current) return;
111
- setSaveState("error");
112
- 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
+ }
113
158
  }
114
159
  }, [scope.sessionId, scope.cwd, path]);
115
160
  const scheduleSave = (0, import_react.useCallback)((xml, immediate = false) => {
@@ -131,7 +176,8 @@ function DrawioCanvas({ content, truncated, path, scope }) {
131
176
  (0, import_react.useEffect)(() => {
132
177
  latestFile.current = content ?? "";
133
178
  latestEditor.current = content ?? "";
134
- }, [content, path]);
179
+ latestVersion.current = version;
180
+ }, [content, version, path]);
135
181
  const markActivity = (0, import_react.useCallback)(() => {
136
182
  lastUserActivity.current = Date.now();
137
183
  }, []);
@@ -162,6 +208,86 @@ function DrawioCanvas({ content, truncated, path, scope }) {
162
208
  const timer = window.setInterval(handleFrameAttach, 800);
163
209
  return () => window.clearInterval(timer);
164
210
  }, []);
211
+ const [menuOpen, setMenuOpen] = (0, import_react.useState)(false);
212
+ const [exportFormat, setExportFormat] = (0, import_react.useState)("png");
213
+ const [isTransparent, setIsTransparent] = (0, import_react.useState)(false);
214
+ const [isExporting, setIsExporting] = (0, import_react.useState)(false);
215
+ const [toast, setToast] = (0, import_react.useState)(null);
216
+ const pendingExport = (0, import_react.useRef)(null);
217
+ const menuRef = (0, import_react.useRef)(null);
218
+ const showToast = (0, import_react.useCallback)((msg) => {
219
+ setToast(msg);
220
+ window.setTimeout(() => setToast((current) => current === msg ? null : current), 3200);
221
+ }, []);
222
+ const startExport = (0, import_react.useCallback)((target) => {
223
+ if (!ready || isExporting) return;
224
+ const ext = exportFormat === "svg" ? ".svg" : exportFormat === "xmlpng" ? ".drawio.png" : ".png";
225
+ const base = path.replace(/\.drawio$/i, "");
226
+ const targetPath = `${base}${ext}`;
227
+ pendingExport.current = {
228
+ format: exportFormat,
229
+ target,
230
+ targetPath
231
+ };
232
+ setIsExporting(true);
233
+ setMenuOpen(false);
234
+ post({
235
+ action: "export",
236
+ format: exportFormat,
237
+ scale: exportFormat === "svg" ? 1 : 2,
238
+ transparent: isTransparent,
239
+ border: 10,
240
+ spin: "\u6B63\u5728\u6E32\u67D3\u5BFC\u51FA\u56FE\u7247..."
241
+ });
242
+ }, [ready, isExporting, exportFormat, isTransparent, path, post]);
243
+ const handleExportResult = (0, import_react.useCallback)(async (pending, data) => {
244
+ try {
245
+ if (pending.target === "download") {
246
+ const link = document.createElement("a");
247
+ link.download = fileName(pending.targetPath);
248
+ link.href = data;
249
+ document.body.appendChild(link);
250
+ link.click();
251
+ document.body.removeChild(link);
252
+ showToast(`\u5DF2\u5F00\u59CB\u4E0B\u8F7D\uFF1A${fileName(pending.targetPath)}`);
253
+ } else if (pending.target === "clipboard") {
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)}`);
273
+ }
274
+ } else if (pending.target === "workspace") {
275
+ const result = await fsSaveImage(scope, pending.targetPath, data);
276
+ showToast(`\u5DF2\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A\uFF1A${fileName(result.path)} (${Math.round(result.bytes / 1024)} KB)`);
277
+ }
278
+ } catch (reason) {
279
+ setError(`\u5BFC\u51FA\u5931\u8D25\uFF1A${messageOf(reason)}`);
280
+ }
281
+ }, [scope, showToast]);
282
+ (0, import_react.useEffect)(() => {
283
+ const handleOutsideClick = (e) => {
284
+ if (menuOpen && menuRef.current && !menuRef.current.contains(e.target)) {
285
+ setMenuOpen(false);
286
+ }
287
+ };
288
+ window.addEventListener("pointerdown", handleOutsideClick);
289
+ return () => window.removeEventListener("pointerdown", handleOutsideClick);
290
+ }, [menuOpen]);
165
291
  (0, import_react.useEffect)(() => {
166
292
  setReady(false);
167
293
  setSaveState("loading");
@@ -181,11 +307,18 @@ function DrawioCanvas({ content, truncated, path, scope }) {
181
307
  setSaveState("saved");
182
308
  } else if ((message.event === "autosave" || message.event === "save") && typeof message.xml === "string") {
183
309
  scheduleSave(message.xml, message.event === "save");
310
+ } else if (message.event === "export" && typeof message.data === "string") {
311
+ const pending = pendingExport.current;
312
+ pendingExport.current = null;
313
+ setIsExporting(false);
314
+ if (pending) {
315
+ void handleExportResult(pending, message.data);
316
+ }
184
317
  }
185
318
  };
186
319
  window.addEventListener("message", receive);
187
320
  return () => window.removeEventListener("message", receive);
188
- }, [src, dark, path, post, scheduleSave]);
321
+ }, [src, dark, path, post, scheduleSave, handleExportResult]);
189
322
  (0, import_react.useEffect)(() => {
190
323
  if (!ready || truncated) return;
191
324
  let cancelled = false;
@@ -195,12 +328,19 @@ function DrawioCanvas({ content, truncated, path, scope }) {
195
328
  if (saveTimer.current !== void 0) return;
196
329
  if (typeof document !== "undefined" && document.hidden) return;
197
330
  try {
198
- const xml = await fsRead(scope, path);
331
+ const stat = await fsStat(scope, path);
332
+ if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return;
333
+ if (stat.version && latestVersion.current && stat.version === latestVersion.current) return;
334
+ const res = await fsRead(scope, path);
199
335
  if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return;
200
- if (xml === latestFile.current || xml === latestEditor.current) return;
201
- latestFile.current = xml;
202
- latestEditor.current = xml;
203
- post({ action: "load", xml, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 });
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 });
204
344
  setSaveState("external");
205
345
  window.setTimeout(() => {
206
346
  if (!cancelled) setSaveState("saved");
@@ -219,16 +359,124 @@ function DrawioCanvas({ content, truncated, path, scope }) {
219
359
  }, [ready, truncated, scope.sessionId, scope.cwd, path, dark, post]);
220
360
  (0, import_react.useEffect)(() => () => {
221
361
  if (saveTimer.current !== void 0) window.clearTimeout(saveTimer.current);
222
- 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);
223
363
  }, [scope.sessionId, scope.cwd, path]);
224
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" });
365
+ const targetExt = exportFormat === "svg" ? ".svg" : exportFormat === "xmlpng" ? ".drawio.png" : ".png";
366
+ const previewName = `${fileName(path).replace(/\.drawio$/i, "")}${targetExt}`;
225
367
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.root, children: [
226
368
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("iframe", { ref: frame, title: `${fileName(path)} Draw.io \u753B\u677F`, src, style: styles.frame }),
227
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { role: "status", "aria-live": "polite", style: { ...styles.status, ...saveState === "error" ? styles.statusError : {} }, children: [
228
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...styles.dot, background: accentColor } }),
369
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { ref: menuRef, style: styles.toolbar, children: [
370
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
371
+ "button",
372
+ {
373
+ type: "button",
374
+ onClick: () => setMenuOpen((open) => !open),
375
+ disabled: !ready || isExporting,
376
+ style: { ...styles.exportTrigger, ...isExporting ? styles.triggerActive : {} },
377
+ title: "\u5BFC\u51FA\u5F53\u524D\u753B\u677F\u4E3A\u56FE\u7247",
378
+ children: [
379
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CameraIcon, { size: 14 }),
380
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: isExporting ? "\u6B63\u5728\u5BFC\u51FA..." : "\u5BFC\u51FA\u56FE\u7247" }),
381
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: styles.arrowIcon, children: "\u25BE" })
382
+ ]
383
+ }
384
+ ),
385
+ menuOpen ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.exportMenu, children: [
386
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.menuHeader, children: [
387
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: styles.menuTitle, children: "\u5BFC\u51FA\u753B\u677F\u56FE\u7247" }),
388
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: styles.menuSub, children: previewName })
389
+ ] }),
390
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: styles.formatRow, children: ["png", "svg", "xmlpng"].map((fmt) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
391
+ "button",
392
+ {
393
+ type: "button",
394
+ onClick: () => setExportFormat(fmt),
395
+ style: {
396
+ ...styles.formatBtn,
397
+ ...exportFormat === fmt ? { ...styles.formatBtnActive, borderColor: accentColor } : {}
398
+ },
399
+ children: fmt === "png" ? "PNG (\u8D85\u6E05)" : fmt === "svg" ? "SVG (\u77E2\u91CF)" : "XML-PNG"
400
+ },
401
+ fmt
402
+ )) }),
403
+ exportFormat !== "svg" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { style: styles.checkRow, children: [
404
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
405
+ "input",
406
+ {
407
+ type: "checkbox",
408
+ checked: isTransparent,
409
+ onChange: (e) => setIsTransparent(e.target.checked),
410
+ style: styles.checkbox
411
+ }
412
+ ),
413
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u900F\u660E\u80CC\u666F" })
414
+ ] }) : null,
415
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.actionCol, children: [
416
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
417
+ "button",
418
+ {
419
+ type: "button",
420
+ onClick: () => startExport("workspace"),
421
+ style: { ...styles.actionBtn, ...styles.actionBtnPrimary, background: accentColor },
422
+ children: [
423
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaveIcon, { size: 13 }),
424
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
425
+ "\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A (",
426
+ targetExt,
427
+ ")"
428
+ ] })
429
+ ]
430
+ }
431
+ ),
432
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
433
+ "button",
434
+ {
435
+ type: "button",
436
+ onClick: () => startExport("clipboard"),
437
+ style: styles.actionBtn,
438
+ children: [
439
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CopyIcon, { size: 13 }),
440
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u590D\u5236\u5230\u526A\u8D34\u677F" })
441
+ ]
442
+ }
443
+ ),
444
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
445
+ "button",
446
+ {
447
+ type: "button",
448
+ onClick: () => startExport("download"),
449
+ style: styles.actionBtn,
450
+ children: [
451
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DownloadIcon, { size: 13 }),
452
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u4E0B\u8F7D\u56FE\u7247\u6587\u4EF6" })
453
+ ]
454
+ }
455
+ )
456
+ ] })
457
+ ] }) : null
458
+ ] }),
459
+ toast ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.toast, children: [
460
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CheckIcon, { size: 14 }),
461
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: toast })
462
+ ] }) : null,
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 } }),
229
465
  statusText(saveState)
230
466
  ] }),
231
- 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
232
480
  ] });
233
481
  }
234
482
  function useDshTheme() {
@@ -269,12 +517,17 @@ function detectDark() {
269
517
  if (numbers?.length === 3) return numbers[0] * 0.299 + numbers[1] * 0.587 + numbers[2] * 0.114 < 128;
270
518
  return document.documentElement.classList.contains("dark") || window.matchMedia("(prefers-color-scheme: dark)").matches;
271
519
  }
520
+ async function fsStat(scope, path, signal) {
521
+ return call("stat", scope, { path }, false, signal);
522
+ }
272
523
  async function fsRead(scope, path, signal) {
273
- const value = await call("read", scope, { path }, false, signal);
274
- return value.content;
524
+ return call("read", scope, { path }, false, signal);
275
525
  }
276
- async function fsWrite(scope, path, content, keepalive = false) {
277
- 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);
528
+ }
529
+ async function fsSaveImage(scope, path, data) {
530
+ return call("save-image", scope, { path, data });
278
531
  }
279
532
  async function call(method, scope, extra, keepalive = false, signal) {
280
533
  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 });
@@ -290,6 +543,34 @@ function CanvasIcon({ size = 16, className }) {
290
543
  /* @__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
544
  ] });
292
545
  }
546
+ function CameraIcon({ size = 16, className }) {
547
+ 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: [
548
+ /* @__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" }),
549
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: "10", cy: "11", r: "3.5" })
550
+ ] });
551
+ }
552
+ function SaveIcon({ size = 16, className }) {
553
+ 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: [
554
+ /* @__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" }),
555
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M13 17v-6H7v6" }),
556
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M7 3v4h6" })
557
+ ] });
558
+ }
559
+ function CopyIcon({ size = 16, className }) {
560
+ 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: [
561
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", { x: "7", y: "7", width: "10", height: "10", rx: "1.5" }),
562
+ /* @__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" })
563
+ ] });
564
+ }
565
+ function DownloadIcon({ size = 16, className }) {
566
+ 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: [
567
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M10 3v10m0 0l-3.5-3.5M10 13l3.5-3.5" }),
568
+ /* @__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" })
569
+ ] });
570
+ }
571
+ function CheckIcon({ size = 16, className }) {
572
+ 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" }) });
573
+ }
293
574
  function CenteredMessage({ title, detail }) {
294
575
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.center, children: [
295
576
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: title }),
@@ -297,21 +578,43 @@ function CenteredMessage({ title, detail }) {
297
578
  ] });
298
579
  }
299
580
  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;
581
+ function parseDrawioAddress(address, fallbackSessionId) {
582
+ if (!address || typeof address !== "string") return null;
303
583
  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 };
584
+ let clean = address.trim();
585
+ let parsedSessionId = "";
586
+ if (clean.startsWith("dsh-resource://file/session/")) {
587
+ const rest = clean.slice("dsh-resource://file/session/".length);
588
+ const slashIdx = rest.indexOf("/");
589
+ if (slashIdx >= 0) {
590
+ parsedSessionId = decodeURIComponent(rest.slice(0, slashIdx));
591
+ clean = rest.slice(slashIdx + 1);
592
+ } else {
593
+ clean = rest;
594
+ }
595
+ } else if (clean.startsWith("dsh-resource://file/")) {
596
+ clean = clean.slice("dsh-resource://file/".length);
597
+ } else if (clean.startsWith("file:///")) {
598
+ clean = clean.slice("file:///".length);
599
+ }
600
+ let decodedPath = clean;
601
+ try {
602
+ decodedPath = decodeURIComponent(clean);
603
+ } catch {
604
+ }
605
+ decodedPath = decodedPath.replace(/\\/g, "/").replace(/^\/+/, "");
606
+ if (!/\.drawio$/i.test(decodedPath)) return null;
607
+ if (decodedPath.includes("\0")) return null;
608
+ return {
609
+ sessionId: parsedSessionId || fallbackSessionId || "",
610
+ path: decodedPath
611
+ };
309
612
  } catch {
310
613
  return null;
311
614
  }
312
615
  }
313
616
  var messageOf = (reason) => reason instanceof Error ? reason.message : String(reason);
314
- 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";
315
618
  var fg = "var(--dsw-alias-label-primary, currentColor)";
316
619
  var muted = "var(--dsw-alias-label-secondary, rgba(127,127,127,.9))";
317
620
  var layer = "var(--dsw-alias-container-bg, Canvas)";
@@ -320,6 +623,23 @@ var accent = "var(--dsw-alias-brand-primary, #ed67ad)";
320
623
  var styles = {
321
624
  root: { position: "relative", width: "100%", height: "100%", minHeight: 0, overflow: "hidden", color: fg, background: layer },
322
625
  frame: { display: "block", width: "100%", height: "100%", border: 0, background: layer },
626
+ toolbar: { position: "absolute", right: 12, top: 12, zIndex: 10, display: "flex", flexDirection: "column", alignItems: "flex-end" },
627
+ 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" },
628
+ triggerActive: { opacity: 0.75, cursor: "wait" },
629
+ arrowIcon: { fontSize: 10, opacity: 0.6, marginLeft: 2 },
630
+ 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 },
631
+ menuHeader: { display: "flex", flexDirection: "column", gap: 2, paddingBottom: 6, borderBottom: `1px solid ${border}` },
632
+ menuTitle: { fontSize: 12, fontWeight: 600, color: fg },
633
+ menuSub: { fontSize: 10, color: muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
634
+ formatRow: { display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 4 },
635
+ 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" },
636
+ formatBtnActive: { color: fg, fontWeight: 600, background: "var(--dsw-alias-fill-quaternary, rgba(127,127,127,0.12))" },
637
+ checkRow: { display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: muted, cursor: "pointer", userSelect: "none" },
638
+ checkbox: { cursor: "pointer", margin: 0 },
639
+ actionCol: { display: "flex", flexDirection: "column", gap: 6, paddingTop: 4 },
640
+ 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" },
641
+ actionBtnPrimary: { color: "#ffffff", border: "none", fontWeight: 500 },
642
+ 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
643
  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
644
  statusError: { color: "var(--dsw-alias-danger, #d84f5f)" },
325
645
  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.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",
@@ -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",