@civilization/dsh-drawio 0.4.0 → 0.4.2
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 +37 -4
- package/lib/client.js +143 -50
- package/package.json +10 -9
- package/src/client/address.js +64 -0
- package/src/client/index.tsx +51 -58
- package/src/index.js +16 -23
- package/src/svg.js +61 -0
package/README.md
CHANGED
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
- **SVG (矢量图)**:提取无损矢量图形,任意放大不失真;
|
|
31
31
|
- **XML-PNG (可编辑图)**:在 PNG 图像中内嵌图表 XML 原数据,既能作为常规图片展示,又可随时拖回 Draw.io 继续二次编辑。
|
|
32
32
|
- **三大输出方式**:
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
33
|
+
- **保存到工作区(仅 SVG)**:在当前画板文件同级目录下自动生成同名矢量文件(如 `arch.drawio` -> `arch.svg`),自动触发 DSH 文件系统感知。DSH 的 `ctx.fs` 只提供文本写入(无二进制写入口),光栅图落盘会退化成 base64 文本,因此 PNG / XML-PNG 不走此通道;
|
|
34
|
+
- **复制到剪贴板**:PNG / XML-PNG 写入操作系统剪贴板(`navigator.clipboard`),SVG 复制为源码文本,方便在 Markdown、PRD 或聊天工具中直接 `Ctrl + V` 粘贴;
|
|
35
|
+
- **下载图片文件**:触发浏览器原生下载弹窗保存到本地磁盘,三种格式均可用。
|
|
36
36
|
|
|
37
37
|
## 开发验证
|
|
38
38
|
|
|
@@ -49,7 +49,40 @@ dsh web --port 3082 --no-open
|
|
|
49
49
|
`.drawio` 文件时,由官方资源路由选择本插件的画板;读取和自动保存通过插件自有的
|
|
50
50
|
`/dsh-drawio/api` 接口完成,并限制在当前会话工作区内。
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
## 💡 DSH Tab 认领机制与模式声明(避坑指南)
|
|
53
|
+
|
|
54
|
+
本插件向 DSH 官方 `sidebarRightTabs` 注册画板 Tab 类型时,声明了双重匹配模式:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
patterns: ['dsh-resource://file/**/*.drawio', '*.drawio']
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 为什么必须包含整地址模式?
|
|
61
|
+
|
|
62
|
+
DSH 的 `sidebarRightTabs` 在判定「由谁打开此文件」时,采用如下候选排序规则(详见官方 `tab-registry.ts` 的 `candidates()`):
|
|
63
|
+
|
|
64
|
+
1. **优先级分档(Priority Band)**:如 `builtin`、`extension` 等;
|
|
65
|
+
2. **命中模式长度(Matched Pattern Length)**:同档位下,**匹配命中的模式字符数更长者胜**;
|
|
66
|
+
3. **注册顺序(Registration Order)**。
|
|
67
|
+
|
|
68
|
+
#### 潜在冲突场景
|
|
69
|
+
若插件仅声明裸扩展名 `*.drawio`(8 字符),一旦用户安装了带有“接管所有文件”的通用编辑器插件(例如某些侧边栏/文件编辑插件声明了 `dsh-resource://file/**`,长 22 字符):
|
|
70
|
+
- 二者同属 `extension` 优先级;
|
|
71
|
+
- 长度比对:`22 > 8`,通用文件编辑器**按字符长度胜出并强行接管**;
|
|
72
|
+
- 结果:用户在文件树点击 `.drawio`,打开的是别人的纯文本编辑器并展示 XML 源码,本插件画板排第二永不渲染(即便 `*.drawio` 在语义上更精确)。
|
|
73
|
+
|
|
74
|
+
#### 最佳实践解法
|
|
75
|
+
- **`dsh-resource://file/**/*.drawio`**(31 字符):含冒号的分隔符模式执行整地址匹配(如 `dsh-resource://file/session/<sid>/out/arch.drawio`),以 31 > 22 字符在同档位中反超通用编辑器,稳稳赢得 `.drawio` 文件的专属画板渲染权;
|
|
76
|
+
- **`*.drawio`**:不带分隔符的模式按 URI basename 匹配,继续兼容覆盖非 `dsh-resource://` 开头的任意形态地址。
|
|
77
|
+
|
|
78
|
+
> [!TIP]
|
|
79
|
+
> 此规则对所有**“按特定扩展名注册定制编辑器 / 视图 Tab”**的 DSH 插件均成立。编写特定扩展名 Tab 时,务必补全 `dsh-resource://file/**/*.ext` 整地址模式,防止被通用文件接管插件按模式长度压制。
|
|
80
|
+
|
|
81
|
+
已在 DSH 0.2.0-rc.2 上验证构建、XML 操作、本地静态资源加载和官方右侧栏注册。
|
|
82
|
+
|
|
83
|
+
`peerDependencies` 只声明**下界**(`>=0.1.5-rc.2`,无上界),以便跟随 DSH 的快速迭代:DSH 的兼容性判定用运行时版本比对这些范围,因此新版本不会再被旧插件卡住。代价是不再对未来的破坏性变更提供“响亮拒绝”,且**不保证前序版本**——已发布版本只在上述版本上验证过。
|
|
84
|
+
|
|
85
|
+
画板地址解析直接复用 DSH 官方 `parseFileAddress`(`@deepseek-ai/dsh-util-workspace-path`),完整支持 `dsh-resource://file/session/<sid>/<path>` 与 `dsh-resource://file/absolute/<path>` 两种 scope;`absolute` 地址只有在文件位于当前会话工作区内时才能打开,工作区外会给出明确提示,服务端仍会二次校验边界。
|
|
53
86
|
|
|
54
87
|
当前内置的精简 Draw.io runtime 固定为 31.4.5。包含完整满血版内置图库(通用、流程图、UML、ER、BPMN、网络、Kubernetes、AWS、GCP、Cisco、电子、平面图等全部分类均可自由开启)以及 PlantUML 离线渲染模块;
|
|
55
88
|
云盘、在线图库、模板库、VSDX/Gliffy 导入未包含;已全面支持纯本地离线 Canvas + SVG 导出引擎。
|
package/lib/client.js
CHANGED
|
@@ -23,11 +23,110 @@ 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
|
|
27
|
-
parseDrawioAddress: () => parseDrawioAddress
|
|
26
|
+
inject: () => inject
|
|
28
27
|
});
|
|
29
28
|
module.exports = __toCommonJS(index_exports);
|
|
30
29
|
var import_react = require("react");
|
|
30
|
+
|
|
31
|
+
// src/svg.js
|
|
32
|
+
function base64ToText(value) {
|
|
33
|
+
const clean = value.replace(/\s+/g, "");
|
|
34
|
+
const binary = atob(clean);
|
|
35
|
+
const bytes = new Uint8Array(binary.length);
|
|
36
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
37
|
+
return new TextDecoder().decode(bytes);
|
|
38
|
+
}
|
|
39
|
+
function svgMarkupOf(data) {
|
|
40
|
+
if (typeof data !== "string") return null;
|
|
41
|
+
const value = data.trim();
|
|
42
|
+
if (value === "") return null;
|
|
43
|
+
if (!value.startsWith("data:")) return value;
|
|
44
|
+
const comma = value.indexOf(",");
|
|
45
|
+
if (comma < 0) return null;
|
|
46
|
+
const header = value.slice("data:".length, comma).toLowerCase();
|
|
47
|
+
if (!header.startsWith("image/svg+xml")) return null;
|
|
48
|
+
const body = value.slice(comma + 1);
|
|
49
|
+
if (header.includes(";base64")) {
|
|
50
|
+
try {
|
|
51
|
+
return base64ToText(body);
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return decodeURIComponent(body);
|
|
58
|
+
} catch {
|
|
59
|
+
return body;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// node_modules/.pnpm/@deepseek-ai+dsh-util-workspace-path@0.2.0-rc.2_@deepseek-ai+cordis@4.0.4/node_modules/@deepseek-ai/dsh-util-workspace-path/lib/index.js
|
|
64
|
+
var FILE_ADDRESS_PREFIX = "dsh-resource://file/";
|
|
65
|
+
function isDriveSegment(segment) {
|
|
66
|
+
return segment !== void 0 && /^[A-Za-z]:$/.test(segment);
|
|
67
|
+
}
|
|
68
|
+
function parseFileAddress(address) {
|
|
69
|
+
try {
|
|
70
|
+
if (!address.startsWith(FILE_ADDRESS_PREFIX)) return void 0;
|
|
71
|
+
const end = address.search(/[?#]/);
|
|
72
|
+
const [scope, ...rest] = address.slice(20, end === -1 ? void 0 : end).split("/");
|
|
73
|
+
if (scope === "session") {
|
|
74
|
+
const [id, ...segments] = rest;
|
|
75
|
+
if (id === void 0 || id === "" || segments.length === 0) return void 0;
|
|
76
|
+
return {
|
|
77
|
+
scope,
|
|
78
|
+
sessionId: decodeURIComponent(id),
|
|
79
|
+
path: segments.map(decodeURIComponent).join("/")
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (scope === "absolute") {
|
|
83
|
+
const unc = rest[0] === "" && rest.length > 1;
|
|
84
|
+
const segments = (unc ? rest.slice(1) : rest).map(decodeURIComponent);
|
|
85
|
+
if (segments.length === 0 || segments[0] === "") return void 0;
|
|
86
|
+
if (unc) return {
|
|
87
|
+
scope,
|
|
88
|
+
path: `//${segments.join("/")}`
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
scope,
|
|
92
|
+
path: isDriveSegment(segments[0]) ? segments.join("/") : `/${segments.join("/")}`
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
} catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/client/address.js
|
|
102
|
+
var DRAWIO_SUFFIX = /\.drawio$/i;
|
|
103
|
+
function parseDrawioAddress(address, fallbackSessionId) {
|
|
104
|
+
if (typeof address !== "string") return null;
|
|
105
|
+
const trimmed = address.trim();
|
|
106
|
+
if (trimmed === "") return null;
|
|
107
|
+
const parsed = parseFileAddress(trimmed);
|
|
108
|
+
if (parsed === void 0) return null;
|
|
109
|
+
if (parsed.path.includes("\0")) return null;
|
|
110
|
+
if (!DRAWIO_SUFFIX.test(parsed.path)) return null;
|
|
111
|
+
if (parsed.scope === "session") {
|
|
112
|
+
return { scope: "session", sessionId: parsed.sessionId, path: parsed.path };
|
|
113
|
+
}
|
|
114
|
+
return { scope: "absolute", sessionId: fallbackSessionId || "", path: parsed.path };
|
|
115
|
+
}
|
|
116
|
+
function relativeToWorkspace(path, cwd) {
|
|
117
|
+
if (typeof path !== "string" || typeof cwd !== "string") return null;
|
|
118
|
+
const root = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
119
|
+
if (root === "") return null;
|
|
120
|
+
const target = path.replace(/\\/g, "/");
|
|
121
|
+
const foldCase = /^[A-Za-z]:/.test(root) || root.startsWith("//");
|
|
122
|
+
const left = foldCase ? root.toLowerCase() : root;
|
|
123
|
+
const right = foldCase ? target.toLowerCase() : target;
|
|
124
|
+
if (right === left) return "";
|
|
125
|
+
if (right.startsWith(`${left}/`)) return target.slice(root.length + 1);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/client/index.tsx
|
|
31
130
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
32
131
|
var inject = ["slots", "sidebarRightTabs"];
|
|
33
132
|
var TAB_ID = "@civilization/dsh-drawio";
|
|
@@ -37,7 +136,14 @@ function apply(ctx) {
|
|
|
37
136
|
ctx.effect(() => official.sidebarRightTabs.register({
|
|
38
137
|
id: TAB_ID,
|
|
39
138
|
kind: TAB_KIND,
|
|
40
|
-
|
|
139
|
+
// The whole-address glob is what actually wins this file: tab types are
|
|
140
|
+
// ranked by priority band, then by the length of the matched pattern, so a
|
|
141
|
+
// bare `*.drawio` (8 characters) loses the claim to any type declaring a
|
|
142
|
+
// broader address glob in the same `extension` band — e.g.
|
|
143
|
+
// dsh-better-sidebar's file editor (`dsh-resource://file/**`, 22), which
|
|
144
|
+
// then renders the XML instead of this canvas. Keeping both patterns makes
|
|
145
|
+
// the specific one win wherever it matches.
|
|
146
|
+
patterns: ["dsh-resource://file/**/*.drawio", "*.drawio"],
|
|
41
147
|
priority: "extension",
|
|
42
148
|
title: (address) => fileName(parseDrawioAddress(address)?.path ?? "Draw.io")
|
|
43
149
|
}), "dsh-drawio: official tab definition");
|
|
@@ -66,20 +172,23 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
|
|
|
66
172
|
const resourceCwd = useSessions((sessions) => resource?.sessionId ? sessions?.byId?.[resource.sessionId]?.cwd : void 0);
|
|
67
173
|
const cwd = activeCwd || resourceCwd;
|
|
68
174
|
const effectiveSessionId = sessionId || resource?.sessionId || "";
|
|
175
|
+
const relativePath = resource?.scope === "absolute" ? relativeToWorkspace(resource.path, cwd) : null;
|
|
176
|
+
const outsideWorkspace = resource?.scope === "absolute" && cwd !== void 0 && relativePath === null;
|
|
177
|
+
const openPath = resource?.scope === "absolute" ? relativePath : resource?.path ?? null;
|
|
69
178
|
const [file, setFile] = (0, import_react.useState)(null);
|
|
70
179
|
const [error, setError] = (0, import_react.useState)(null);
|
|
71
|
-
const key = `${effectiveSessionId}\0${cwd ?? ""}\0${
|
|
180
|
+
const key = `${effectiveSessionId}\0${cwd ?? ""}\0${openPath ?? ""}`;
|
|
72
181
|
(0, import_react.useEffect)(() => {
|
|
73
|
-
if (!
|
|
182
|
+
if (!openPath || !effectiveSessionId || !cwd) return;
|
|
74
183
|
const controller = new AbortController();
|
|
75
184
|
setError(null);
|
|
76
|
-
void fsRead({ sessionId: effectiveSessionId, cwd },
|
|
185
|
+
void fsRead({ sessionId: effectiveSessionId, cwd }, openPath, controller.signal).then((res) => {
|
|
77
186
|
if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version });
|
|
78
187
|
}).catch((reason) => {
|
|
79
188
|
if (!controller.signal.aborted) setError(messageOf(reason));
|
|
80
189
|
});
|
|
81
190
|
return () => controller.abort();
|
|
82
|
-
}, [key,
|
|
191
|
+
}, [key, openPath, effectiveSessionId, cwd]);
|
|
83
192
|
if (!resource || !resource.path) {
|
|
84
193
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
85
194
|
CenteredMessage,
|
|
@@ -98,9 +207,19 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
|
|
|
98
207
|
}
|
|
99
208
|
);
|
|
100
209
|
}
|
|
210
|
+
if (outsideWorkspace) {
|
|
211
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
212
|
+
CenteredMessage,
|
|
213
|
+
{
|
|
214
|
+
title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F",
|
|
215
|
+
detail: `\u8BE5\u6587\u4EF6\u4E0D\u5728\u5F53\u524D\u5DE5\u4F5C\u533A\u5185\uFF0C\u753B\u677F\u53EA\u80FD\u6253\u5F00\u5DE5\u4F5C\u533A\u4E2D\u7684 .drawio \u6587\u4EF6\uFF1A${resource.path}`
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (!openPath) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F", detail: "\u65E0\u6CD5\u786E\u5B9A\u753B\u677F\u6587\u4EF6\u7684\u5DE5\u4F5C\u533A\u76F8\u5BF9\u8DEF\u5F84\u3002" });
|
|
101
220
|
if (error) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u753B\u677F\u8F7D\u5165\u5931\u8D25", detail: error });
|
|
102
|
-
if (file?.key !== key) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u6B63\u5728\u8F7D\u5165\u753B\u677F", detail: fileName(
|
|
103
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawioCanvas, { content: file.content, version: file.version, path:
|
|
221
|
+
if (file?.key !== key) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u6B63\u5728\u8F7D\u5165\u753B\u677F", detail: fileName(openPath) });
|
|
222
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawioCanvas, { content: file.content, version: file.version, path: openPath, scope: { sessionId: effectiveSessionId, cwd } });
|
|
104
223
|
}
|
|
105
224
|
function DrawioCanvas({ content, version, truncated, path, scope }) {
|
|
106
225
|
const frame = (0, import_react.useRef)(null);
|
|
@@ -221,6 +340,10 @@ function DrawioCanvas({ content, version, truncated, path, scope }) {
|
|
|
221
340
|
}, []);
|
|
222
341
|
const startExport = (0, import_react.useCallback)((target) => {
|
|
223
342
|
if (!ready || isExporting) return;
|
|
343
|
+
if (target === "workspace" && exportFormat !== "svg") {
|
|
344
|
+
showToast("\u4EC5 SVG \u652F\u6301\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A\uFF0CPNG / XML-PNG \u8BF7\u4F7F\u7528\u4E0B\u8F7D\u6216\u526A\u8D34\u677F");
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
224
347
|
const ext = exportFormat === "svg" ? ".svg" : exportFormat === "xmlpng" ? ".drawio.png" : ".png";
|
|
225
348
|
const base = path.replace(/\.drawio$/i, "");
|
|
226
349
|
const targetPath = `${base}${ext}`;
|
|
@@ -239,7 +362,7 @@ function DrawioCanvas({ content, version, truncated, path, scope }) {
|
|
|
239
362
|
border: 10,
|
|
240
363
|
spin: "\u6B63\u5728\u6E32\u67D3\u5BFC\u51FA\u56FE\u7247..."
|
|
241
364
|
});
|
|
242
|
-
}, [ready, isExporting, exportFormat, isTransparent, path, post]);
|
|
365
|
+
}, [ready, isExporting, exportFormat, isTransparent, path, post, showToast]);
|
|
243
366
|
const handleExportResult = (0, import_react.useCallback)(async (pending, data) => {
|
|
244
367
|
try {
|
|
245
368
|
if (pending.target === "download") {
|
|
@@ -253,7 +376,7 @@ function DrawioCanvas({ content, version, truncated, path, scope }) {
|
|
|
253
376
|
} else if (pending.target === "clipboard") {
|
|
254
377
|
try {
|
|
255
378
|
if (pending.format === "svg") {
|
|
256
|
-
const svgText =
|
|
379
|
+
const svgText = svgMarkupOf(data) ?? data;
|
|
257
380
|
await navigator.clipboard.writeText(svgText);
|
|
258
381
|
showToast("\u5DF2\u590D\u5236 SVG \u4EE3\u7801\u5230\u526A\u8D34\u677F");
|
|
259
382
|
} else {
|
|
@@ -412,19 +535,24 @@ function DrawioCanvas({ content, version, truncated, path, scope }) {
|
|
|
412
535
|
),
|
|
413
536
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u900F\u660E\u80CC\u666F" })
|
|
414
537
|
] }) : null,
|
|
538
|
+
exportFormat !== "svg" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: styles.menuSub, children: "\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A\u4EC5\u652F\u6301 SVG\uFF1BPNG / XML-PNG \u8BF7\u7528\u4E0B\u8F7D\u6216\u526A\u8D34\u677F\u3002" }) : null,
|
|
415
539
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.actionCol, children: [
|
|
416
540
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
417
541
|
"button",
|
|
418
542
|
{
|
|
419
543
|
type: "button",
|
|
420
544
|
onClick: () => startExport("workspace"),
|
|
421
|
-
|
|
545
|
+
disabled: exportFormat !== "svg",
|
|
546
|
+
title: exportFormat === "svg" ? `\u4FDD\u5B58\u4E3A ${targetExt} \u5230\u753B\u677F\u540C\u7EA7\u76EE\u5F55` : "\u4EC5 SVG \u652F\u6301\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A\uFF0CPNG / XML-PNG \u8BF7\u4F7F\u7528\u4E0B\u8F7D\u6216\u526A\u8D34\u677F",
|
|
547
|
+
style: {
|
|
548
|
+
...styles.actionBtn,
|
|
549
|
+
...exportFormat === "svg" ? { ...styles.actionBtnPrimary, background: accentColor } : { opacity: 0.5, cursor: "not-allowed" }
|
|
550
|
+
},
|
|
422
551
|
children: [
|
|
423
552
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaveIcon, { size: 13 }),
|
|
424
553
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
|
|
425
|
-
"\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A
|
|
426
|
-
targetExt
|
|
427
|
-
")"
|
|
554
|
+
"\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A ",
|
|
555
|
+
exportFormat === "svg" ? `(${targetExt})` : "(\u4EC5 SVG)"
|
|
428
556
|
] })
|
|
429
557
|
]
|
|
430
558
|
}
|
|
@@ -578,41 +706,6 @@ function CenteredMessage({ title, detail }) {
|
|
|
578
706
|
] });
|
|
579
707
|
}
|
|
580
708
|
var fileName = (path) => path.replace(/\\/g, "/").split("/").pop() || path;
|
|
581
|
-
function parseDrawioAddress(address, fallbackSessionId) {
|
|
582
|
-
if (!address || typeof address !== "string") return null;
|
|
583
|
-
try {
|
|
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
|
-
};
|
|
612
|
-
} catch {
|
|
613
|
-
return null;
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
709
|
var messageOf = (reason) => reason instanceof Error ? reason.message : String(reason);
|
|
617
710
|
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";
|
|
618
711
|
var fg = "var(--dsw-alias-label-primary, currentColor)";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@civilization/dsh-drawio",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Local draw.io canvas and model tools for DeepSeek Harness",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -51,15 +51,16 @@
|
|
|
51
51
|
"provenance": true
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
+
"@deepseek-ai/dsh-util-workspace-path": "0.2.0-rc.2",
|
|
54
55
|
"@deepseek-ai/schemastery": "3.18.2",
|
|
55
56
|
"@xmldom/xmldom": "0.9.12",
|
|
56
57
|
"pako": "2.1.0"
|
|
57
58
|
},
|
|
58
59
|
"peerDependencies": {
|
|
59
60
|
"@deepseek-ai/cordis": ">=4.0.0 <5.0.0-0",
|
|
60
|
-
"@deepseek-ai/dsh-fs": ">=0.1.
|
|
61
|
-
"@deepseek-ai/dsh-host-webserver": ">=0.1.
|
|
62
|
-
"@deepseek-ai/dsh-tools": ">=0.1.
|
|
61
|
+
"@deepseek-ai/dsh-fs": ">=0.1.5-rc.2",
|
|
62
|
+
"@deepseek-ai/dsh-host-webserver": ">=0.1.5-rc.2",
|
|
63
|
+
"@deepseek-ai/dsh-tools": ">=0.1.5-rc.2"
|
|
63
64
|
},
|
|
64
65
|
"peerDependenciesMeta": {
|
|
65
66
|
"@deepseek-ai/dsh-sandbox": {
|
|
@@ -67,11 +68,11 @@
|
|
|
67
68
|
}
|
|
68
69
|
},
|
|
69
70
|
"devDependencies": {
|
|
70
|
-
"@deepseek-ai/cordis": "4.0.
|
|
71
|
-
"@deepseek-ai/dsh-fs": "0.
|
|
72
|
-
"@deepseek-ai/dsh-host-webserver": "0.
|
|
73
|
-
"@deepseek-ai/dsh-sandbox": "0.
|
|
74
|
-
"@deepseek-ai/dsh-tools": "0.
|
|
71
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
72
|
+
"@deepseek-ai/dsh-fs": "0.2.0-rc.2",
|
|
73
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2",
|
|
74
|
+
"@deepseek-ai/dsh-sandbox": "0.2.0-rc.2",
|
|
75
|
+
"@deepseek-ai/dsh-tools": "0.2.0-rc.2",
|
|
75
76
|
"@types/react": "18.3.31",
|
|
76
77
|
"esbuild": "0.28.2",
|
|
77
78
|
"react": "18.3.1",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
|
2
|
+
|
|
3
|
+
const DRAWIO_SUFFIX = /\.drawio$/i
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One Draw.io file named by a sidebar tab address.
|
|
7
|
+
* @typedef {object} DrawioAddress
|
|
8
|
+
* @property {'session' | 'absolute'} scope - which scope of the `dsh-resource://file/…` grammar named it.
|
|
9
|
+
* @property {string} sessionId - the session that resolves the path; empty for an absolute address opened without a pane session.
|
|
10
|
+
* @property {string} path - workspace-relative or absolute `/`-separated path, exactly as the address carried it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse one sidebar tab address into the Draw.io file it names.
|
|
15
|
+
*
|
|
16
|
+
* The grammar is DSH's own (`dsh-resource://file/session/<sessionId>/<path>` and
|
|
17
|
+
* `dsh-resource://file/absolute/<path>`), so parsing is delegated to the product
|
|
18
|
+
* util rather than re-implemented here; only the Draw.io file filter and the
|
|
19
|
+
* fallback session for a session-less absolute address are added on top. A
|
|
20
|
+
* session-scoped path stays exactly as written — it may be workspace-relative
|
|
21
|
+
* or absolute — because the host resolves and confines it.
|
|
22
|
+
*
|
|
23
|
+
* @param {string | undefined} address - the address the tab was claimed for.
|
|
24
|
+
* @param {string} [fallbackSessionId] - the pane's session, used when the address carries none.
|
|
25
|
+
* @returns {DrawioAddress | null} the parsed file, or `null` when the address names anything else.
|
|
26
|
+
*/
|
|
27
|
+
export function parseDrawioAddress(address, fallbackSessionId) {
|
|
28
|
+
if (typeof address !== 'string') return null
|
|
29
|
+
const trimmed = address.trim()
|
|
30
|
+
if (trimmed === '') return null
|
|
31
|
+
const parsed = parseFileAddress(trimmed)
|
|
32
|
+
if (parsed === undefined) return null
|
|
33
|
+
if (parsed.path.includes('\0')) return null
|
|
34
|
+
if (!DRAWIO_SUFFIX.test(parsed.path)) return null
|
|
35
|
+
if (parsed.scope === 'session') {
|
|
36
|
+
return { scope: 'session', sessionId: parsed.sessionId, path: parsed.path }
|
|
37
|
+
}
|
|
38
|
+
return { scope: 'absolute', sessionId: fallbackSessionId || '', path: parsed.path }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The workspace-relative form of an absolute path.
|
|
43
|
+
*
|
|
44
|
+
* An absolute address carries no session, so the canvas needs this answer
|
|
45
|
+
* before it can read through the host: a path under the session workspace is
|
|
46
|
+
* addressed relatively, and one outside it is not openable at all.
|
|
47
|
+
*
|
|
48
|
+
* @param {string | undefined} path - absolute path in either separator spelling.
|
|
49
|
+
* @param {string | undefined} cwd - the session workspace root.
|
|
50
|
+
* @returns {string | null} the workspace-relative path (empty for the root itself), or `null` when it is outside.
|
|
51
|
+
*/
|
|
52
|
+
export function relativeToWorkspace(path, cwd) {
|
|
53
|
+
if (typeof path !== 'string' || typeof cwd !== 'string') return null
|
|
54
|
+
const root = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
55
|
+
if (root === '') return null
|
|
56
|
+
const target = path.replace(/\\/g, '/')
|
|
57
|
+
// A Windows drive or UNC root addresses the same file in either case.
|
|
58
|
+
const foldCase = /^[A-Za-z]:/.test(root) || root.startsWith('//')
|
|
59
|
+
const left = foldCase ? root.toLowerCase() : root
|
|
60
|
+
const right = foldCase ? target.toLowerCase() : target
|
|
61
|
+
if (right === left) return ''
|
|
62
|
+
if (right.startsWith(`${left}/`)) return target.slice(root.length + 1)
|
|
63
|
+
return null
|
|
64
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis'
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
|
+
import { svgMarkupOf } from '../svg.js'
|
|
4
|
+
import { parseDrawioAddress, relativeToWorkspace } from './address.js'
|
|
3
5
|
|
|
4
6
|
export const inject = ['slots', 'sidebarRightTabs']
|
|
5
7
|
const TAB_ID = '@civilization/dsh-drawio'
|
|
@@ -14,7 +16,14 @@ export function apply(ctx: Context): void {
|
|
|
14
16
|
ctx.effect(() => official.sidebarRightTabs.register({
|
|
15
17
|
id: TAB_ID,
|
|
16
18
|
kind: TAB_KIND,
|
|
17
|
-
|
|
19
|
+
// The whole-address glob is what actually wins this file: tab types are
|
|
20
|
+
// ranked by priority band, then by the length of the matched pattern, so a
|
|
21
|
+
// bare `*.drawio` (8 characters) loses the claim to any type declaring a
|
|
22
|
+
// broader address glob in the same `extension` band — e.g.
|
|
23
|
+
// dsh-better-sidebar's file editor (`dsh-resource://file/**`, 22), which
|
|
24
|
+
// then renders the XML instead of this canvas. Keeping both patterns makes
|
|
25
|
+
// the specific one win wherever it matches.
|
|
26
|
+
patterns: ['dsh-resource://file/**/*.drawio', '*.drawio'],
|
|
18
27
|
priority: 'extension',
|
|
19
28
|
title: (address: string) => fileName(parseDrawioAddress(address)?.path ?? 'Draw.io'),
|
|
20
29
|
}), 'dsh-drawio: official tab definition')
|
|
@@ -44,19 +53,25 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX
|
|
|
44
53
|
const cwd = activeCwd || resourceCwd
|
|
45
54
|
const effectiveSessionId = sessionId || resource?.sessionId || ''
|
|
46
55
|
|
|
56
|
+
// An absolute address carries no session, so it is openable only when it names
|
|
57
|
+
// a file under this session's workspace; the host re-checks that boundary.
|
|
58
|
+
const relativePath = resource?.scope === 'absolute' ? relativeToWorkspace(resource.path, cwd) : null
|
|
59
|
+
const outsideWorkspace = resource?.scope === 'absolute' && cwd !== undefined && relativePath === null
|
|
60
|
+
const openPath = resource?.scope === 'absolute' ? relativePath : resource?.path ?? null
|
|
61
|
+
|
|
47
62
|
const [file, setFile] = useState<{ key: string; content: string; version?: string } | null>(null)
|
|
48
63
|
const [error, setError] = useState<string | null>(null)
|
|
49
|
-
const key = `${effectiveSessionId}\0${cwd ?? ''}\0${
|
|
64
|
+
const key = `${effectiveSessionId}\0${cwd ?? ''}\0${openPath ?? ''}`
|
|
50
65
|
|
|
51
66
|
useEffect(() => {
|
|
52
|
-
if (!
|
|
67
|
+
if (!openPath || !effectiveSessionId || !cwd) return
|
|
53
68
|
const controller = new AbortController()
|
|
54
69
|
setError(null)
|
|
55
|
-
void fsRead({ sessionId: effectiveSessionId, cwd },
|
|
70
|
+
void fsRead({ sessionId: effectiveSessionId, cwd }, openPath, controller.signal)
|
|
56
71
|
.then(res => { if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version }) })
|
|
57
72
|
.catch(reason => { if (!controller.signal.aborted) setError(messageOf(reason)) })
|
|
58
73
|
return () => controller.abort()
|
|
59
|
-
}, [key,
|
|
74
|
+
}, [key, openPath, effectiveSessionId, cwd])
|
|
60
75
|
|
|
61
76
|
if (!resource || !resource.path) {
|
|
62
77
|
return (
|
|
@@ -74,9 +89,18 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX
|
|
|
74
89
|
/>
|
|
75
90
|
)
|
|
76
91
|
}
|
|
92
|
+
if (outsideWorkspace) {
|
|
93
|
+
return (
|
|
94
|
+
<CenteredMessage
|
|
95
|
+
title="无法打开画板"
|
|
96
|
+
detail={`该文件不在当前工作区内,画板只能打开工作区中的 .drawio 文件:${resource.path}`}
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
if (!openPath) return <CenteredMessage title="无法打开画板" detail="无法确定画板文件的工作区相对路径。" />
|
|
77
101
|
if (error) return <CenteredMessage title="画板载入失败" detail={error} />
|
|
78
|
-
if (file?.key !== key) return <CenteredMessage title="正在载入画板" detail={fileName(
|
|
79
|
-
return <DrawioCanvas content={file.content} version={file.version} path={
|
|
102
|
+
if (file?.key !== key) return <CenteredMessage title="正在载入画板" detail={fileName(openPath)} />
|
|
103
|
+
return <DrawioCanvas content={file.content} version={file.version} path={openPath} scope={{ sessionId: effectiveSessionId, cwd }} />
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerProps): JSX.Element {
|
|
@@ -208,6 +232,13 @@ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerPr
|
|
|
208
232
|
|
|
209
233
|
const startExport = useCallback((target: 'workspace' | 'download' | 'clipboard') => {
|
|
210
234
|
if (!ready || isExporting) return
|
|
235
|
+
// The workspace target is SVG-only: DSH's filesystem service writes text
|
|
236
|
+
// (there is no binary write seam), so a raster export would land as base64
|
|
237
|
+
// text. PNG and XML-PNG stay available through download and clipboard.
|
|
238
|
+
if (target === 'workspace' && exportFormat !== 'svg') {
|
|
239
|
+
showToast('仅 SVG 支持保存到工作区,PNG / XML-PNG 请使用下载或剪贴板')
|
|
240
|
+
return
|
|
241
|
+
}
|
|
211
242
|
const ext = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png'
|
|
212
243
|
const base = path.replace(/\.drawio$/i, '')
|
|
213
244
|
const targetPath = `${base}${ext}`
|
|
@@ -227,7 +258,7 @@ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerPr
|
|
|
227
258
|
border: 10,
|
|
228
259
|
spin: '正在渲染导出图片...',
|
|
229
260
|
})
|
|
230
|
-
}, [ready, isExporting, exportFormat, isTransparent, path, post])
|
|
261
|
+
}, [ready, isExporting, exportFormat, isTransparent, path, post, showToast])
|
|
231
262
|
|
|
232
263
|
const handleExportResult = useCallback(async (pending: { format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string }, data: string) => {
|
|
233
264
|
try {
|
|
@@ -242,9 +273,7 @@ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerPr
|
|
|
242
273
|
} else if (pending.target === 'clipboard') {
|
|
243
274
|
try {
|
|
244
275
|
if (pending.format === 'svg') {
|
|
245
|
-
const svgText =
|
|
246
|
-
? decodeURIComponent(data.split(',')[1] || '')
|
|
247
|
-
: data
|
|
276
|
+
const svgText = svgMarkupOf(data) ?? data
|
|
248
277
|
await navigator.clipboard.writeText(svgText)
|
|
249
278
|
showToast('已复制 SVG 代码到剪贴板')
|
|
250
279
|
} else {
|
|
@@ -416,14 +445,23 @@ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerPr
|
|
|
416
445
|
</label>
|
|
417
446
|
) : null}
|
|
418
447
|
|
|
448
|
+
{exportFormat !== 'svg' ? (
|
|
449
|
+
<span style={styles.menuSub}>保存到工作区仅支持 SVG;PNG / XML-PNG 请用下载或剪贴板。</span>
|
|
450
|
+
) : null}
|
|
451
|
+
|
|
419
452
|
<div style={styles.actionCol}>
|
|
420
453
|
<button
|
|
421
454
|
type="button"
|
|
422
455
|
onClick={() => startExport('workspace')}
|
|
423
|
-
|
|
456
|
+
disabled={exportFormat !== 'svg'}
|
|
457
|
+
title={exportFormat === 'svg' ? `保存为 ${targetExt} 到画板同级目录` : '仅 SVG 支持保存到工作区,PNG / XML-PNG 请使用下载或剪贴板'}
|
|
458
|
+
style={{
|
|
459
|
+
...styles.actionBtn,
|
|
460
|
+
...(exportFormat === 'svg' ? { ...styles.actionBtnPrimary, background: accentColor } : { opacity: 0.5, cursor: 'not-allowed' }),
|
|
461
|
+
}}
|
|
424
462
|
>
|
|
425
463
|
<SaveIcon size={13} />
|
|
426
|
-
<span>保存到工作区 ({targetExt})</span>
|
|
464
|
+
<span>保存到工作区 {exportFormat === 'svg' ? `(${targetExt})` : '(仅 SVG)'}</span>
|
|
427
465
|
</button>
|
|
428
466
|
|
|
429
467
|
<button
|
|
@@ -569,51 +607,6 @@ function CenteredMessage({ title, detail }: { title: string; detail: string }):
|
|
|
569
607
|
}
|
|
570
608
|
|
|
571
609
|
const fileName = (path: string): string => path.replace(/\\/g, '/').split('/').pop() || path
|
|
572
|
-
|
|
573
|
-
export function parseDrawioAddress(address?: string, fallbackSessionId?: string): { sessionId: string; path: string } | null {
|
|
574
|
-
if (!address || typeof address !== 'string') return null
|
|
575
|
-
try {
|
|
576
|
-
let clean = address.trim()
|
|
577
|
-
let parsedSessionId = ''
|
|
578
|
-
|
|
579
|
-
if (clean.startsWith('dsh-resource://file/session/')) {
|
|
580
|
-
const rest = clean.slice('dsh-resource://file/session/'.length)
|
|
581
|
-
const slashIdx = rest.indexOf('/')
|
|
582
|
-
if (slashIdx >= 0) {
|
|
583
|
-
parsedSessionId = decodeURIComponent(rest.slice(0, slashIdx))
|
|
584
|
-
clean = rest.slice(slashIdx + 1)
|
|
585
|
-
} else {
|
|
586
|
-
clean = rest
|
|
587
|
-
}
|
|
588
|
-
} else if (clean.startsWith('dsh-resource://file/')) {
|
|
589
|
-
clean = clean.slice('dsh-resource://file/'.length)
|
|
590
|
-
} else if (clean.startsWith('file:///')) {
|
|
591
|
-
clean = clean.slice('file:///'.length)
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// 处理可能是 URL 编码的 path(支持嵌套目录 %2F)
|
|
595
|
-
let decodedPath = clean
|
|
596
|
-
try {
|
|
597
|
-
decodedPath = decodeURIComponent(clean)
|
|
598
|
-
} catch {}
|
|
599
|
-
|
|
600
|
-
// 规范化斜杠并去除开头的斜杠
|
|
601
|
-
decodedPath = decodedPath.replace(/\\/g, '/').replace(/^\/+/, '')
|
|
602
|
-
|
|
603
|
-
// 必须是 .drawio 文件
|
|
604
|
-
if (!/\.drawio$/i.test(decodedPath)) return null
|
|
605
|
-
|
|
606
|
-
// 防空字符
|
|
607
|
-
if (decodedPath.includes('\0')) return null
|
|
608
|
-
|
|
609
|
-
return {
|
|
610
|
-
sessionId: parsedSessionId || fallbackSessionId || '',
|
|
611
|
-
path: decodedPath,
|
|
612
|
-
}
|
|
613
|
-
} catch {
|
|
614
|
-
return null
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
610
|
const messageOf = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason)
|
|
618
611
|
const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'conflict' ? '检测到冲突' : state === 'error' ? '保存失败' : '已保存'
|
|
619
612
|
const fg = 'var(--dsw-alias-label-primary, currentColor)'
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,7 @@ 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
|
+
import { isSvgMarkup, svgMarkupOf } from './svg.js'
|
|
6
7
|
import { editDrawio, inspectDrawio, normalizeDrawio } from './xml.js'
|
|
7
8
|
|
|
8
9
|
export const name = 'dsh-drawio'
|
|
@@ -105,27 +106,19 @@ async function serveCanvasApi(ctx, req, res) {
|
|
|
105
106
|
const payload = await readJsonBody(req)
|
|
106
107
|
|
|
107
108
|
if (method === 'save-image') {
|
|
108
|
-
|
|
109
|
+
// The workspace target is SVG-only: DSH's filesystem service writes text,
|
|
110
|
+
// with no binary seam, so a raster export has no faithful landing place.
|
|
111
|
+
// PNG and XML-PNG are exported through the browser instead.
|
|
112
|
+
const markup = svgMarkupOf(payload.data)
|
|
113
|
+
if (markup === null || !isSvgMarkup(markup)) throw new Error('Only an SVG export can be saved to the workspace.')
|
|
114
|
+
const svg = markup.trim()
|
|
115
|
+
const bytes = Buffer.byteLength(svg)
|
|
116
|
+
if (bytes > MAX_DIAGRAM_BYTES) throw new Error('SVG data is too large to save safely.')
|
|
109
117
|
const target = await resolveCanvasTarget(ctx, payload.cwd, payload.path, true)
|
|
110
|
-
let buffer
|
|
111
|
-
if (payload.data.startsWith('data:')) {
|
|
112
|
-
const comma = payload.data.indexOf(',')
|
|
113
|
-
const base64 = comma >= 0 ? payload.data.slice(comma + 1) : payload.data
|
|
114
|
-
buffer = Buffer.from(base64, 'base64')
|
|
115
|
-
} else if (payload.data.startsWith('<svg') || payload.data.startsWith('<?xml')) {
|
|
116
|
-
buffer = Buffer.from(payload.data, 'utf8')
|
|
117
|
-
} else {
|
|
118
|
-
buffer = Buffer.from(payload.data, 'base64')
|
|
119
|
-
}
|
|
120
|
-
if (buffer.length > MAX_REQUEST_BYTES) throw new Error('Image data is too large to save safely.')
|
|
121
118
|
const sandboxPolicy = { mode: 'workspace-write', workspaceRoot: payload.cwd }
|
|
122
|
-
const outcome =
|
|
123
|
-
? await ctx.fs.writeFile(target, buffer, { kind: 'replace' }, undefined, sandboxPolicy)
|
|
124
|
-
: typeof ctx.fs.writeBinary === 'function'
|
|
125
|
-
? await ctx.fs.writeBinary(target, buffer, { kind: 'replace' }, undefined, sandboxPolicy)
|
|
126
|
-
: await ctx.fs.writeText(target, payload.path.endsWith('.svg') ? buffer.toString('utf8') : buffer.toString('base64'), { kind: 'replace' }, undefined, sandboxPolicy)
|
|
119
|
+
const outcome = await ctx.fs.writeText(target, svg, { kind: 'replace' }, undefined, sandboxPolicy)
|
|
127
120
|
ctx.emit?.('fs/observed', target, { kind: 'present', version: outcome?.version }, undefined)
|
|
128
|
-
return respondJson(res, 200, { ok: true, value: { path: target.displayPath ?? payload.path, bytes
|
|
121
|
+
return respondJson(res, 200, { ok: true, value: { path: target.displayPath ?? payload.path, bytes } })
|
|
129
122
|
}
|
|
130
123
|
|
|
131
124
|
const target = await resolveCanvasTarget(ctx, payload.cwd, payload.path)
|
|
@@ -147,10 +140,10 @@ async function serveCanvasApi(ctx, req, res) {
|
|
|
147
140
|
}
|
|
148
141
|
}
|
|
149
142
|
|
|
150
|
-
async function resolveCanvasTarget(ctx, cwd, path,
|
|
143
|
+
async function resolveCanvasTarget(ctx, cwd, path, allowSvgExport = false) {
|
|
151
144
|
if (typeof cwd !== 'string' || !cwd.trim()) throw new Error('A workspace is required.')
|
|
152
|
-
if (
|
|
153
|
-
|
|
145
|
+
if (allowSvgExport) {
|
|
146
|
+
assertSvgExportPath(path)
|
|
154
147
|
} else {
|
|
155
148
|
assertDrawioPath(path)
|
|
156
149
|
}
|
|
@@ -159,8 +152,8 @@ async function resolveCanvasTarget(ctx, cwd, path, allowImage = false) {
|
|
|
159
152
|
return target
|
|
160
153
|
}
|
|
161
154
|
|
|
162
|
-
function
|
|
163
|
-
if (typeof value !== 'string' || !/\.
|
|
155
|
+
function assertSvgExportPath(value) {
|
|
156
|
+
if (typeof value !== 'string' || !/\.svg$/i.test(value)) throw new Error('path must end with .svg')
|
|
164
157
|
}
|
|
165
158
|
|
|
166
159
|
async function readJsonBody(req) {
|
package/src/svg.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG export payload handling shared by the host route and the canvas client.
|
|
3
|
+
*
|
|
4
|
+
* The bundled Draw.io runtime hands an export back in one of two carrier
|
|
5
|
+
* forms: the common `data:image/svg+xml;base64,<base64>` data URL, or a
|
|
6
|
+
* `data:image/svg+xml,<percent-encoded>` / raw-markup payload. Both are
|
|
7
|
+
* normalized to markup here so a caller never guesses which one it received.
|
|
8
|
+
* The module stays browser-safe: it decodes with `atob`/`TextDecoder`, never
|
|
9
|
+
* with Node's `Buffer`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Decode one base64 payload without depending on Node globals.
|
|
14
|
+
* @param {string} value - base64 text, whitespace tolerated.
|
|
15
|
+
* @returns {string} the decoded text.
|
|
16
|
+
*/
|
|
17
|
+
function base64ToText(value) {
|
|
18
|
+
const clean = value.replace(/\s+/g, '')
|
|
19
|
+
const binary = atob(clean)
|
|
20
|
+
const bytes = new Uint8Array(binary.length)
|
|
21
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
|
|
22
|
+
return new TextDecoder().decode(bytes)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Normalize one export payload to SVG markup.
|
|
27
|
+
* @param {unknown} data - raw markup, or a `data:` URL carrying it.
|
|
28
|
+
* @returns {string | null} the markup, or `null` when the payload names another media type or cannot be decoded.
|
|
29
|
+
*/
|
|
30
|
+
export function svgMarkupOf(data) {
|
|
31
|
+
if (typeof data !== 'string') return null
|
|
32
|
+
const value = data.trim()
|
|
33
|
+
if (value === '') return null
|
|
34
|
+
if (!value.startsWith('data:')) return value
|
|
35
|
+
const comma = value.indexOf(',')
|
|
36
|
+
if (comma < 0) return null
|
|
37
|
+
const header = value.slice('data:'.length, comma).toLowerCase()
|
|
38
|
+
if (!header.startsWith('image/svg+xml')) return null
|
|
39
|
+
const body = value.slice(comma + 1)
|
|
40
|
+
if (header.includes(';base64')) {
|
|
41
|
+
try {
|
|
42
|
+
return base64ToText(body)
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return decodeURIComponent(body)
|
|
49
|
+
} catch {
|
|
50
|
+
return body
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether one payload is SVG markup the workspace may store as text.
|
|
56
|
+
* @param {unknown} value - candidate markup.
|
|
57
|
+
* @returns {boolean} whether a root `<svg>` element is present.
|
|
58
|
+
*/
|
|
59
|
+
export function isSvgMarkup(value) {
|
|
60
|
+
return typeof value === 'string' && /<svg[\s>]/i.test(value)
|
|
61
|
+
}
|