@civilization/dsh-drawio 0.1.0 → 0.2.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 +7 -4
- package/lib/client.js +124 -30
- package/package.json +2 -4
- package/src/client/index.tsx +135 -30
- package/src/index.js +64 -0
- package/vendor/drawio/js/PreConfig.js +8 -2
- package/vendor/drawio/js/plantuml/drawio-plantuml.min.js +353 -0
- package/vendor/drawio/service-worker.js +7 -947
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
## 边界
|
|
7
7
|
|
|
8
|
-
-
|
|
8
|
+
- 不引入项目、目录或文件管理;直接使用 DSH 官方右侧栏与文件工作台。
|
|
9
9
|
- 任意 `.drawio` 文件都由画板打开,可停靠、拆分或浮动。
|
|
10
10
|
- 编辑器资源由 DSH Host 从本机提供,不连接 diagrams.net。
|
|
11
11
|
- 用户保存和 Agent 工具修改的是同一份 XML 文件。
|
|
@@ -31,8 +31,11 @@ dsh plugin --profile web add D:\project\dsh-drawio --registry=https://registry.n
|
|
|
31
31
|
dsh web --port 3082 --no-open
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
客户端只注入 DSH 官方的 `slots` 与 `sidebarRightTabs` 服务。文件工作台打开
|
|
35
|
+
`.drawio` 文件时,由官方资源路由选择本插件的画板;读取和自动保存通过插件自有的
|
|
36
|
+
`/dsh-drawio/api` 接口完成,并限制在当前会话工作区内。
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
已在 DSH 0.1.5-rc.2 上验证构建、XML 操作、本地静态资源加载和官方右侧栏注册。
|
|
39
|
+
|
|
40
|
+
当前内置的精简 Draw.io runtime 固定为 31.4.5。包含完整满血版内置图库(通用、流程图、UML、ER、BPMN、网络、Kubernetes、AWS、GCP、Cisco、电子、平面图等全部分类均可自由开启)以及 PlantUML 离线渲染模块;
|
|
38
41
|
云盘、在线图库、模板库、VSDX/Gliffy 导入和服务端导出未包含。
|
package/lib/client.js
CHANGED
|
@@ -28,20 +28,57 @@ __export(index_exports, {
|
|
|
28
28
|
module.exports = __toCommonJS(index_exports);
|
|
29
29
|
var import_react = require("react");
|
|
30
30
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
31
|
-
var inject = ["
|
|
32
|
-
var
|
|
31
|
+
var inject = ["slots", "sidebarRightTabs"];
|
|
32
|
+
var TAB_ID = "@civilization/dsh-drawio";
|
|
33
|
+
var TAB_KIND = "drawio";
|
|
33
34
|
function apply(ctx) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
35
|
+
const official = ctx;
|
|
36
|
+
ctx.effect(() => official.sidebarRightTabs.register({
|
|
37
|
+
id: TAB_ID,
|
|
38
|
+
kind: TAB_KIND,
|
|
39
|
+
patterns: ["*.drawio"],
|
|
40
|
+
priority: "extension",
|
|
41
|
+
title: (address) => fileName(parseDrawioAddress(address)?.path ?? "Draw.io")
|
|
42
|
+
}), "dsh-drawio: official tab definition");
|
|
43
|
+
ctx.effect(() => official.slots.inject("sidebar.right.pane.tab", () => official.slots.register({
|
|
44
|
+
name: "sidebar.right.pane.tab",
|
|
45
|
+
key: TAB_ID
|
|
46
|
+
}, OfficialDrawioTabBody)), "dsh-drawio: official tab body");
|
|
47
|
+
ctx.effect(() => official.slots.inject("sidebar.right.pane.tab.title", () => official.slots.register({
|
|
48
|
+
name: "sidebar.right.pane.tab.title",
|
|
49
|
+
key: TAB_ID
|
|
50
|
+
}, OfficialDrawioTabTitle)), "dsh-drawio: official tab title");
|
|
51
|
+
}
|
|
52
|
+
function OfficialDrawioTabTitle({ useTabInfo }) {
|
|
53
|
+
const { tab } = useTabInfo();
|
|
54
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
55
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(CanvasIcon, { size: 15 }),
|
|
56
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: tab.title })
|
|
57
|
+
] });
|
|
58
|
+
}
|
|
59
|
+
function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }) {
|
|
60
|
+
const { tab } = useTabInfo();
|
|
61
|
+
const cwd = useSessions((sessions) => sessions?.byId?.[sessionId]?.cwd);
|
|
62
|
+
const resource = (0, import_react.useMemo)(() => parseDrawioAddress(tab.navigation.address), [tab.navigation.address]);
|
|
63
|
+
const [file, setFile] = (0, import_react.useState)(null);
|
|
64
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
65
|
+
const key = `${sessionId}\0${cwd ?? ""}\0${resource?.path ?? ""}`;
|
|
66
|
+
(0, import_react.useEffect)(() => {
|
|
67
|
+
if (!resource || resource.sessionId !== sessionId || !cwd) return;
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
setError(null);
|
|
70
|
+
void fsRead({ sessionId, cwd }, resource.path, controller.signal).then((content) => {
|
|
71
|
+
if (!controller.signal.aborted) setFile({ key, content });
|
|
72
|
+
}).catch((reason) => {
|
|
73
|
+
if (!controller.signal.aborted) setError(messageOf(reason));
|
|
74
|
+
});
|
|
75
|
+
return () => controller.abort();
|
|
76
|
+
}, [key, resource?.sessionId, resource?.path, sessionId, cwd]);
|
|
77
|
+
if (!resource || resource.sessionId !== sessionId) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F", detail: "\u6587\u4EF6\u5730\u5740\u4E0D\u662F\u5F53\u524D\u4F1A\u8BDD\u4E2D\u7684 Draw.io \u6587\u4EF6\u3002" });
|
|
78
|
+
if (!cwd) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u65E0\u6CD5\u6253\u5F00\u753B\u677F", detail: "\u5F53\u524D\u4F1A\u8BDD\u6CA1\u6709\u53EF\u7528\u7684\u5DE5\u4F5C\u533A\u3002" });
|
|
79
|
+
if (error) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CenteredMessage, { title: "\u753B\u677F\u8F7D\u5165\u5931\u8D25", detail: error });
|
|
80
|
+
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 } });
|
|
45
82
|
}
|
|
46
83
|
function DrawioCanvas({ content, truncated, path, scope }) {
|
|
47
84
|
const frame = (0, import_react.useRef)(null);
|
|
@@ -52,6 +89,8 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
52
89
|
const [ready, setReady] = (0, import_react.useState)(false);
|
|
53
90
|
const [saveState, setSaveState] = (0, import_react.useState)("loading");
|
|
54
91
|
const [error, setError] = (0, import_react.useState)(truncated ? "\u6587\u4EF6\u8FC7\u5927\uFF0CDSH \u53EA\u8FD4\u56DE\u4E86\u622A\u65AD\u5185\u5BB9\uFF0C\u65E0\u6CD5\u5B89\u5168\u6253\u5F00\u3002" : null);
|
|
92
|
+
const lastUserActivity = (0, import_react.useRef)(0);
|
|
93
|
+
const isInteracting = (0, import_react.useRef)(false);
|
|
55
94
|
const { dark, accent: accentColor } = useDshTheme();
|
|
56
95
|
const src = (0, import_react.useMemo)(() => `/dsh-drawio/runtime/index.html?embed=1&proto=json&spin=1&offline=1&local=1&ui=min&libraries=1&configure=1&noExitBtn=1&saveAndExit=0&dark=${dark ? "1" : "0"}`, [dark]);
|
|
57
96
|
const post = (0, import_react.useCallback)((message) => {
|
|
@@ -74,17 +113,55 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
74
113
|
}
|
|
75
114
|
}, [scope.sessionId, scope.cwd, path]);
|
|
76
115
|
const scheduleSave = (0, import_react.useCallback)((xml, immediate = false) => {
|
|
116
|
+
lastUserActivity.current = Date.now();
|
|
77
117
|
latestEditor.current = xml;
|
|
78
|
-
if (saveTimer.current !== void 0)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
118
|
+
if (saveTimer.current !== void 0) {
|
|
119
|
+
window.clearTimeout(saveTimer.current);
|
|
120
|
+
saveTimer.current = void 0;
|
|
121
|
+
}
|
|
122
|
+
if (immediate) {
|
|
123
|
+
void persist(xml);
|
|
124
|
+
} else {
|
|
125
|
+
saveTimer.current = window.setTimeout(() => {
|
|
126
|
+
saveTimer.current = void 0;
|
|
127
|
+
void persist(latestEditor.current);
|
|
128
|
+
}, 350);
|
|
129
|
+
}
|
|
83
130
|
}, [persist]);
|
|
84
131
|
(0, import_react.useEffect)(() => {
|
|
85
132
|
latestFile.current = content ?? "";
|
|
86
133
|
latestEditor.current = content ?? "";
|
|
87
134
|
}, [content, path]);
|
|
135
|
+
const markActivity = (0, import_react.useCallback)(() => {
|
|
136
|
+
lastUserActivity.current = Date.now();
|
|
137
|
+
}, []);
|
|
138
|
+
(0, import_react.useEffect)(() => {
|
|
139
|
+
const attached = /* @__PURE__ */ new WeakSet();
|
|
140
|
+
const handleFrameAttach = () => {
|
|
141
|
+
try {
|
|
142
|
+
const doc = frame.current?.contentDocument;
|
|
143
|
+
if (!doc || attached.has(doc)) return;
|
|
144
|
+
attached.add(doc);
|
|
145
|
+
const onDown = () => {
|
|
146
|
+
isInteracting.current = true;
|
|
147
|
+
lastUserActivity.current = Date.now();
|
|
148
|
+
};
|
|
149
|
+
const onUp = () => {
|
|
150
|
+
isInteracting.current = false;
|
|
151
|
+
lastUserActivity.current = Date.now();
|
|
152
|
+
};
|
|
153
|
+
const onKey = () => {
|
|
154
|
+
lastUserActivity.current = Date.now();
|
|
155
|
+
};
|
|
156
|
+
doc.addEventListener("pointerdown", onDown, true);
|
|
157
|
+
doc.addEventListener("pointerup", onUp, true);
|
|
158
|
+
doc.addEventListener("keydown", onKey, true);
|
|
159
|
+
} catch {
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const timer = window.setInterval(handleFrameAttach, 800);
|
|
163
|
+
return () => window.clearInterval(timer);
|
|
164
|
+
}, []);
|
|
88
165
|
(0, import_react.useEffect)(() => {
|
|
89
166
|
setReady(false);
|
|
90
167
|
setSaveState("loading");
|
|
@@ -97,7 +174,7 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
97
174
|
return;
|
|
98
175
|
}
|
|
99
176
|
if (message.event === "configure") {
|
|
100
|
-
post({ action: "configure", config: { compressXml: false, enableCssDarkMode: true, defaultLibraries: "general;flowchart;basic;arrows2", enabledLibraries:
|
|
177
|
+
post({ action: "configure", config: { compressXml: false, enableCssDarkMode: true, defaultLibraries: "general;uml;er;bpmn;flowchart;basic;arrows2", enabledLibraries: null } });
|
|
101
178
|
} else if (message.event === "init") {
|
|
102
179
|
post({ action: "load", xml: latestFile.current, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 });
|
|
103
180
|
setReady(true);
|
|
@@ -113,9 +190,14 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
113
190
|
if (!ready || truncated) return;
|
|
114
191
|
let cancelled = false;
|
|
115
192
|
const poll = async () => {
|
|
193
|
+
if (isInteracting.current) return;
|
|
194
|
+
if (Date.now() - lastUserActivity.current < 1500) return;
|
|
195
|
+
if (saveTimer.current !== void 0) return;
|
|
196
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
116
197
|
try {
|
|
117
198
|
const xml = await fsRead(scope, path);
|
|
118
|
-
if (cancelled ||
|
|
199
|
+
if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return;
|
|
200
|
+
if (xml === latestFile.current || xml === latestEditor.current) return;
|
|
119
201
|
latestFile.current = xml;
|
|
120
202
|
latestEditor.current = xml;
|
|
121
203
|
post({ action: "load", xml, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 });
|
|
@@ -129,7 +211,7 @@ function DrawioCanvas({ content, truncated, path, scope }) {
|
|
|
129
211
|
};
|
|
130
212
|
const timer = window.setInterval(() => {
|
|
131
213
|
void poll();
|
|
132
|
-
},
|
|
214
|
+
}, 2500);
|
|
133
215
|
return () => {
|
|
134
216
|
cancelled = true;
|
|
135
217
|
window.clearInterval(timer);
|
|
@@ -187,22 +269,21 @@ function detectDark() {
|
|
|
187
269
|
if (numbers?.length === 3) return numbers[0] * 0.299 + numbers[1] * 0.587 + numbers[2] * 0.114 < 128;
|
|
188
270
|
return document.documentElement.classList.contains("dark") || window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
189
271
|
}
|
|
190
|
-
async function fsRead(scope, path) {
|
|
191
|
-
const value = await call("
|
|
192
|
-
|
|
193
|
-
return value.content ?? "";
|
|
272
|
+
async function fsRead(scope, path, signal) {
|
|
273
|
+
const value = await call("read", scope, { path }, false, signal);
|
|
274
|
+
return value.content;
|
|
194
275
|
}
|
|
195
276
|
async function fsWrite(scope, path, content, keepalive = false) {
|
|
196
|
-
await call("
|
|
277
|
+
await call("write", scope, { path, content }, keepalive);
|
|
197
278
|
}
|
|
198
|
-
async function call(method, scope, extra, keepalive = false) {
|
|
199
|
-
const response = await fetch(`/
|
|
279
|
+
async function call(method, scope, extra, keepalive = false, signal) {
|
|
280
|
+
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 });
|
|
200
281
|
const envelope = await response.json().catch(() => null);
|
|
201
282
|
if (!response.ok || envelope?.ok !== true || envelope.value === void 0) throw new Error(envelope?.error?.message ?? `HTTP ${response.status}`);
|
|
202
283
|
return envelope.value;
|
|
203
284
|
}
|
|
204
|
-
function CanvasIcon() {
|
|
205
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { "aria-hidden": true, viewBox: "0 0 20 20", width:
|
|
285
|
+
function CanvasIcon({ size = 16, className }) {
|
|
286
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { className, "aria-hidden": true, viewBox: "0 0 20 20", width: size, height: size, fill: "none", children: [
|
|
206
287
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", { x: "2.5", y: "2.5", width: "15", height: "15", rx: "2.5", stroke: "currentColor" }),
|
|
207
288
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: "7", cy: "7", r: "1.5", fill: "currentColor" }),
|
|
208
289
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: "13", cy: "13", r: "1.5", fill: "currentColor" }),
|
|
@@ -216,6 +297,19 @@ function CenteredMessage({ title, detail }) {
|
|
|
216
297
|
] });
|
|
217
298
|
}
|
|
218
299
|
var fileName = (path) => path.replace(/\\/g, "/").split("/").pop() || path;
|
|
300
|
+
function parseDrawioAddress(address) {
|
|
301
|
+
const prefix = "dsh-resource://file/session/";
|
|
302
|
+
if (!address.startsWith(prefix)) return null;
|
|
303
|
+
try {
|
|
304
|
+
const parts = address.slice(prefix.length).split("/").map(decodeURIComponent);
|
|
305
|
+
const sessionId = parts.shift() ?? "";
|
|
306
|
+
const path = parts.join("/");
|
|
307
|
+
if (!sessionId || !path || !/\.drawio$/i.test(path) || parts.some((part) => part.includes("/") || part.includes("\\") || part.includes("\0"))) return null;
|
|
308
|
+
return { sessionId, path };
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
219
313
|
var messageOf = (reason) => reason instanceof Error ? reason.message : String(reason);
|
|
220
314
|
var statusText = (state) => state === "loading" ? "\u6B63\u5728\u8F7D\u5165" : state === "saving" ? "\u6B63\u5728\u4FDD\u5B58" : state === "external" ? "\u5DF2\u540C\u6B65 AI \u4FEE\u6539" : state === "error" ? "\u4FDD\u5B58\u5931\u8D25" : "\u5DF2\u4FDD\u5B58";
|
|
221
315
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Local draw.io canvas and model tools for DeepSeek Harness",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -59,8 +59,7 @@
|
|
|
59
59
|
"@deepseek-ai/dsh-fs": "^0.1.2-rc.1",
|
|
60
60
|
"@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
|
|
61
61
|
"@deepseek-ai/dsh-sandbox": "^0.1.2-rc.1",
|
|
62
|
-
"@deepseek-ai/dsh-tools": "^0.1.2-rc.1"
|
|
63
|
-
"dsh-better-sidebar": ">=0.18.0"
|
|
62
|
+
"@deepseek-ai/dsh-tools": "^0.1.2-rc.1"
|
|
64
63
|
},
|
|
65
64
|
"devDependencies": {
|
|
66
65
|
"@deepseek-ai/cordis": "4.0.2",
|
|
@@ -69,7 +68,6 @@
|
|
|
69
68
|
"@deepseek-ai/dsh-sandbox": "0.1.2-rc.1",
|
|
70
69
|
"@deepseek-ai/dsh-tools": "0.1.2-rc.1",
|
|
71
70
|
"@types/react": "18.3.31",
|
|
72
|
-
"dsh-better-sidebar": "0.18.0",
|
|
73
71
|
"esbuild": "0.28.2",
|
|
74
72
|
"react": "18.3.1",
|
|
75
73
|
"typescript": "5.9.3"
|
package/src/client/index.tsx
CHANGED
|
@@ -1,23 +1,61 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis'
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
|
-
import type {} from 'dsh-better-sidebar'
|
|
4
|
-
import type { FileViewerProps, SessionScope } from 'dsh-better-sidebar/client/service'
|
|
5
3
|
|
|
6
|
-
export const inject = ['
|
|
7
|
-
const
|
|
4
|
+
export const inject = ['slots', 'sidebarRightTabs']
|
|
5
|
+
const TAB_ID = '@civilization/dsh-drawio'
|
|
6
|
+
const TAB_KIND = 'drawio'
|
|
7
|
+
|
|
8
|
+
interface SessionScope { sessionId: string; cwd?: string }
|
|
9
|
+
interface FileViewerProps { content?: string; truncated?: boolean; path: string; scope: SessionScope }
|
|
10
|
+
interface OfficialContext extends Context { slots: any; sidebarRightTabs: any }
|
|
8
11
|
|
|
9
12
|
export function apply(ctx: Context): void {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
const official = ctx as OfficialContext
|
|
14
|
+
ctx.effect(() => official.sidebarRightTabs.register({
|
|
15
|
+
id: TAB_ID,
|
|
16
|
+
kind: TAB_KIND,
|
|
17
|
+
patterns: ['*.drawio'],
|
|
18
|
+
priority: 'extension',
|
|
19
|
+
title: (address: string) => fileName(parseDrawioAddress(address)?.path ?? 'Draw.io'),
|
|
20
|
+
}), 'dsh-drawio: official tab definition')
|
|
21
|
+
ctx.effect(() => official.slots.inject('sidebar.right.pane.tab', () => official.slots.register({
|
|
22
|
+
name: 'sidebar.right.pane.tab',
|
|
23
|
+
key: TAB_ID,
|
|
24
|
+
}, OfficialDrawioTabBody)), 'dsh-drawio: official tab body')
|
|
25
|
+
ctx.effect(() => official.slots.inject('sidebar.right.pane.tab.title', () => official.slots.register({
|
|
26
|
+
name: 'sidebar.right.pane.tab.title',
|
|
27
|
+
key: TAB_ID,
|
|
28
|
+
}, OfficialDrawioTabTitle)), 'dsh-drawio: official tab title')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function OfficialDrawioTabTitle({ useTabInfo }: any): JSX.Element {
|
|
32
|
+
const { tab } = useTabInfo()
|
|
33
|
+
return <><CanvasIcon size={15} /><span>{tab.title}</span></>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX.Element {
|
|
37
|
+
const { tab } = useTabInfo()
|
|
38
|
+
const cwd = useSessions((sessions: any) => sessions?.byId?.[sessionId]?.cwd) as string | undefined
|
|
39
|
+
const resource = useMemo(() => parseDrawioAddress(tab.navigation.address), [tab.navigation.address])
|
|
40
|
+
const [file, setFile] = useState<{ key: string; content: string } | null>(null)
|
|
41
|
+
const [error, setError] = useState<string | null>(null)
|
|
42
|
+
const key = `${sessionId}\0${cwd ?? ''}\0${resource?.path ?? ''}`
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!resource || resource.sessionId !== sessionId || !cwd) return
|
|
46
|
+
const controller = new AbortController()
|
|
47
|
+
setError(null)
|
|
48
|
+
void fsRead({ sessionId, cwd }, resource.path, controller.signal)
|
|
49
|
+
.then(content => { if (!controller.signal.aborted) setFile({ key, content }) })
|
|
50
|
+
.catch(reason => { if (!controller.signal.aborted) setError(messageOf(reason)) })
|
|
51
|
+
return () => controller.abort()
|
|
52
|
+
}, [key, resource?.sessionId, resource?.path, sessionId, cwd])
|
|
53
|
+
|
|
54
|
+
if (!resource || resource.sessionId !== sessionId) return <CenteredMessage title="无法打开画板" detail="文件地址不是当前会话中的 Draw.io 文件。" />
|
|
55
|
+
if (!cwd) return <CenteredMessage title="无法打开画板" detail="当前会话没有可用的工作区。" />
|
|
56
|
+
if (error) return <CenteredMessage title="画板载入失败" detail={error} />
|
|
57
|
+
if (file?.key !== key) return <CenteredMessage title="正在载入画板" detail={fileName(resource.path)} />
|
|
58
|
+
return <DrawioCanvas content={file.content} path={resource.path} scope={{ sessionId, cwd }} />
|
|
21
59
|
}
|
|
22
60
|
|
|
23
61
|
function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX.Element {
|
|
@@ -29,6 +67,8 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
29
67
|
const [ready, setReady] = useState(false)
|
|
30
68
|
const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'external' | 'error'>('loading')
|
|
31
69
|
const [error, setError] = useState<string | null>(truncated ? '文件过大,DSH 只返回了截断内容,无法安全打开。' : null)
|
|
70
|
+
const lastUserActivity = useRef(0)
|
|
71
|
+
const isInteracting = useRef(false)
|
|
32
72
|
const { dark, accent: accentColor } = useDshTheme()
|
|
33
73
|
const src = useMemo(() => `/dsh-drawio/runtime/index.html?embed=1&proto=json&spin=1&offline=1&local=1&ui=min&libraries=1&configure=1&noExitBtn=1&saveAndExit=0&dark=${dark ? '1' : '0'}`, [dark])
|
|
34
74
|
|
|
@@ -54,10 +94,20 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
54
94
|
}, [scope.sessionId, scope.cwd, path])
|
|
55
95
|
|
|
56
96
|
const scheduleSave = useCallback((xml: string, immediate = false) => {
|
|
97
|
+
lastUserActivity.current = Date.now()
|
|
57
98
|
latestEditor.current = xml
|
|
58
|
-
if (saveTimer.current !== undefined)
|
|
59
|
-
|
|
60
|
-
|
|
99
|
+
if (saveTimer.current !== undefined) {
|
|
100
|
+
window.clearTimeout(saveTimer.current)
|
|
101
|
+
saveTimer.current = undefined
|
|
102
|
+
}
|
|
103
|
+
if (immediate) {
|
|
104
|
+
void persist(xml)
|
|
105
|
+
} else {
|
|
106
|
+
saveTimer.current = window.setTimeout(() => {
|
|
107
|
+
saveTimer.current = undefined
|
|
108
|
+
void persist(latestEditor.current)
|
|
109
|
+
}, 350)
|
|
110
|
+
}
|
|
61
111
|
}, [persist])
|
|
62
112
|
|
|
63
113
|
useEffect(() => {
|
|
@@ -65,6 +115,38 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
65
115
|
latestEditor.current = content ?? ''
|
|
66
116
|
}, [content, path])
|
|
67
117
|
|
|
118
|
+
const markActivity = useCallback(() => {
|
|
119
|
+
lastUserActivity.current = Date.now()
|
|
120
|
+
}, [])
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const attached = new WeakSet<Document>()
|
|
124
|
+
const handleFrameAttach = () => {
|
|
125
|
+
try {
|
|
126
|
+
const doc = frame.current?.contentDocument
|
|
127
|
+
if (!doc || attached.has(doc)) return
|
|
128
|
+
attached.add(doc)
|
|
129
|
+
const onDown = () => {
|
|
130
|
+
isInteracting.current = true
|
|
131
|
+
lastUserActivity.current = Date.now()
|
|
132
|
+
}
|
|
133
|
+
const onUp = () => {
|
|
134
|
+
isInteracting.current = false
|
|
135
|
+
lastUserActivity.current = Date.now()
|
|
136
|
+
}
|
|
137
|
+
const onKey = () => {
|
|
138
|
+
lastUserActivity.current = Date.now()
|
|
139
|
+
}
|
|
140
|
+
// 仅在用户按住拖拽、松手或输入时感知,单纯鼠标移动(hover)不打断
|
|
141
|
+
doc.addEventListener('pointerdown', onDown, true)
|
|
142
|
+
doc.addEventListener('pointerup', onUp, true)
|
|
143
|
+
doc.addEventListener('keydown', onKey, true)
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
const timer = window.setInterval(handleFrameAttach, 800)
|
|
147
|
+
return () => window.clearInterval(timer)
|
|
148
|
+
}, [])
|
|
149
|
+
|
|
68
150
|
useEffect(() => {
|
|
69
151
|
setReady(false)
|
|
70
152
|
setSaveState('loading')
|
|
@@ -74,7 +156,7 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
74
156
|
try { message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data }
|
|
75
157
|
catch { return }
|
|
76
158
|
if (message.event === 'configure') {
|
|
77
|
-
post({ action: 'configure', config: { compressXml: false, enableCssDarkMode: true, defaultLibraries: 'general;flowchart;basic;arrows2', enabledLibraries:
|
|
159
|
+
post({ action: 'configure', config: { compressXml: false, enableCssDarkMode: true, defaultLibraries: 'general;uml;er;bpmn;flowchart;basic;arrows2', enabledLibraries: null } })
|
|
78
160
|
} else if (message.event === 'init') {
|
|
79
161
|
post({ action: 'load', xml: latestFile.current, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
|
|
80
162
|
setReady(true)
|
|
@@ -91,9 +173,21 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
91
173
|
if (!ready || truncated) return
|
|
92
174
|
let cancelled = false
|
|
93
175
|
const poll = async () => {
|
|
176
|
+
// 1. 用户按住鼠标拖拽中,绝不读取
|
|
177
|
+
if (isInteracting.current) return
|
|
178
|
+
// 2. 刚松手或打字后 1.5 秒内(操作缓冲),暂停轮询
|
|
179
|
+
if (Date.now() - lastUserActivity.current < 1500) return
|
|
180
|
+
// 3. 本地正在防抖保存中,不从磁盘读取
|
|
181
|
+
if (saveTimer.current !== undefined) return
|
|
182
|
+
// 4. 浏览器标签页切后台时静默
|
|
183
|
+
if (typeof document !== 'undefined' && document.hidden) return
|
|
184
|
+
|
|
94
185
|
try {
|
|
95
186
|
const xml = await fsRead(scope, path)
|
|
96
|
-
|
|
187
|
+
// 5. 响应返回后二次防护:若这期间用户按下了鼠标或开始拖动,果断丢弃本次结果
|
|
188
|
+
if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return
|
|
189
|
+
if (xml === latestFile.current || xml === latestEditor.current) return
|
|
190
|
+
|
|
97
191
|
latestFile.current = xml
|
|
98
192
|
latestEditor.current = xml
|
|
99
193
|
post({ action: 'load', xml, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
|
|
@@ -103,7 +197,8 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
103
197
|
if (!cancelled) setError(messageOf(reason))
|
|
104
198
|
}
|
|
105
199
|
}
|
|
106
|
-
|
|
200
|
+
// 空闲时每 2.5 秒正常读取一次
|
|
201
|
+
const timer = window.setInterval(() => { void poll() }, 2500)
|
|
107
202
|
return () => { cancelled = true; window.clearInterval(timer) }
|
|
108
203
|
}, [ready, truncated, scope.sessionId, scope.cwd, path, dark, post])
|
|
109
204
|
|
|
@@ -165,25 +260,24 @@ function detectDark(): boolean {
|
|
|
165
260
|
return document.documentElement.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
166
261
|
}
|
|
167
262
|
|
|
168
|
-
async function fsRead(scope: SessionScope, path: string): Promise<string> {
|
|
169
|
-
const value = await call<{
|
|
170
|
-
|
|
171
|
-
return value.content ?? ''
|
|
263
|
+
async function fsRead(scope: SessionScope, path: string, signal?: AbortSignal): Promise<string> {
|
|
264
|
+
const value = await call<{ content: string }>('read', scope, { path }, false, signal)
|
|
265
|
+
return value.content
|
|
172
266
|
}
|
|
173
267
|
|
|
174
268
|
async function fsWrite(scope: SessionScope, path: string, content: string, keepalive = false): Promise<void> {
|
|
175
|
-
await call('
|
|
269
|
+
await call('write', scope, { path, content }, keepalive)
|
|
176
270
|
}
|
|
177
271
|
|
|
178
|
-
async function call<T = { ok: true }>(method: string, scope: SessionScope, extra: Record<string, unknown>, keepalive = false): Promise<T> {
|
|
179
|
-
const response = await fetch(`/
|
|
272
|
+
async function call<T = { ok: true }>(method: string, scope: SessionScope, extra: Record<string, unknown>, keepalive = false, signal?: AbortSignal): Promise<T> {
|
|
273
|
+
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 })
|
|
180
274
|
const envelope = await response.json().catch(() => null) as { ok?: boolean; value?: T; error?: { message?: string } } | null
|
|
181
275
|
if (!response.ok || envelope?.ok !== true || envelope.value === undefined) throw new Error(envelope?.error?.message ?? `HTTP ${response.status}`)
|
|
182
276
|
return envelope.value
|
|
183
277
|
}
|
|
184
278
|
|
|
185
|
-
function CanvasIcon(): JSX.Element {
|
|
186
|
-
return <svg aria-hidden viewBox="0 0 20 20" width=
|
|
279
|
+
function CanvasIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
|
|
280
|
+
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>
|
|
187
281
|
}
|
|
188
282
|
|
|
189
283
|
function CenteredMessage({ title, detail }: { title: string; detail: string }): JSX.Element {
|
|
@@ -191,6 +285,17 @@ function CenteredMessage({ title, detail }: { title: string; detail: string }):
|
|
|
191
285
|
}
|
|
192
286
|
|
|
193
287
|
const fileName = (path: string): string => path.replace(/\\/g, '/').split('/').pop() || path
|
|
288
|
+
function parseDrawioAddress(address: string): { sessionId: string; path: string } | null {
|
|
289
|
+
const prefix = 'dsh-resource://file/session/'
|
|
290
|
+
if (!address.startsWith(prefix)) return null
|
|
291
|
+
try {
|
|
292
|
+
const parts = address.slice(prefix.length).split('/').map(decodeURIComponent)
|
|
293
|
+
const sessionId = parts.shift() ?? ''
|
|
294
|
+
const path = parts.join('/')
|
|
295
|
+
if (!sessionId || !path || !/\.drawio$/i.test(path) || parts.some(part => part.includes('/') || part.includes('\\') || part.includes('\0'))) return null
|
|
296
|
+
return { sessionId, path }
|
|
297
|
+
} catch { return null }
|
|
298
|
+
}
|
|
194
299
|
const messageOf = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason)
|
|
195
300
|
const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'error' ? '保存失败' : '已保存'
|
|
196
301
|
const fg = 'var(--dsw-alias-label-primary, currentColor)'
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,8 @@ export const name = 'dsh-drawio'
|
|
|
9
9
|
export const inject = ['tools', 'fs', 'sandbox', 'webServer']
|
|
10
10
|
|
|
11
11
|
const runtimeRoot = fileURLToPath(new URL('../vendor/drawio/', import.meta.url))
|
|
12
|
+
const MAX_DIAGRAM_BYTES = 10 * 1024 * 1024
|
|
13
|
+
const MAX_REQUEST_BYTES = MAX_DIAGRAM_BYTES + 64 * 1024
|
|
12
14
|
const output = {
|
|
13
15
|
schema: { type: 'object', additionalProperties: true, properties: {} },
|
|
14
16
|
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
@@ -17,6 +19,7 @@ const pathParameter = { type: 'string', required: true, description: 'Path to a
|
|
|
17
19
|
|
|
18
20
|
export function apply(ctx) {
|
|
19
21
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-drawio/runtime', handler: serveRuntime }))
|
|
22
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-drawio/api', handler: (req, res) => serveCanvasApi(ctx, req, res) }))
|
|
20
23
|
register(ctx, defineTool({
|
|
21
24
|
name: 'drawio_inspect',
|
|
22
25
|
description: 'Read a Draw.io file as structured pages, nodes, edges and geometry. Use this before editing an existing diagram. Set include_xml only when raw XML details are required.',
|
|
@@ -71,6 +74,67 @@ export function apply(ctx) {
|
|
|
71
74
|
}))
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
async function serveCanvasApi(ctx, req, res) {
|
|
78
|
+
if (req.method === 'OPTIONS') {
|
|
79
|
+
res.statusCode = 204
|
|
80
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
|
|
81
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
82
|
+
res.end()
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (req.method !== 'POST') return respondJson(res, 405, { ok: false, error: { message: 'Method Not Allowed' } })
|
|
86
|
+
try {
|
|
87
|
+
const pathname = new URL(req.url || '/', 'http://dsh.internal').pathname
|
|
88
|
+
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.' } })
|
|
90
|
+
const payload = await readJsonBody(req)
|
|
91
|
+
const target = await resolveCanvasTarget(ctx, payload.cwd, payload.path)
|
|
92
|
+
const info = await ctx.fs.stat(target)
|
|
93
|
+
if (!info || info.type !== 'file') throw new Error('Draw.io file was not found.')
|
|
94
|
+
if (Number(info.size) > MAX_DIAGRAM_BYTES) throw new Error('Draw.io file is too large to open safely.')
|
|
95
|
+
if (method === 'read') return respondJson(res, 200, { ok: true, value: { content: await ctx.fs.readText(target) } })
|
|
96
|
+
if (typeof payload.content !== 'string' || Buffer.byteLength(payload.content) > MAX_DIAGRAM_BYTES) throw new Error('Draw.io content is too large to save safely.')
|
|
97
|
+
const xml = normalizeDrawio(payload.content)
|
|
98
|
+
const sandboxPolicy = { mode: 'workspace-write', workspaceRoot: payload.cwd }
|
|
99
|
+
await ctx.fs.writeText(target, xml, { kind: 'replaceIfVersion', version: info.version }, undefined, sandboxPolicy)
|
|
100
|
+
return respondJson(res, 200, { ok: true, value: { ok: true } })
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return respondJson(res, 400, { ok: false, error: { message: String(error?.message || error).slice(0, 500) } })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function resolveCanvasTarget(ctx, cwd, path) {
|
|
107
|
+
if (typeof cwd !== 'string' || !cwd.trim()) throw new Error('A workspace is required.')
|
|
108
|
+
assertDrawioPath(path)
|
|
109
|
+
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('Draw.io file must stay inside the current workspace.')
|
|
111
|
+
return target
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function readJsonBody(req) {
|
|
115
|
+
const declared = Number(req.headers?.['content-length'])
|
|
116
|
+
if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES) throw new Error('Request body is too large.')
|
|
117
|
+
const chunks = []; let size = 0
|
|
118
|
+
for await (const chunk of req) {
|
|
119
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
120
|
+
size += buffer.length
|
|
121
|
+
if (size > MAX_REQUEST_BYTES) throw new Error('Request body is too large.')
|
|
122
|
+
chunks.push(buffer)
|
|
123
|
+
}
|
|
124
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
125
|
+
const value = text ? JSON.parse(text) : {}
|
|
126
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Request body must be a JSON object.')
|
|
127
|
+
return value
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function respondJson(res, status, value) {
|
|
131
|
+
const body = JSON.stringify(value)
|
|
132
|
+
res.statusCode = status
|
|
133
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
134
|
+
res.setHeader('Content-Length', Buffer.byteLength(body))
|
|
135
|
+
res.end(body)
|
|
136
|
+
}
|
|
137
|
+
|
|
74
138
|
function register(ctx, tool) {
|
|
75
139
|
ctx.effect(() => ctx.tools.register(tool))
|
|
76
140
|
}
|
|
@@ -8,10 +8,16 @@ window.DRAW_MATH_URL = null;
|
|
|
8
8
|
window.DRAWIO_CONFIG = {
|
|
9
9
|
compressXml: false,
|
|
10
10
|
enableCssDarkMode: true,
|
|
11
|
-
defaultLibraries: 'general;flowchart;basic;arrows2',
|
|
12
|
-
enabledLibraries:
|
|
11
|
+
defaultLibraries: 'general;uml;er;bpmn;flowchart;basic;arrows2',
|
|
12
|
+
enabledLibraries: null
|
|
13
13
|
};
|
|
14
14
|
urlParams['sync'] = 'manual';
|
|
15
15
|
urlParams['offline'] = '1';
|
|
16
16
|
urlParams['local'] = '1';
|
|
17
17
|
urlParams['plugins'] = '0';
|
|
18
|
+
|
|
19
|
+
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
|
20
|
+
navigator.serviceWorker.getRegistrations().then(function(regs) {
|
|
21
|
+
for (var i = 0; i < regs.length; i++) regs[i].unregister();
|
|
22
|
+
}).catch(function() {});
|
|
23
|
+
}
|