@civilization/dsh-drawio 0.4.1 → 0.4.3
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 +9 -4
- package/lib/client.js +135 -49
- package/package.json +11 -10
- package/src/client/address.js +64 -0
- package/src/client/index.tsx +43 -57
- 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
|
|
|
@@ -78,7 +78,12 @@ DSH 的 `sidebarRightTabs` 在判定「由谁打开此文件」时,采用如
|
|
|
78
78
|
> [!TIP]
|
|
79
79
|
> 此规则对所有**“按特定扩展名注册定制编辑器 / 视图 Tab”**的 DSH 插件均成立。编写特定扩展名 Tab 时,务必补全 `dsh-resource://file/**/*.ext` 整地址模式,防止被通用文件接管插件按模式长度压制。
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
## Harness / Cordis 版本兼容
|
|
82
|
+
|
|
83
|
+
当前版本已在 DeepSeek Harness **0.2.1-rc.2** 及 Cordis **4.0.5** 上全量验证通过:构建、XML 离线操作与编辑、乐观锁版本感知、本地静态资源加载及官方右侧栏 `sidebarRightTabs` / `slots` 注册全部正常,单元测试通过率 100%。
|
|
84
|
+
|
|
85
|
+
- `peerDependencies` 只声明**下界**(`>=0.1.5-rc.2`,无上界),以便跟随 DSH 的快速迭代:DSH 的兼容性判定用运行时版本比对这些范围,新版本不会再被旧插件卡住。
|
|
86
|
+
- 画板地址解析直接复用 DSH 官方 `parseFileAddress`(`@deepseek-ai/dsh-util-workspace-path`),完整支持 `dsh-resource://file/session/<sid>/<path>` 与 `dsh-resource://file/absolute/<path>` 两种 scope;`absolute` 地址只有在文件位于当前会话工作区内时才能打开,工作区外会给出明确提示,服务端仍会二次校验边界。
|
|
82
87
|
|
|
83
88
|
当前内置的精简 Draw.io runtime 固定为 31.4.5。包含完整满血版内置图库(通用、流程图、UML、ER、BPMN、网络、Kubernetes、AWS、GCP、Cisco、电子、平面图等全部分类均可自由开启)以及 PlantUML 离线渲染模块;
|
|
84
89
|
云盘、在线图库、模板库、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.5/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";
|
|
@@ -73,20 +172,23 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
|
|
|
73
172
|
const resourceCwd = useSessions((sessions) => resource?.sessionId ? sessions?.byId?.[resource.sessionId]?.cwd : void 0);
|
|
74
173
|
const cwd = activeCwd || resourceCwd;
|
|
75
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;
|
|
76
178
|
const [file, setFile] = (0, import_react.useState)(null);
|
|
77
179
|
const [error, setError] = (0, import_react.useState)(null);
|
|
78
|
-
const key = `${effectiveSessionId}\0${cwd ?? ""}\0${
|
|
180
|
+
const key = `${effectiveSessionId}\0${cwd ?? ""}\0${openPath ?? ""}`;
|
|
79
181
|
(0, import_react.useEffect)(() => {
|
|
80
|
-
if (!
|
|
182
|
+
if (!openPath || !effectiveSessionId || !cwd) return;
|
|
81
183
|
const controller = new AbortController();
|
|
82
184
|
setError(null);
|
|
83
|
-
void fsRead({ sessionId: effectiveSessionId, cwd },
|
|
185
|
+
void fsRead({ sessionId: effectiveSessionId, cwd }, openPath, controller.signal).then((res) => {
|
|
84
186
|
if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version });
|
|
85
187
|
}).catch((reason) => {
|
|
86
188
|
if (!controller.signal.aborted) setError(messageOf(reason));
|
|
87
189
|
});
|
|
88
190
|
return () => controller.abort();
|
|
89
|
-
}, [key,
|
|
191
|
+
}, [key, openPath, effectiveSessionId, cwd]);
|
|
90
192
|
if (!resource || !resource.path) {
|
|
91
193
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
92
194
|
CenteredMessage,
|
|
@@ -105,9 +207,19 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
|
|
|
105
207
|
}
|
|
106
208
|
);
|
|
107
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" });
|
|
108
220
|
if (error) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u753B\u677F\u8F7D\u5165\u5931\u8D25", detail: error });
|
|
109
|
-
if (file?.key !== key) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u6B63\u5728\u8F7D\u5165\u753B\u677F", detail: fileName(
|
|
110
|
-
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 } });
|
|
111
223
|
}
|
|
112
224
|
function DrawioCanvas({ content, version, truncated, path, scope }) {
|
|
113
225
|
const frame = (0, import_react.useRef)(null);
|
|
@@ -228,6 +340,10 @@ function DrawioCanvas({ content, version, truncated, path, scope }) {
|
|
|
228
340
|
}, []);
|
|
229
341
|
const startExport = (0, import_react.useCallback)((target) => {
|
|
230
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
|
+
}
|
|
231
347
|
const ext = exportFormat === "svg" ? ".svg" : exportFormat === "xmlpng" ? ".drawio.png" : ".png";
|
|
232
348
|
const base = path.replace(/\.drawio$/i, "");
|
|
233
349
|
const targetPath = `${base}${ext}`;
|
|
@@ -246,7 +362,7 @@ function DrawioCanvas({ content, version, truncated, path, scope }) {
|
|
|
246
362
|
border: 10,
|
|
247
363
|
spin: "\u6B63\u5728\u6E32\u67D3\u5BFC\u51FA\u56FE\u7247..."
|
|
248
364
|
});
|
|
249
|
-
}, [ready, isExporting, exportFormat, isTransparent, path, post]);
|
|
365
|
+
}, [ready, isExporting, exportFormat, isTransparent, path, post, showToast]);
|
|
250
366
|
const handleExportResult = (0, import_react.useCallback)(async (pending, data) => {
|
|
251
367
|
try {
|
|
252
368
|
if (pending.target === "download") {
|
|
@@ -260,7 +376,7 @@ function DrawioCanvas({ content, version, truncated, path, scope }) {
|
|
|
260
376
|
} else if (pending.target === "clipboard") {
|
|
261
377
|
try {
|
|
262
378
|
if (pending.format === "svg") {
|
|
263
|
-
const svgText =
|
|
379
|
+
const svgText = svgMarkupOf(data) ?? data;
|
|
264
380
|
await navigator.clipboard.writeText(svgText);
|
|
265
381
|
showToast("\u5DF2\u590D\u5236 SVG \u4EE3\u7801\u5230\u526A\u8D34\u677F");
|
|
266
382
|
} else {
|
|
@@ -419,19 +535,24 @@ function DrawioCanvas({ content, version, truncated, path, scope }) {
|
|
|
419
535
|
),
|
|
420
536
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "\u900F\u660E\u80CC\u666F" })
|
|
421
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,
|
|
422
539
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.actionCol, children: [
|
|
423
540
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
424
541
|
"button",
|
|
425
542
|
{
|
|
426
543
|
type: "button",
|
|
427
544
|
onClick: () => startExport("workspace"),
|
|
428
|
-
|
|
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
|
+
},
|
|
429
551
|
children: [
|
|
430
552
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(SaveIcon, { size: 13 }),
|
|
431
553
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
|
|
432
|
-
"\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A
|
|
433
|
-
targetExt
|
|
434
|
-
")"
|
|
554
|
+
"\u4FDD\u5B58\u5230\u5DE5\u4F5C\u533A ",
|
|
555
|
+
exportFormat === "svg" ? `(${targetExt})` : "(\u4EC5 SVG)"
|
|
435
556
|
] })
|
|
436
557
|
]
|
|
437
558
|
}
|
|
@@ -585,41 +706,6 @@ function CenteredMessage({ title, detail }) {
|
|
|
585
706
|
] });
|
|
586
707
|
}
|
|
587
708
|
var fileName = (path) => path.replace(/\\/g, "/").split("/").pop() || path;
|
|
588
|
-
function parseDrawioAddress(address, fallbackSessionId) {
|
|
589
|
-
if (!address || typeof address !== "string") return null;
|
|
590
|
-
try {
|
|
591
|
-
let clean = address.trim();
|
|
592
|
-
let parsedSessionId = "";
|
|
593
|
-
if (clean.startsWith("dsh-resource://file/session/")) {
|
|
594
|
-
const rest = clean.slice("dsh-resource://file/session/".length);
|
|
595
|
-
const slashIdx = rest.indexOf("/");
|
|
596
|
-
if (slashIdx >= 0) {
|
|
597
|
-
parsedSessionId = decodeURIComponent(rest.slice(0, slashIdx));
|
|
598
|
-
clean = rest.slice(slashIdx + 1);
|
|
599
|
-
} else {
|
|
600
|
-
clean = rest;
|
|
601
|
-
}
|
|
602
|
-
} else if (clean.startsWith("dsh-resource://file/")) {
|
|
603
|
-
clean = clean.slice("dsh-resource://file/".length);
|
|
604
|
-
} else if (clean.startsWith("file:///")) {
|
|
605
|
-
clean = clean.slice("file:///".length);
|
|
606
|
-
}
|
|
607
|
-
let decodedPath = clean;
|
|
608
|
-
try {
|
|
609
|
-
decodedPath = decodeURIComponent(clean);
|
|
610
|
-
} catch {
|
|
611
|
-
}
|
|
612
|
-
decodedPath = decodedPath.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
613
|
-
if (!/\.drawio$/i.test(decodedPath)) return null;
|
|
614
|
-
if (decodedPath.includes("\0")) return null;
|
|
615
|
-
return {
|
|
616
|
-
sessionId: parsedSessionId || fallbackSessionId || "",
|
|
617
|
-
path: decodedPath
|
|
618
|
-
};
|
|
619
|
-
} catch {
|
|
620
|
-
return null;
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
709
|
var messageOf = (reason) => reason instanceof Error ? reason.message : String(reason);
|
|
624
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";
|
|
625
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.3",
|
|
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/
|
|
54
|
+
"@deepseek-ai/dsh-util-workspace-path": "0.2.0-rc.2",
|
|
55
|
+
"@deepseek-ai/schemastery": "~3.18.5",
|
|
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.1
|
|
72
|
-
"@deepseek-ai/dsh-host-webserver": "0.1
|
|
73
|
-
"@deepseek-ai/dsh-sandbox": "0.1
|
|
74
|
-
"@deepseek-ai/dsh-tools": "0.1
|
|
71
|
+
"@deepseek-ai/cordis": "4.0.5",
|
|
72
|
+
"@deepseek-ai/dsh-fs": "0.2.1-rc.2",
|
|
73
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.1-rc.2",
|
|
74
|
+
"@deepseek-ai/dsh-sandbox": "0.2.1-rc.2",
|
|
75
|
+
"@deepseek-ai/dsh-tools": "0.2.1-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'
|
|
@@ -51,19 +53,25 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX
|
|
|
51
53
|
const cwd = activeCwd || resourceCwd
|
|
52
54
|
const effectiveSessionId = sessionId || resource?.sessionId || ''
|
|
53
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
|
+
|
|
54
62
|
const [file, setFile] = useState<{ key: string; content: string; version?: string } | null>(null)
|
|
55
63
|
const [error, setError] = useState<string | null>(null)
|
|
56
|
-
const key = `${effectiveSessionId}\0${cwd ?? ''}\0${
|
|
64
|
+
const key = `${effectiveSessionId}\0${cwd ?? ''}\0${openPath ?? ''}`
|
|
57
65
|
|
|
58
66
|
useEffect(() => {
|
|
59
|
-
if (!
|
|
67
|
+
if (!openPath || !effectiveSessionId || !cwd) return
|
|
60
68
|
const controller = new AbortController()
|
|
61
69
|
setError(null)
|
|
62
|
-
void fsRead({ sessionId: effectiveSessionId, cwd },
|
|
70
|
+
void fsRead({ sessionId: effectiveSessionId, cwd }, openPath, controller.signal)
|
|
63
71
|
.then(res => { if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version }) })
|
|
64
72
|
.catch(reason => { if (!controller.signal.aborted) setError(messageOf(reason)) })
|
|
65
73
|
return () => controller.abort()
|
|
66
|
-
}, [key,
|
|
74
|
+
}, [key, openPath, effectiveSessionId, cwd])
|
|
67
75
|
|
|
68
76
|
if (!resource || !resource.path) {
|
|
69
77
|
return (
|
|
@@ -81,9 +89,18 @@ function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX
|
|
|
81
89
|
/>
|
|
82
90
|
)
|
|
83
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="无法确定画板文件的工作区相对路径。" />
|
|
84
101
|
if (error) return <CenteredMessage title="画板载入失败" detail={error} />
|
|
85
|
-
if (file?.key !== key) return <CenteredMessage title="正在载入画板" detail={fileName(
|
|
86
|
-
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 }} />
|
|
87
104
|
}
|
|
88
105
|
|
|
89
106
|
function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerProps): JSX.Element {
|
|
@@ -215,6 +232,13 @@ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerPr
|
|
|
215
232
|
|
|
216
233
|
const startExport = useCallback((target: 'workspace' | 'download' | 'clipboard') => {
|
|
217
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
|
+
}
|
|
218
242
|
const ext = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png'
|
|
219
243
|
const base = path.replace(/\.drawio$/i, '')
|
|
220
244
|
const targetPath = `${base}${ext}`
|
|
@@ -234,7 +258,7 @@ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerPr
|
|
|
234
258
|
border: 10,
|
|
235
259
|
spin: '正在渲染导出图片...',
|
|
236
260
|
})
|
|
237
|
-
}, [ready, isExporting, exportFormat, isTransparent, path, post])
|
|
261
|
+
}, [ready, isExporting, exportFormat, isTransparent, path, post, showToast])
|
|
238
262
|
|
|
239
263
|
const handleExportResult = useCallback(async (pending: { format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string }, data: string) => {
|
|
240
264
|
try {
|
|
@@ -249,9 +273,7 @@ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerPr
|
|
|
249
273
|
} else if (pending.target === 'clipboard') {
|
|
250
274
|
try {
|
|
251
275
|
if (pending.format === 'svg') {
|
|
252
|
-
const svgText =
|
|
253
|
-
? decodeURIComponent(data.split(',')[1] || '')
|
|
254
|
-
: data
|
|
276
|
+
const svgText = svgMarkupOf(data) ?? data
|
|
255
277
|
await navigator.clipboard.writeText(svgText)
|
|
256
278
|
showToast('已复制 SVG 代码到剪贴板')
|
|
257
279
|
} else {
|
|
@@ -423,14 +445,23 @@ function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerPr
|
|
|
423
445
|
</label>
|
|
424
446
|
) : null}
|
|
425
447
|
|
|
448
|
+
{exportFormat !== 'svg' ? (
|
|
449
|
+
<span style={styles.menuSub}>保存到工作区仅支持 SVG;PNG / XML-PNG 请用下载或剪贴板。</span>
|
|
450
|
+
) : null}
|
|
451
|
+
|
|
426
452
|
<div style={styles.actionCol}>
|
|
427
453
|
<button
|
|
428
454
|
type="button"
|
|
429
455
|
onClick={() => startExport('workspace')}
|
|
430
|
-
|
|
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
|
+
}}
|
|
431
462
|
>
|
|
432
463
|
<SaveIcon size={13} />
|
|
433
|
-
<span>保存到工作区 ({targetExt})</span>
|
|
464
|
+
<span>保存到工作区 {exportFormat === 'svg' ? `(${targetExt})` : '(仅 SVG)'}</span>
|
|
434
465
|
</button>
|
|
435
466
|
|
|
436
467
|
<button
|
|
@@ -576,51 +607,6 @@ function CenteredMessage({ title, detail }: { title: string; detail: string }):
|
|
|
576
607
|
}
|
|
577
608
|
|
|
578
609
|
const fileName = (path: string): string => path.replace(/\\/g, '/').split('/').pop() || path
|
|
579
|
-
|
|
580
|
-
export function parseDrawioAddress(address?: string, fallbackSessionId?: string): { sessionId: string; path: string } | null {
|
|
581
|
-
if (!address || typeof address !== 'string') return null
|
|
582
|
-
try {
|
|
583
|
-
let clean = address.trim()
|
|
584
|
-
let parsedSessionId = ''
|
|
585
|
-
|
|
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
|
-
|
|
601
|
-
// 处理可能是 URL 编码的 path(支持嵌套目录 %2F)
|
|
602
|
-
let decodedPath = clean
|
|
603
|
-
try {
|
|
604
|
-
decodedPath = decodeURIComponent(clean)
|
|
605
|
-
} catch {}
|
|
606
|
-
|
|
607
|
-
// 规范化斜杠并去除开头的斜杠
|
|
608
|
-
decodedPath = decodedPath.replace(/\\/g, '/').replace(/^\/+/, '')
|
|
609
|
-
|
|
610
|
-
// 必须是 .drawio 文件
|
|
611
|
-
if (!/\.drawio$/i.test(decodedPath)) return null
|
|
612
|
-
|
|
613
|
-
// 防空字符
|
|
614
|
-
if (decodedPath.includes('\0')) return null
|
|
615
|
-
|
|
616
|
-
return {
|
|
617
|
-
sessionId: parsedSessionId || fallbackSessionId || '',
|
|
618
|
-
path: decodedPath,
|
|
619
|
-
}
|
|
620
|
-
} catch {
|
|
621
|
-
return null
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
610
|
const messageOf = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason)
|
|
625
611
|
const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'conflict' ? '检测到冲突' : state === 'error' ? '保存失败' : '已保存'
|
|
626
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
|
+
}
|