@civilization/dsh-drawio 0.2.0 → 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 +17 -2
- package/lib/client.js +285 -20
- package/package.json +11 -6
- package/skills/drawio-operation/SKILL.md +53 -0
- package/src/client/index.tsx +296 -21
- package/src/index.js +73 -10
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:
|
|
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
|
|
62
|
-
const resource = (0, import_react.useMemo)(() => parseDrawioAddress(
|
|
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 = `${
|
|
71
|
+
const key = `${effectiveSessionId}\0${cwd ?? ""}\0${resource?.path ?? ""}`;
|
|
66
72
|
(0, import_react.useEffect)(() => {
|
|
67
|
-
if (!resource ||
|
|
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?.
|
|
77
|
-
if (!resource || resource.
|
|
78
|
-
|
|
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
|
-
|
|
302
|
-
if (!address.startsWith(prefix)) return null;
|
|
526
|
+
function parseDrawioAddress(address, fallbackSessionId) {
|
|
527
|
+
if (!address || typeof address !== "string") return null;
|
|
303
528
|
try {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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.
|
|
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",
|
|
@@ -55,11 +56,15 @@
|
|
|
55
56
|
"pako": "2.1.0"
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
|
-
"@deepseek-ai/cordis": "
|
|
59
|
-
"@deepseek-ai/dsh-fs": "
|
|
60
|
-
"@deepseek-ai/dsh-host-webserver": "
|
|
61
|
-
"@deepseek-ai/dsh-
|
|
62
|
-
|
|
59
|
+
"@deepseek-ai/cordis": ">=4.0.0 <5.0.0-0",
|
|
60
|
+
"@deepseek-ai/dsh-fs": ">=0.1.2-rc.1 <0.2.0-0",
|
|
61
|
+
"@deepseek-ai/dsh-host-webserver": ">=0.1.2-rc.1 <0.2.0-0",
|
|
62
|
+
"@deepseek-ai/dsh-tools": ">=0.1.2-rc.1 <0.2.0-0"
|
|
63
|
+
},
|
|
64
|
+
"peerDependenciesMeta": {
|
|
65
|
+
"@deepseek-ai/dsh-sandbox": {
|
|
66
|
+
"optional": true
|
|
67
|
+
}
|
|
63
68
|
},
|
|
64
69
|
"devDependencies": {
|
|
65
70
|
"@deepseek-ai/cordis": "4.0.2",
|
|
@@ -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`.
|
package/src/client/index.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
39
|
-
const resource = useMemo(() => parseDrawioAddress(
|
|
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 = `${
|
|
49
|
+
const key = `${effectiveSessionId}\0${cwd ?? ''}\0${resource?.path ?? ''}`
|
|
43
50
|
|
|
44
51
|
useEffect(() => {
|
|
45
|
-
if (!resource ||
|
|
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?.
|
|
59
|
+
}, [key, resource?.path, effectiveSessionId, cwd])
|
|
53
60
|
|
|
54
|
-
if (!resource || resource.
|
|
55
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
if (!address
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
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', '
|
|
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({
|
|
@@ -54,7 +67,8 @@ export function apply(ctx) {
|
|
|
54
67
|
async execute(args, exec) {
|
|
55
68
|
const { text, target } = await readDiagram(ctx, args.path, exec)
|
|
56
69
|
const xml = editDrawio(text, args.operations, args.page)
|
|
57
|
-
const
|
|
70
|
+
const policy = await resolvePolicy(ctx, 'drawio_edit', args, exec)
|
|
71
|
+
const outcome = await writeDiagram(ctx, target, xml, args, exec, policy)
|
|
58
72
|
return { path: target.displayPath, operation: outcome.operation, operationsApplied: args.operations.length, ...inspectDrawio(xml, false) }
|
|
59
73
|
},
|
|
60
74
|
}))
|
|
@@ -65,7 +79,7 @@ export function apply(ctx) {
|
|
|
65
79
|
output,
|
|
66
80
|
async execute(args, exec) {
|
|
67
81
|
assertDrawioPath(args.path)
|
|
68
|
-
const policy = await
|
|
82
|
+
const policy = await resolvePolicy(ctx, 'drawio_write', args, exec)
|
|
69
83
|
const target = await ctx.fs.resolve(args.path, resolveOptions(exec, args.path, policy?.workspaceRoot))
|
|
70
84
|
const xml = normalizeDrawio(args.xml)
|
|
71
85
|
const outcome = await writeDiagram(ctx, target, xml, args, exec, policy)
|
|
@@ -86,8 +100,33 @@ async function serveCanvasApi(ctx, req, res) {
|
|
|
86
100
|
try {
|
|
87
101
|
const pathname = new URL(req.url || '/', 'http://dsh.internal').pathname
|
|
88
102
|
const method = pathname.slice('/dsh-drawio/api/'.length)
|
|
89
|
-
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.' } })
|
|
90
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
|
+
|
|
91
130
|
const target = await resolveCanvasTarget(ctx, payload.cwd, payload.path)
|
|
92
131
|
const info = await ctx.fs.stat(target)
|
|
93
132
|
if (!info || info.type !== 'file') throw new Error('Draw.io file was not found.')
|
|
@@ -103,14 +142,22 @@ async function serveCanvasApi(ctx, req, res) {
|
|
|
103
142
|
}
|
|
104
143
|
}
|
|
105
144
|
|
|
106
|
-
async function resolveCanvasTarget(ctx, cwd, path) {
|
|
145
|
+
async function resolveCanvasTarget(ctx, cwd, path, allowImage = false) {
|
|
107
146
|
if (typeof cwd !== 'string' || !cwd.trim()) throw new Error('A workspace is required.')
|
|
108
|
-
|
|
147
|
+
if (allowImage) {
|
|
148
|
+
assertExportImagePath(path)
|
|
149
|
+
} else {
|
|
150
|
+
assertDrawioPath(path)
|
|
151
|
+
}
|
|
109
152
|
const [root, target] = await Promise.all([ctx.fs.resolve(cwd), ctx.fs.resolve(path, { cwd })])
|
|
110
|
-
if (!ctx.fs.contains(root, target)) throw new Error('
|
|
153
|
+
if (!ctx.fs.contains(root, target)) throw new Error('File must stay inside the current workspace.')
|
|
111
154
|
return target
|
|
112
155
|
}
|
|
113
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
|
+
|
|
114
161
|
async function readJsonBody(req) {
|
|
115
162
|
const declared = Number(req.headers?.['content-length'])
|
|
116
163
|
if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES) throw new Error('Request body is too large.')
|
|
@@ -150,8 +197,24 @@ async function readDiagram(ctx, requestedPath, exec) {
|
|
|
150
197
|
return { target, text }
|
|
151
198
|
}
|
|
152
199
|
|
|
200
|
+
async function resolvePolicy(ctx, toolName, args, exec) {
|
|
201
|
+
const sandbox = ctx.get?.('sandbox')
|
|
202
|
+
if (sandbox?.resolvePolicy) {
|
|
203
|
+
try {
|
|
204
|
+
return await sandbox.resolvePolicy(toolName, args, exec)
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
const policyService = ctx.get?.('sandboxPolicy')
|
|
208
|
+
if (policyService?.resolve) {
|
|
209
|
+
try {
|
|
210
|
+
return policyService.resolve({ ...(exec?.agent ? { session: exec.agent.session } : {}) })
|
|
211
|
+
} catch {}
|
|
212
|
+
}
|
|
213
|
+
return undefined
|
|
214
|
+
}
|
|
215
|
+
|
|
153
216
|
async function writeDiagram(ctx, target, xml, args, exec, resolvedPolicy) {
|
|
154
|
-
const policy = resolvedPolicy ?? await
|
|
217
|
+
const policy = resolvedPolicy ?? await resolvePolicy(ctx, 'write', args, exec)
|
|
155
218
|
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
|
|
156
219
|
const outcome = await ctx.fs.writeText(target, xml, intent, exec.signal, policy)
|
|
157
220
|
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|