@civilization/dsh-drawio 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -6
- package/lib/client.js +361 -41
- package/package.json +2 -1
- package/skills/drawio-operation/SKILL.md +80 -0
- package/src/client/index.tsx +381 -46
- package/src/index.js +64 -13
- package/src/xml.js +119 -14
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: drawio-operation
|
|
3
|
+
description: 读取、检查、创建和语义化编辑 Draw.io 架构图与流程图,协同原生离线画板,并指导导出与排版工作流。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Draw.io 画板与图表协同指南
|
|
7
|
+
|
|
8
|
+
`dsh-drawio` 插件为 DeepSeek Harness (DSH) 提供 100% 本地离线的 Draw.io 画板,并向 Agent 注册结构化图表编辑工具。
|
|
9
|
+
|
|
10
|
+
当用户询问 Draw.io 功能或需要绘制图表时,直接说明支持的画板功能与工具,无需探测工作区无关文件。
|
|
11
|
+
|
|
12
|
+
## 核心模型工具
|
|
13
|
+
|
|
14
|
+
### 1. `drawio_inspect`
|
|
15
|
+
- **功能**:将现有 `.drawio` 文件解析为结构化的页面、节点、连线、文本内容及几何坐标。
|
|
16
|
+
- **最佳实践**:修改已有图表前必须先调用 `drawio_inspect`,获取已有 cell ID、父子层级关系及连线端点。
|
|
17
|
+
- **字段**:返回的 `plainText` 是已去除 HTML 格式的纯净标签;`children` 标识该容器节点包含的子节点 ID。
|
|
18
|
+
- 仅在需要查看原始底层 XML 属性时设置 `include_xml: true`。
|
|
19
|
+
|
|
20
|
+
### 2. `drawio_edit`
|
|
21
|
+
- **功能**:对现有 `.drawio` 文件应用批量语义化增量修改,支持多页面管理。
|
|
22
|
+
- **支持的批量操作类型**:
|
|
23
|
+
- `add_node`:新增节点。属性:`id`, `label`, `style`, `parent`, `x`, `y`, `width`, `height`。
|
|
24
|
+
- `add_edge`:新增连线。属性:`id`, `source`, `target`, `label`, `style`, `parent`。
|
|
25
|
+
- `update`:更新已有节点或连线属性。
|
|
26
|
+
- 推荐使用 `stylePatch: { fillColor: '#dae8fc', strokeColor: '#6c8ebf' }` 局部增量修改颜色或边框,避免冲掉节点的已有样式(如圆角、对齐等)。
|
|
27
|
+
- 也支持整体更新 `label`, `style`, `x`, `y`, `width`, `height`。
|
|
28
|
+
- `delete`:安全删除节点,并自动级联清理连接到该节点的孤立连线。
|
|
29
|
+
- `add_page`:在当前文档中新增图表页面。属性:`id`, `name`(如 `部署拓扑`、`时序交互`)。
|
|
30
|
+
- `rename_page`:重命名页面。属性:`page` (索引或ID), `name`。
|
|
31
|
+
- `delete_page`:删除指定页面。属性:`page`。
|
|
32
|
+
- **规范**:节点 ID 应具备稳定且语义化的英文标识(例如 `client`, `api_gateway`, `auth_service`, `db_cluster`),严禁使用易冲突的纯数字 ID。
|
|
33
|
+
|
|
34
|
+
### 3. `drawio_write`
|
|
35
|
+
- **功能**:校验并写入完整的 Draw.io `mxfile` 或 `mxGraphModel` XML 文档。
|
|
36
|
+
- 自动格式化为未压缩的规范 XML,便于后续 AI 审查与 Git diff 跟踪。
|
|
37
|
+
- **建议**:从零构建新图表时首选 `drawio_write`;对现有图表进行局部修改或扩展时首选 `drawio_edit`。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 架构图排版与设计规范
|
|
42
|
+
|
|
43
|
+
为了保证大模型生成的图表整洁、专业、无重叠,请遵循以下工业级排版规范:
|
|
44
|
+
|
|
45
|
+
### 1. 坐标与网格系统
|
|
46
|
+
- **基准网格**:Draw.io 默认 `gridSize=10`,所有坐标 `x`, `y`, `width`, `height` 请取 10 的整数倍。
|
|
47
|
+
- **标准节点尺寸**:
|
|
48
|
+
- 常规服务/模块:`width: 140, height: 60`
|
|
49
|
+
- 数据库/存储:`width: 120, height: 70`
|
|
50
|
+
- 外部系统/客户端:`width: 130, height: 50`
|
|
51
|
+
- 分组容器(Group/VPC):根据内部节点数量自适应(如 `width: 600, height: 320`)
|
|
52
|
+
- **间距推荐**:
|
|
53
|
+
- 横向流式布局:节点水平间距保持在 `80px ~ 120px`(如 `x1=40, x2=240, x3=440`);
|
|
54
|
+
- 纵向分层架构:分层垂直间距保持在 `90px ~ 130px`(如 Layer 1: `y=40`,Layer 2: `y=160`,Layer 3: `y=280`)。
|
|
55
|
+
|
|
56
|
+
### 2. 经典高质感现代配色方案(Flat Palette)
|
|
57
|
+
|
|
58
|
+
| 分类/层级 | 填充色 `fillColor` | 边框色 `strokeColor` | 字体色 `fontColor` | 推荐使用场景 |
|
|
59
|
+
| :--- | :--- | :--- | :--- | :--- |
|
|
60
|
+
| **接入/网关** | `#dae8fc` (浅蓝) | `#6c8ebf` (深蓝) | `#000000` | Client, Nginx, API Gateway, CDN |
|
|
61
|
+
| **业务服务** | `#d5e8d4` (浅绿) | `#82b366` (深绿) | `#000000` | OrderService, UserService, K8s Pods |
|
|
62
|
+
| **存储/数据库** | `#ffe6cc` (暖橙) | `#d79b00` (深橙) | `#000000` | MySQL, Redis, MongoDB, S3 |
|
|
63
|
+
| **异步队列/通信** | `#e1d5e7` (浅紫) | `#9673a6` (深紫) | `#000000` | Kafka, RabbitMQ, EventBus |
|
|
64
|
+
| **告警/高风险** | `#f8cecc` (浅红) | `#b85450` (深红) | `#000000` | ErrorHandler, DeadLetter, Alert |
|
|
65
|
+
| **容器/分组边框** | `#f5f5f5` (极浅灰) | `#cccccc` (浅灰) | `#333333` | VPC, Kubernetes Cluster, Subnet |
|
|
66
|
+
|
|
67
|
+
### 3. 连线样式(Edge Styles)
|
|
68
|
+
- 统一使用正交折线:`edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;`
|
|
69
|
+
- 若要强调特定流向,可增加线宽 `strokeWidth=2;`,异步/订阅连线推荐增加虚线 `dashed=1;`。
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 离线画板协同与图片导出
|
|
74
|
+
|
|
75
|
+
- **右侧栏实时双向协同**:
|
|
76
|
+
- 用户在 DSH 中打开任何 `.drawio` 文件即可在右侧栏全功能画板中直观交互;
|
|
77
|
+
- 模型调用 `drawio_edit` 或 `drawio_write` 保存后,画板会自动轻量检测版本变化并同步刷新,且不会中断用户正在进行的画布拖拽。
|
|
78
|
+
- **纯本地离线图片导出**:
|
|
79
|
+
- 画板右上角内置纯本地导出面板,支持导出为 **PNG (2x 超清)**、**SVG (矢量无损)** 和 **XML-PNG (双模可编辑图)**;
|
|
80
|
+
- 支持一键**保存到工作区**(自动生成同名图片)、**写入系统剪贴板**(便于直接粘贴到文档或聊天窗口)或**下载到本地磁盘**。
|
package/src/client/index.tsx
CHANGED
|
@@ -6,7 +6,7 @@ const TAB_ID = '@civilization/dsh-drawio'
|
|
|
6
6
|
const TAB_KIND = 'drawio'
|
|
7
7
|
|
|
8
8
|
interface SessionScope { sessionId: string; cwd?: string }
|
|
9
|
-
interface FileViewerProps { content?: string; truncated?: boolean; path: string; scope: SessionScope }
|
|
9
|
+
interface FileViewerProps { content?: string; version?: string; truncated?: boolean; path: string; scope: SessionScope }
|
|
10
10
|
interface OfficialContext extends Context { slots: any; sidebarRightTabs: any }
|
|
11
11
|
|
|
12
12
|
export function apply(ctx: Context): void {
|
|
@@ -30,66 +30,111 @@ export function apply(ctx: Context): void {
|
|
|
30
30
|
|
|
31
31
|
function OfficialDrawioTabTitle({ useTabInfo }: any): JSX.Element {
|
|
32
32
|
const { tab } = useTabInfo()
|
|
33
|
-
|
|
33
|
+
const title = tab?.title || (tab?.navigation?.address ? fileName(parseDrawioAddress(tab.navigation.address)?.path ?? 'Draw.io') : 'Draw.io')
|
|
34
|
+
return <><CanvasIcon size={15} /><span>{title}</span></>
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX.Element {
|
|
37
38
|
const { tab } = useTabInfo()
|
|
38
|
-
const
|
|
39
|
-
const resource = useMemo(() => parseDrawioAddress(
|
|
40
|
-
|
|
39
|
+
const rawAddress = tab?.navigation?.address
|
|
40
|
+
const resource = useMemo(() => parseDrawioAddress(rawAddress, sessionId), [rawAddress, sessionId])
|
|
41
|
+
|
|
42
|
+
const activeCwd = useSessions((sessions: any) => sessions?.byId?.[sessionId]?.cwd) as string | undefined
|
|
43
|
+
const resourceCwd = useSessions((sessions: any) => resource?.sessionId ? sessions?.byId?.[resource.sessionId]?.cwd : undefined) as string | undefined
|
|
44
|
+
const cwd = activeCwd || resourceCwd
|
|
45
|
+
const effectiveSessionId = sessionId || resource?.sessionId || ''
|
|
46
|
+
|
|
47
|
+
const [file, setFile] = useState<{ key: string; content: string; version?: string } | null>(null)
|
|
41
48
|
const [error, setError] = useState<string | null>(null)
|
|
42
|
-
const key = `${
|
|
49
|
+
const key = `${effectiveSessionId}\0${cwd ?? ''}\0${resource?.path ?? ''}`
|
|
43
50
|
|
|
44
51
|
useEffect(() => {
|
|
45
|
-
if (!resource ||
|
|
52
|
+
if (!resource?.path || !effectiveSessionId || !cwd) return
|
|
46
53
|
const controller = new AbortController()
|
|
47
54
|
setError(null)
|
|
48
|
-
void fsRead({ sessionId, cwd }, resource.path, controller.signal)
|
|
49
|
-
.then(
|
|
55
|
+
void fsRead({ sessionId: effectiveSessionId, cwd }, resource.path, controller.signal)
|
|
56
|
+
.then(res => { if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version }) })
|
|
50
57
|
.catch(reason => { if (!controller.signal.aborted) setError(messageOf(reason)) })
|
|
51
58
|
return () => controller.abort()
|
|
52
|
-
}, [key, resource?.
|
|
59
|
+
}, [key, resource?.path, effectiveSessionId, cwd])
|
|
53
60
|
|
|
54
|
-
if (!resource || resource.
|
|
55
|
-
|
|
61
|
+
if (!resource || !resource.path) {
|
|
62
|
+
return (
|
|
63
|
+
<CenteredMessage
|
|
64
|
+
title="无法打开画板"
|
|
65
|
+
detail={rawAddress ? `无法解析 Draw.io 文件地址:${rawAddress}` : '无法获取文件导航地址。'}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
if (!cwd) {
|
|
70
|
+
return (
|
|
71
|
+
<CenteredMessage
|
|
72
|
+
title="无法打开画板"
|
|
73
|
+
detail="当前会话没有关联可用的工作区,请先在 DSH 中打开或选择一个工作区文件夹。"
|
|
74
|
+
/>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
56
77
|
if (error) return <CenteredMessage title="画板载入失败" detail={error} />
|
|
57
78
|
if (file?.key !== key) return <CenteredMessage title="正在载入画板" detail={fileName(resource.path)} />
|
|
58
|
-
return <DrawioCanvas content={file.content} path={resource.path} scope={{ sessionId, cwd }} />
|
|
79
|
+
return <DrawioCanvas content={file.content} version={file.version} path={resource.path} scope={{ sessionId: effectiveSessionId, cwd }} />
|
|
59
80
|
}
|
|
60
81
|
|
|
61
|
-
function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX.Element {
|
|
82
|
+
function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerProps): JSX.Element {
|
|
62
83
|
const frame = useRef<HTMLIFrameElement>(null)
|
|
63
84
|
const latestFile = useRef(content ?? '')
|
|
64
85
|
const latestEditor = useRef(content ?? '')
|
|
86
|
+
const latestVersion = useRef(version)
|
|
65
87
|
const saveTimer = useRef<number | undefined>(undefined)
|
|
66
88
|
const saveGeneration = useRef(0)
|
|
67
89
|
const [ready, setReady] = useState(false)
|
|
68
|
-
const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'external' | 'error'>('loading')
|
|
90
|
+
const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'external' | 'conflict' | 'error'>('loading')
|
|
69
91
|
const [error, setError] = useState<string | null>(truncated ? '文件过大,DSH 只返回了截断内容,无法安全打开。' : null)
|
|
70
92
|
const lastUserActivity = useRef(0)
|
|
71
93
|
const isInteracting = useRef(false)
|
|
72
94
|
const { dark, accent: accentColor } = useDshTheme()
|
|
73
|
-
const
|
|
95
|
+
const initialDark = useRef(dark)
|
|
96
|
+
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=${initialDark.current ? '1' : '0'}`, [])
|
|
74
97
|
|
|
75
98
|
const post = useCallback((message: unknown) => {
|
|
76
99
|
frame.current?.contentWindow?.postMessage(JSON.stringify(message), window.location.origin)
|
|
77
100
|
}, [])
|
|
78
101
|
|
|
102
|
+
const reloadFromDisk = useCallback(async () => {
|
|
103
|
+
try {
|
|
104
|
+
setSaveState('loading')
|
|
105
|
+
const result = await fsRead(scope, path)
|
|
106
|
+
latestFile.current = result.content
|
|
107
|
+
latestEditor.current = result.content
|
|
108
|
+
latestVersion.current = result.version
|
|
109
|
+
post({ action: 'load', xml: result.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
|
|
110
|
+
setSaveState('saved')
|
|
111
|
+
setError(null)
|
|
112
|
+
} catch (reason) {
|
|
113
|
+
setError(messageOf(reason))
|
|
114
|
+
}
|
|
115
|
+
}, [scope, path, dark, post])
|
|
116
|
+
|
|
79
117
|
const persist = useCallback(async (xml: string) => {
|
|
80
118
|
const generation = ++saveGeneration.current
|
|
81
119
|
setSaveState('saving')
|
|
82
120
|
try {
|
|
83
|
-
await fsWrite(scope, path, xml)
|
|
121
|
+
const outcome = await fsWrite(scope, path, xml, latestVersion.current)
|
|
84
122
|
if (generation !== saveGeneration.current) return
|
|
85
123
|
latestFile.current = xml
|
|
86
124
|
latestEditor.current = xml
|
|
125
|
+
if (outcome?.version) latestVersion.current = outcome.version
|
|
87
126
|
setSaveState('saved')
|
|
88
127
|
setError(null)
|
|
89
128
|
} catch (reason) {
|
|
90
129
|
if (generation !== saveGeneration.current) return
|
|
91
|
-
|
|
92
|
-
|
|
130
|
+
const msg = messageOf(reason)
|
|
131
|
+
if (msg.includes('modified by an external process') || msg.includes('409')) {
|
|
132
|
+
setSaveState('conflict')
|
|
133
|
+
setError('文件已被外部或 AI 修改,当前存在并发冲突,点击可重新载入最新内容')
|
|
134
|
+
} else {
|
|
135
|
+
setSaveState('error')
|
|
136
|
+
setError(msg)
|
|
137
|
+
}
|
|
93
138
|
}
|
|
94
139
|
}, [scope.sessionId, scope.cwd, path])
|
|
95
140
|
|
|
@@ -113,7 +158,8 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
113
158
|
useEffect(() => {
|
|
114
159
|
latestFile.current = content ?? ''
|
|
115
160
|
latestEditor.current = content ?? ''
|
|
116
|
-
|
|
161
|
+
latestVersion.current = version
|
|
162
|
+
}, [content, version, path])
|
|
117
163
|
|
|
118
164
|
const markActivity = useCallback(() => {
|
|
119
165
|
lastUserActivity.current = Date.now()
|
|
@@ -147,12 +193,101 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
147
193
|
return () => window.clearInterval(timer)
|
|
148
194
|
}, [])
|
|
149
195
|
|
|
196
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
197
|
+
const [exportFormat, setExportFormat] = useState<'png' | 'svg' | 'xmlpng'>('png')
|
|
198
|
+
const [isTransparent, setIsTransparent] = useState(false)
|
|
199
|
+
const [isExporting, setIsExporting] = useState(false)
|
|
200
|
+
const [toast, setToast] = useState<string | null>(null)
|
|
201
|
+
const pendingExport = useRef<{ format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string } | null>(null)
|
|
202
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
203
|
+
|
|
204
|
+
const showToast = useCallback((msg: string) => {
|
|
205
|
+
setToast(msg)
|
|
206
|
+
window.setTimeout(() => setToast(current => current === msg ? null : current), 3200)
|
|
207
|
+
}, [])
|
|
208
|
+
|
|
209
|
+
const startExport = useCallback((target: 'workspace' | 'download' | 'clipboard') => {
|
|
210
|
+
if (!ready || isExporting) return
|
|
211
|
+
const ext = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png'
|
|
212
|
+
const base = path.replace(/\.drawio$/i, '')
|
|
213
|
+
const targetPath = `${base}${ext}`
|
|
214
|
+
|
|
215
|
+
pendingExport.current = {
|
|
216
|
+
format: exportFormat,
|
|
217
|
+
target,
|
|
218
|
+
targetPath,
|
|
219
|
+
}
|
|
220
|
+
setIsExporting(true)
|
|
221
|
+
setMenuOpen(false)
|
|
222
|
+
post({
|
|
223
|
+
action: 'export',
|
|
224
|
+
format: exportFormat,
|
|
225
|
+
scale: exportFormat === 'svg' ? 1 : 2,
|
|
226
|
+
transparent: isTransparent,
|
|
227
|
+
border: 10,
|
|
228
|
+
spin: '正在渲染导出图片...',
|
|
229
|
+
})
|
|
230
|
+
}, [ready, isExporting, exportFormat, isTransparent, path, post])
|
|
231
|
+
|
|
232
|
+
const handleExportResult = useCallback(async (pending: { format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string }, data: string) => {
|
|
233
|
+
try {
|
|
234
|
+
if (pending.target === 'download') {
|
|
235
|
+
const link = document.createElement('a')
|
|
236
|
+
link.download = fileName(pending.targetPath)
|
|
237
|
+
link.href = data
|
|
238
|
+
document.body.appendChild(link)
|
|
239
|
+
link.click()
|
|
240
|
+
document.body.removeChild(link)
|
|
241
|
+
showToast(`已开始下载:${fileName(pending.targetPath)}`)
|
|
242
|
+
} else if (pending.target === 'clipboard') {
|
|
243
|
+
try {
|
|
244
|
+
if (pending.format === 'svg') {
|
|
245
|
+
const svgText = data.startsWith('data:image/svg+xml')
|
|
246
|
+
? decodeURIComponent(data.split(',')[1] || '')
|
|
247
|
+
: data
|
|
248
|
+
await navigator.clipboard.writeText(svgText)
|
|
249
|
+
showToast('已复制 SVG 代码到剪贴板')
|
|
250
|
+
} else {
|
|
251
|
+
const res = await fetch(data)
|
|
252
|
+
const blob = await res.blob()
|
|
253
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
|
254
|
+
showToast('已复制图片到剪贴板')
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// 剪贴板受限或报错时降级为自动下载
|
|
258
|
+
const link = document.createElement('a')
|
|
259
|
+
link.download = fileName(pending.targetPath)
|
|
260
|
+
link.href = data
|
|
261
|
+
document.body.appendChild(link)
|
|
262
|
+
link.click()
|
|
263
|
+
document.body.removeChild(link)
|
|
264
|
+
showToast(`剪贴板无权限,已自动下载:${fileName(pending.targetPath)}`)
|
|
265
|
+
}
|
|
266
|
+
} else if (pending.target === 'workspace') {
|
|
267
|
+
const result = await fsSaveImage(scope, pending.targetPath, data)
|
|
268
|
+
showToast(`已保存到工作区:${fileName(result.path)} (${Math.round(result.bytes / 1024)} KB)`)
|
|
269
|
+
}
|
|
270
|
+
} catch (reason) {
|
|
271
|
+
setError(`导出失败:${messageOf(reason)}`)
|
|
272
|
+
}
|
|
273
|
+
}, [scope, showToast])
|
|
274
|
+
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
const handleOutsideClick = (e: MouseEvent) => {
|
|
277
|
+
if (menuOpen && menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
278
|
+
setMenuOpen(false)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
window.addEventListener('pointerdown', handleOutsideClick)
|
|
282
|
+
return () => window.removeEventListener('pointerdown', handleOutsideClick)
|
|
283
|
+
}, [menuOpen])
|
|
284
|
+
|
|
150
285
|
useEffect(() => {
|
|
151
286
|
setReady(false)
|
|
152
287
|
setSaveState('loading')
|
|
153
288
|
const receive = (event: MessageEvent) => {
|
|
154
289
|
if (event.source !== frame.current?.contentWindow || event.origin !== window.location.origin) return
|
|
155
|
-
let message: { event?: string; xml?: string }
|
|
290
|
+
let message: { event?: string; xml?: string; format?: string; data?: string }
|
|
156
291
|
try { message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data }
|
|
157
292
|
catch { return }
|
|
158
293
|
if (message.event === 'configure') {
|
|
@@ -163,11 +298,18 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
163
298
|
setSaveState('saved')
|
|
164
299
|
} else if ((message.event === 'autosave' || message.event === 'save') && typeof message.xml === 'string') {
|
|
165
300
|
scheduleSave(message.xml, message.event === 'save')
|
|
301
|
+
} else if (message.event === 'export' && typeof message.data === 'string') {
|
|
302
|
+
const pending = pendingExport.current
|
|
303
|
+
pendingExport.current = null
|
|
304
|
+
setIsExporting(false)
|
|
305
|
+
if (pending) {
|
|
306
|
+
void handleExportResult(pending, message.data)
|
|
307
|
+
}
|
|
166
308
|
}
|
|
167
309
|
}
|
|
168
310
|
window.addEventListener('message', receive)
|
|
169
311
|
return () => window.removeEventListener('message', receive)
|
|
170
|
-
}, [src, dark, path, post, scheduleSave])
|
|
312
|
+
}, [src, dark, path, post, scheduleSave, handleExportResult])
|
|
171
313
|
|
|
172
314
|
useEffect(() => {
|
|
173
315
|
if (!ready || truncated) return
|
|
@@ -183,14 +325,23 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
183
325
|
if (typeof document !== 'undefined' && document.hidden) return
|
|
184
326
|
|
|
185
327
|
try {
|
|
186
|
-
|
|
328
|
+
// 轻量检查版本号
|
|
329
|
+
const stat = await fsStat(scope, path)
|
|
330
|
+
if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return
|
|
331
|
+
if (stat.version && latestVersion.current && stat.version === latestVersion.current) return
|
|
332
|
+
|
|
333
|
+
const res = await fsRead(scope, path)
|
|
187
334
|
// 5. 响应返回后二次防护:若这期间用户按下了鼠标或开始拖动,果断丢弃本次结果
|
|
188
335
|
if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return
|
|
189
|
-
if (
|
|
336
|
+
if (res.content === latestFile.current || res.content === latestEditor.current) {
|
|
337
|
+
latestVersion.current = res.version
|
|
338
|
+
return
|
|
339
|
+
}
|
|
190
340
|
|
|
191
|
-
latestFile.current =
|
|
192
|
-
latestEditor.current =
|
|
193
|
-
|
|
341
|
+
latestFile.current = res.content
|
|
342
|
+
latestEditor.current = res.content
|
|
343
|
+
latestVersion.current = res.version
|
|
344
|
+
post({ action: 'load', xml: res.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 })
|
|
194
345
|
setSaveState('external')
|
|
195
346
|
window.setTimeout(() => { if (!cancelled) setSaveState('saved') }, 1200)
|
|
196
347
|
} catch (reason) {
|
|
@@ -204,17 +355,123 @@ function DrawioCanvas({ content, truncated, path, scope }: FileViewerProps): JSX
|
|
|
204
355
|
|
|
205
356
|
useEffect(() => () => {
|
|
206
357
|
if (saveTimer.current !== undefined) window.clearTimeout(saveTimer.current)
|
|
207
|
-
if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, true)
|
|
358
|
+
if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, latestVersion.current, true)
|
|
208
359
|
}, [scope.sessionId, scope.cwd, path])
|
|
209
360
|
|
|
210
361
|
if (truncated) return <CenteredMessage title="无法打开画板" detail="文件内容已被截断,继续编辑可能损坏原文件。" />
|
|
362
|
+
|
|
363
|
+
const targetExt = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png'
|
|
364
|
+
const previewName = `${fileName(path).replace(/\.drawio$/i, '')}${targetExt}`
|
|
365
|
+
|
|
211
366
|
return (
|
|
212
367
|
<div style={styles.root}>
|
|
213
368
|
<iframe ref={frame} title={`${fileName(path)} Draw.io 画板`} src={src} style={styles.frame} />
|
|
214
|
-
|
|
215
|
-
|
|
369
|
+
|
|
370
|
+
{/* 右上角悬浮导出工具面板 */}
|
|
371
|
+
<div ref={menuRef} style={styles.toolbar}>
|
|
372
|
+
<button
|
|
373
|
+
type="button"
|
|
374
|
+
onClick={() => setMenuOpen(open => !open)}
|
|
375
|
+
disabled={!ready || isExporting}
|
|
376
|
+
style={{ ...styles.exportTrigger, ...(isExporting ? styles.triggerActive : {}) }}
|
|
377
|
+
title="导出当前画板为图片"
|
|
378
|
+
>
|
|
379
|
+
<CameraIcon size={14} />
|
|
380
|
+
<span>{isExporting ? '正在导出...' : '导出图片'}</span>
|
|
381
|
+
<span style={styles.arrowIcon}>▾</span>
|
|
382
|
+
</button>
|
|
383
|
+
|
|
384
|
+
{menuOpen ? (
|
|
385
|
+
<div style={styles.exportMenu}>
|
|
386
|
+
<div style={styles.menuHeader}>
|
|
387
|
+
<span style={styles.menuTitle}>导出画板图片</span>
|
|
388
|
+
<span style={styles.menuSub}>{previewName}</span>
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
<div style={styles.formatRow}>
|
|
392
|
+
{(['png', 'svg', 'xmlpng'] as const).map(fmt => (
|
|
393
|
+
<button
|
|
394
|
+
key={fmt}
|
|
395
|
+
type="button"
|
|
396
|
+
onClick={() => setExportFormat(fmt)}
|
|
397
|
+
style={{
|
|
398
|
+
...styles.formatBtn,
|
|
399
|
+
...(exportFormat === fmt ? { ...styles.formatBtnActive, borderColor: accentColor } : {}),
|
|
400
|
+
}}
|
|
401
|
+
>
|
|
402
|
+
{fmt === 'png' ? 'PNG (超清)' : fmt === 'svg' ? 'SVG (矢量)' : 'XML-PNG'}
|
|
403
|
+
</button>
|
|
404
|
+
))}
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
{exportFormat !== 'svg' ? (
|
|
408
|
+
<label style={styles.checkRow}>
|
|
409
|
+
<input
|
|
410
|
+
type="checkbox"
|
|
411
|
+
checked={isTransparent}
|
|
412
|
+
onChange={e => setIsTransparent(e.target.checked)}
|
|
413
|
+
style={styles.checkbox}
|
|
414
|
+
/>
|
|
415
|
+
<span>透明背景</span>
|
|
416
|
+
</label>
|
|
417
|
+
) : null}
|
|
418
|
+
|
|
419
|
+
<div style={styles.actionCol}>
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
onClick={() => startExport('workspace')}
|
|
423
|
+
style={{ ...styles.actionBtn, ...styles.actionBtnPrimary, background: accentColor }}
|
|
424
|
+
>
|
|
425
|
+
<SaveIcon size={13} />
|
|
426
|
+
<span>保存到工作区 ({targetExt})</span>
|
|
427
|
+
</button>
|
|
428
|
+
|
|
429
|
+
<button
|
|
430
|
+
type="button"
|
|
431
|
+
onClick={() => startExport('clipboard')}
|
|
432
|
+
style={styles.actionBtn}
|
|
433
|
+
>
|
|
434
|
+
<CopyIcon size={13} />
|
|
435
|
+
<span>复制到剪贴板</span>
|
|
436
|
+
</button>
|
|
437
|
+
|
|
438
|
+
<button
|
|
439
|
+
type="button"
|
|
440
|
+
onClick={() => startExport('download')}
|
|
441
|
+
style={styles.actionBtn}
|
|
442
|
+
>
|
|
443
|
+
<DownloadIcon size={13} />
|
|
444
|
+
<span>下载图片文件</span>
|
|
445
|
+
</button>
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
) : null}
|
|
449
|
+
</div>
|
|
450
|
+
|
|
451
|
+
{/* Toast 提示 */}
|
|
452
|
+
{toast ? (
|
|
453
|
+
<div style={styles.toast}>
|
|
454
|
+
<CheckIcon size={14} />
|
|
455
|
+
<span>{toast}</span>
|
|
456
|
+
</div>
|
|
457
|
+
) : null}
|
|
458
|
+
|
|
459
|
+
<div role="status" aria-live="polite" style={{ ...styles.status, ...(saveState === 'error' || saveState === 'conflict' ? styles.statusError : {}) }}>
|
|
460
|
+
<span style={{ ...styles.dot, background: saveState === 'conflict' ? 'var(--dsw-alias-warning, #e6a23c)' : accentColor }} />{statusText(saveState)}
|
|
216
461
|
</div>
|
|
217
|
-
{error ?
|
|
462
|
+
{error ? (
|
|
463
|
+
<button
|
|
464
|
+
type="button"
|
|
465
|
+
title={error}
|
|
466
|
+
onClick={() => {
|
|
467
|
+
if (saveState === 'conflict') void reloadFromDisk()
|
|
468
|
+
setError(null)
|
|
469
|
+
}}
|
|
470
|
+
style={styles.error}
|
|
471
|
+
>
|
|
472
|
+
{saveState === 'conflict' ? '⚠️ 检测到外部修改 · 点击重新载入' : '保存失败 · 点击关闭'}
|
|
473
|
+
</button>
|
|
474
|
+
) : null}
|
|
218
475
|
</div>
|
|
219
476
|
)
|
|
220
477
|
}
|
|
@@ -260,13 +517,20 @@ function detectDark(): boolean {
|
|
|
260
517
|
return document.documentElement.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
261
518
|
}
|
|
262
519
|
|
|
263
|
-
async function
|
|
264
|
-
|
|
265
|
-
return value.content
|
|
520
|
+
async function fsStat(scope: SessionScope, path: string, signal?: AbortSignal): Promise<{ version: string; size: number }> {
|
|
521
|
+
return call<{ version: string; size: number }>('stat', scope, { path }, false, signal)
|
|
266
522
|
}
|
|
267
523
|
|
|
268
|
-
async function
|
|
269
|
-
|
|
524
|
+
async function fsRead(scope: SessionScope, path: string, signal?: AbortSignal): Promise<{ content: string; version: string }> {
|
|
525
|
+
return call<{ content: string; version: string }>('read', scope, { path }, false, signal)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function fsWrite(scope: SessionScope, path: string, content: string, version?: string, keepalive = false): Promise<{ version?: string }> {
|
|
529
|
+
return call<{ version?: string }>('write', scope, { path, content, ...(version ? { version } : {}) }, keepalive)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function fsSaveImage(scope: SessionScope, path: string, data: string): Promise<{ path: string; bytes: number }> {
|
|
533
|
+
return call<{ path: string; bytes: number }>('save-image', scope, { path, data })
|
|
270
534
|
}
|
|
271
535
|
|
|
272
536
|
async function call<T = { ok: true }>(method: string, scope: SessionScope, extra: Record<string, unknown>, keepalive = false, signal?: AbortSignal): Promise<T> {
|
|
@@ -280,24 +544,78 @@ function CanvasIcon({ size = 16, className }: { size?: number; className?: strin
|
|
|
280
544
|
return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none"><rect x="2.5" y="2.5" width="15" height="15" rx="2.5" stroke="currentColor"/><circle cx="7" cy="7" r="1.5" fill="currentColor"/><circle cx="13" cy="13" r="1.5" fill="currentColor"/><path d="M8.4 7.8l3.2 4.4M8.2 6.2h3.6M6.2 8.2v3.6" stroke="currentColor" strokeLinecap="round"/></svg>
|
|
281
545
|
}
|
|
282
546
|
|
|
547
|
+
function CameraIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
|
|
548
|
+
return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 6.5A1.5 1.5 0 0 1 5.5 5h1.8a1.5 1.5 0 0 0 1.2-.6l.5-.7A1.5 1.5 0 0 1 10.2 3h1.6a1.5 1.5 0 0 1 1.2.7l.5.7a1.5 1.5 0 0 0 1.2.6h1.8A1.5 1.5 0 0 1 18 6.5v9a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 2 15.5v-9z"/><circle cx="10" cy="11" r="3.5"/></svg>
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function SaveIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
|
|
552
|
+
return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M16 17H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h9.5l3.5 3.5V16a1 1 0 0 1-1 1z"/><path d="M13 17v-6H7v6"/><path d="M7 3v4h6"/></svg>
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function CopyIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
|
|
556
|
+
return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="7" y="7" width="10" height="10" rx="1.5"/><path d="M4 13H3.5A1.5 1.5 0 0 1 2 11.5v-8A1.5 1.5 0 0 1 3.5 2h8A1.5 1.5 0 0 1 13 3.5V4"/></svg>
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function DownloadIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
|
|
560
|
+
return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3v10m0 0l-3.5-3.5M10 13l3.5-3.5"/><path d="M3 14v2a1.5 1.5 0 0 0 1.5 1.5h11a1.5 1.5 0 0 0 1.5-1.5v-2"/></svg>
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function CheckIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
|
|
564
|
+
return <svg className={className} aria-hidden viewBox="0 0 20 20" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 10.5l4 4 8-9"/></svg>
|
|
565
|
+
}
|
|
566
|
+
|
|
283
567
|
function CenteredMessage({ title, detail }: { title: string; detail: string }): JSX.Element {
|
|
284
568
|
return <div style={styles.center}><strong>{title}</strong><span style={styles.muted}>{detail}</span></div>
|
|
285
569
|
}
|
|
286
570
|
|
|
287
571
|
const fileName = (path: string): string => path.replace(/\\/g, '/').split('/').pop() || path
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (!address
|
|
572
|
+
|
|
573
|
+
export function parseDrawioAddress(address?: string, fallbackSessionId?: string): { sessionId: string; path: string } | null {
|
|
574
|
+
if (!address || typeof address !== 'string') return null
|
|
291
575
|
try {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|
|
298
616
|
}
|
|
299
617
|
const messageOf = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason)
|
|
300
|
-
const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'error' ? '保存失败' : '已保存'
|
|
618
|
+
const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'conflict' ? '检测到冲突' : state === 'error' ? '保存失败' : '已保存'
|
|
301
619
|
const fg = 'var(--dsw-alias-label-primary, currentColor)'
|
|
302
620
|
const muted = 'var(--dsw-alias-label-secondary, rgba(127,127,127,.9))'
|
|
303
621
|
const layer = 'var(--dsw-alias-container-bg, Canvas)'
|
|
@@ -306,6 +624,23 @@ const accent = 'var(--dsw-alias-brand-primary, #ed67ad)'
|
|
|
306
624
|
const styles: Record<string, React.CSSProperties> = {
|
|
307
625
|
root: { position: 'relative', width: '100%', height: '100%', minHeight: 0, overflow: 'hidden', color: fg, background: layer },
|
|
308
626
|
frame: { display: 'block', width: '100%', height: '100%', border: 0, background: layer },
|
|
627
|
+
toolbar: { position: 'absolute', right: 12, top: 12, zIndex: 10, display: 'flex', flexDirection: 'column', alignItems: 'flex-end' },
|
|
628
|
+
exportTrigger: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 10px', border: `1px solid ${border}`, borderRadius: 6, color: fg, background: layer, boxShadow: 'var(--dsw-alias-shadow-sm, 0 2px 8px rgba(0,0,0,.12))', fontSize: 12, fontWeight: 500, cursor: 'pointer', backdropFilter: 'blur(8px)', transition: 'all 0.15s ease' },
|
|
629
|
+
triggerActive: { opacity: 0.75, cursor: 'wait' },
|
|
630
|
+
arrowIcon: { fontSize: 10, opacity: 0.6, marginLeft: 2 },
|
|
631
|
+
exportMenu: { marginTop: 6, width: 220, padding: 12, border: `1px solid ${border}`, borderRadius: 8, background: layer, boxShadow: 'var(--dsw-alias-shadow-md, 0 6px 20px rgba(0,0,0,.18))', backdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', gap: 10 },
|
|
632
|
+
menuHeader: { display: 'flex', flexDirection: 'column', gap: 2, paddingBottom: 6, borderBottom: `1px solid ${border}` },
|
|
633
|
+
menuTitle: { fontSize: 12, fontWeight: 600, color: fg },
|
|
634
|
+
menuSub: { fontSize: 10, color: muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
635
|
+
formatRow: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 4 },
|
|
636
|
+
formatBtn: { padding: '4px 2px', border: `1px solid ${border}`, borderRadius: 4, background: 'transparent', color: muted, fontSize: 10.5, cursor: 'pointer', textAlign: 'center', transition: 'all 0.1s ease' },
|
|
637
|
+
formatBtnActive: { color: fg, fontWeight: 600, background: 'var(--dsw-alias-fill-quaternary, rgba(127,127,127,0.12))' },
|
|
638
|
+
checkRow: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: muted, cursor: 'pointer', userSelect: 'none' },
|
|
639
|
+
checkbox: { cursor: 'pointer', margin: 0 },
|
|
640
|
+
actionCol: { display: 'flex', flexDirection: 'column', gap: 6, paddingTop: 4 },
|
|
641
|
+
actionBtn: { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, padding: '6px 10px', border: `1px solid ${border}`, borderRadius: 5, background: 'transparent', color: fg, fontSize: 11.5, cursor: 'pointer', transition: 'all 0.12s ease' },
|
|
642
|
+
actionBtnPrimary: { color: '#ffffff', border: 'none', fontWeight: 500 },
|
|
643
|
+
toast: { position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)', zIndex: 12, display: 'flex', alignItems: 'center', gap: 6, padding: '6px 14px', border: `1px solid ${border}`, borderRadius: 20, background: layer, color: fg, boxShadow: 'var(--dsw-alias-shadow-md, 0 4px 14px rgba(0,0,0,.16))', fontSize: 12, backdropFilter: 'blur(10px)', pointerEvents: 'none' },
|
|
309
644
|
status: { position: 'absolute', right: 10, bottom: 9, zIndex: 2, display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', border: `1px solid ${border}`, borderRadius: 999, color: muted, background: layer, boxShadow: 'var(--dsw-alias-shadow-sm, 0 2px 8px rgba(0,0,0,.12))', fontSize: 11, pointerEvents: 'none' },
|
|
310
645
|
statusError: { color: 'var(--dsw-alias-danger, #d84f5f)' },
|
|
311
646
|
dot: { width: 6, height: 6, borderRadius: 99, background: accent },
|