dsh-drawai 0.2.5 → 0.2.7
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 +4 -4
- package/lib/client.js +77 -37
- package/lib/index.js +18 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# dsh-drawai
|
|
6
6
|
|
|
7
|
-
[](https://github.com/fourzkw/dsh-drawai) [](LICENSE)
|
|
7
|
+
[](https://github.com/fourzkw/dsh-drawai) [](https://www.npmjs.com/package/dsh-drawai) [](LICENSE)
|
|
8
8
|
|
|
9
9
|
## 功能一览
|
|
10
10
|
|
|
@@ -56,7 +56,7 @@ dsh plugin --profile web add github:fourzkw/dsh-drawai
|
|
|
56
56
|
|
|
57
57
|
| 入口 | 你能得到什么 |
|
|
58
58
|
|---|---|
|
|
59
|
-
| **右侧栏画布** | 打开或新建画布、画节点与连线、就地改字、图层、分区、顺序、多选对齐、查找、微移、多页切换、复制粘贴、导出 SVG / PNG
|
|
59
|
+
| **右侧栏画布** | 打开或新建画布、画节点与连线、就地改字、图层、分区、顺序、多选对齐、查找、微移、多页切换、复制粘贴、导出 SVG / PNG / 透明 PNG(4×,含导出选中) |
|
|
60
60
|
| **`diagram_apply`** | 让模型按 ops 改图:增删改、重接边、复制、排序、分层、导出;支持 `ids` 批量、`as` 别名、`expectRevision` |
|
|
61
61
|
| **`diagram_read`** | 让模型看见图的结构与样式、图层与父级、页面尺寸、你此刻的选区、文件指纹 `revision` |
|
|
62
62
|
| **选中与回退** | 你选中什么模型就知道什么;模型的每一轮改动都能在界面上退回去 |
|
|
@@ -78,7 +78,7 @@ dsh plugin --profile web add github:fourzkw/dsh-drawai
|
|
|
78
78
|
| **图层** | 列出 / 新建 / 显示隐藏 / 锁定 / 重命名 / 删除(合并到邻层)/ 选中移层 / 设为当前层;隐藏与锁定写入文件 |
|
|
79
79
|
| **顺序** | 置顶 / 上移 / 下移 / 置底 —— 写回时**真的改动文件里单元的先后**(否则重新打开就变回去了) |
|
|
80
80
|
| **自定义数据** | 右键「编辑数据」,按 `key=value` 一行一项改单元属性;按 user object 存回文件,drawio 打开不会丢 |
|
|
81
|
-
| **导出** | SVG / PNG(
|
|
81
|
+
| **导出** | SVG / PNG(4×) / 透明 PNG(4×);「看一眼画布效果」走 DSH 附件服务,**不在工作区落文件** |
|
|
82
82
|
| **回退** | 「编辑 → 撤销 AI 改动」最多可以连着退 8 步 |
|
|
83
83
|
|
|
84
84
|
## 🤖 两个工具 / The two tools
|
|
@@ -211,7 +211,7 @@ id / layer / parent、选区能上报给模型、写回是逐单元的、画布
|
|
|
211
211
|
- **多页**:可切换编辑当前页(视图菜单);其它页保存时原样保留。AI 可用 `diagram_read` / `diagram_apply` 的 `page` 参数指定页。多页的**新建页**尚未实现。
|
|
212
212
|
- 独立 `edgeLabel` 单元只读;`entityRelation` 等其它 edgeStyle、箭头字形、旋转 / 翻转 / 透明、stencil 形状只保留、不渲染;`.drawio.svg` / `.png` / `.html` 内嵌载体不支持。
|
|
213
213
|
- **图片**:内嵌 SVG(`data:image/svg+xml,…`)与内嵌位图(PNG/JPEG/GIF/WebP data URI)都会画出来;**外链 / 相对路径**图片仍按矩形占位,单元原样保留(工作区磁盘上的图片文件画布直接读不到——要复刻请用 `traceImage`)。
|
|
214
|
-
- 导出只有 SVG
|
|
214
|
+
- 导出只有 SVG、PNG(4×) 与透明 PNG(4×):没有 PDF。
|
|
215
215
|
- 交互细节:对齐辅助线只跟节点比(不与折点、端点对齐);单条选中的连线不能复制。
|
|
216
216
|
|
|
217
217
|
完整的取舍与实现细节在 [docs/design.md](docs/design.md)。
|
package/lib/client.js
CHANGED
|
@@ -7677,6 +7677,7 @@ window.__ModuleLoader__.load({
|
|
|
7677
7677
|
if (junk[i].parentNode !== null) junk[i].parentNode.removeChild(junk[i])
|
|
7678
7678
|
}
|
|
7679
7679
|
const ids = opts !== undefined && opts !== null && Array.isArray(opts.ids) ? opts.ids : null
|
|
7680
|
+
const transparent = opts !== undefined && opts !== null && opts.transparent === true
|
|
7680
7681
|
let boundsDoc = current
|
|
7681
7682
|
if (ids !== null && ids.length > 0) {
|
|
7682
7683
|
const filtered = docFilteredToIds(current, ids)
|
|
@@ -7716,22 +7717,35 @@ window.__ModuleLoader__.load({
|
|
|
7716
7717
|
// 就是严格模式)会因为"用了未声明的前缀"整份丢弃:导出的 SVG/PNG 里那张图凭空消失。
|
|
7717
7718
|
clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
|
|
7718
7719
|
clone.removeAttribute('style')
|
|
7719
|
-
//
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7720
|
+
// 视口底:网格纸(url(#drawai-grid-major…))或关网格时的实色纸。它是 svg 下第一个 <rect>。
|
|
7721
|
+
let pageRect = null
|
|
7722
|
+
for (let i = 0; i < clone.childNodes.length; i += 1) {
|
|
7723
|
+
const child = clone.childNodes[i]
|
|
7724
|
+
if (child.nodeType === 1 && String(child.nodeName).toLowerCase() === 'rect') {
|
|
7725
|
+
pageRect = child
|
|
7726
|
+
break
|
|
7727
|
+
}
|
|
7728
|
+
}
|
|
7729
|
+
if (transparent) {
|
|
7730
|
+
// 透明导出:底纸/网格都要拿掉,否则 alpha 通道会被盖成不透明。
|
|
7731
|
+
if (pageRect !== null && pageRect.parentNode !== null) pageRect.parentNode.removeChild(pageRect)
|
|
7732
|
+
} else {
|
|
7733
|
+
// 网格纸在实时视图里只铺满"当前视口";导出要铺满整图,否则缩放状态下网格会断掉。
|
|
7734
|
+
if (pageRect !== null) {
|
|
7735
|
+
pageRect.setAttribute('x', String(bounds.minX - pad))
|
|
7736
|
+
pageRect.setAttribute('y', String(bounds.minY - pad))
|
|
7737
|
+
pageRect.setAttribute('width', String(w))
|
|
7738
|
+
pageRect.setAttribute('height', String(h))
|
|
7739
|
+
}
|
|
7740
|
+
// 网格图案是半透明的:不加一层底色的话,PNG 出来是透明背景。
|
|
7741
|
+
const paper = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
|
|
7742
|
+
paper.setAttribute('x', String(bounds.minX - pad))
|
|
7743
|
+
paper.setAttribute('y', String(bounds.minY - pad))
|
|
7744
|
+
paper.setAttribute('width', String(w))
|
|
7745
|
+
paper.setAttribute('height', String(h))
|
|
7746
|
+
paper.setAttribute('fill', mode === 'dark' ? '#1b1b1b' : '#ffffff')
|
|
7747
|
+
clone.insertBefore(paper, clone.firstChild)
|
|
7748
|
+
}
|
|
7735
7749
|
return { node: clone, width: w, height: h }
|
|
7736
7750
|
}
|
|
7737
7751
|
|
|
@@ -7837,12 +7851,17 @@ window.__ModuleLoader__.load({
|
|
|
7837
7851
|
setTimeout(() => URL.revokeObjectURL(url), 2000)
|
|
7838
7852
|
}
|
|
7839
7853
|
|
|
7840
|
-
function downloadPng(node, width, height, filename) {
|
|
7854
|
+
function downloadPng(node, width, height, filename, opts) {
|
|
7841
7855
|
const markup = new XMLSerializer().serializeToString(node)
|
|
7842
7856
|
const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }))
|
|
7843
7857
|
const image = new Image()
|
|
7844
7858
|
image.onload = () => {
|
|
7845
|
-
|
|
7859
|
+
// 默认 4×(与透明高质量同档)。过大时压到浏览器常见 canvas 上限,避免整图失败。
|
|
7860
|
+
let scale = opts !== undefined && opts !== null && typeof opts.scale === 'number' && opts.scale > 0 ? opts.scale : 4
|
|
7861
|
+
const transparent = opts !== undefined && opts !== null && opts.transparent === true
|
|
7862
|
+
// Chromium / 多数桌面浏览器单边可到 16384;再高容易在部分环境创建失败。
|
|
7863
|
+
const maxEdge = 16384
|
|
7864
|
+
while (scale > 1 && (width * scale > maxEdge || height * scale > maxEdge)) scale = scale / 2
|
|
7846
7865
|
const canvas = document.createElement('canvas')
|
|
7847
7866
|
canvas.width = Math.max(1, Math.round(width * scale))
|
|
7848
7867
|
canvas.height = Math.max(1, Math.round(height * scale))
|
|
@@ -7851,8 +7870,10 @@ window.__ModuleLoader__.load({
|
|
|
7851
7870
|
URL.revokeObjectURL(url)
|
|
7852
7871
|
return
|
|
7853
7872
|
}
|
|
7854
|
-
|
|
7855
|
-
|
|
7873
|
+
if (transparent !== true) {
|
|
7874
|
+
context.fillStyle = mode === 'dark' ? '#1b1b1b' : '#ffffff'
|
|
7875
|
+
context.fillRect(0, 0, canvas.width, canvas.height)
|
|
7876
|
+
}
|
|
7856
7877
|
context.drawImage(image, 0, 0, canvas.width, canvas.height)
|
|
7857
7878
|
URL.revokeObjectURL(url)
|
|
7858
7879
|
canvas.toBlob((blob) => {
|
|
@@ -9274,19 +9295,23 @@ window.__ModuleLoader__.load({
|
|
|
9274
9295
|
// 导出。刻意放在 effect 里跑:按钮先清掉选中,等这一帧渲染完(DOM 里没有手柄和选中框了)
|
|
9275
9296
|
// 再序列化,产物才干净。若在点击回调里直接导,会把手柄一起拍进去。
|
|
9276
9297
|
//
|
|
9277
|
-
// 两种触发形态:菜单里点的是字符串('svg' / 'png')→
|
|
9278
|
-
// (`{format, requestId
|
|
9279
|
-
//
|
|
9298
|
+
// 两种触发形态:菜单里点的是字符串('svg' / 'png')→ 浏览器下载;对象形态
|
|
9299
|
+
// (`{format, requestId?, name?, ids?, transparent?, scale?}`)→ 菜单的选区/透明导出,
|
|
9300
|
+
// 或 AI 的 `{format, requestId, name}`(svg 回执落盘、png 仍走浏览器下载)。
|
|
9280
9301
|
React.useEffect(() => {
|
|
9281
9302
|
if (exportRequest === null) return
|
|
9282
9303
|
const request = exportRequest
|
|
9283
9304
|
setExportRequest(null)
|
|
9284
|
-
const
|
|
9285
|
-
const format =
|
|
9286
|
-
const requestId =
|
|
9287
|
-
const askedName =
|
|
9288
|
-
const exportIds =
|
|
9289
|
-
const
|
|
9305
|
+
const fromObj = typeof request === 'object' && request !== null
|
|
9306
|
+
const format = fromObj ? request.format : request
|
|
9307
|
+
const requestId = fromObj && typeof request.requestId === 'string' ? request.requestId : null
|
|
9308
|
+
const askedName = fromObj && typeof request.name === 'string' && request.name.length > 0 ? request.name : null
|
|
9309
|
+
const exportIds = fromObj && Array.isArray(request.ids) ? request.ids : null
|
|
9310
|
+
const transparent = fromObj && request.transparent === true
|
|
9311
|
+
const scale = fromObj && typeof request.scale === 'number' && request.scale > 0 ? request.scale : 4
|
|
9312
|
+
const built = buildExportSvg(
|
|
9313
|
+
exportIds !== null || transparent ? { ids: exportIds !== null ? exportIds : undefined, transparent: transparent } : undefined,
|
|
9314
|
+
)
|
|
9290
9315
|
if (built === null) {
|
|
9291
9316
|
if (requestId !== null) reportExportResult(requestId, format, { ok: false, error: '画布还没准备好,导出没做成' })
|
|
9292
9317
|
else if (exportIds !== null) setSaveNote('导出选中失败:选区里没有可导出的单元')
|
|
@@ -9297,7 +9322,9 @@ window.__ModuleLoader__.load({
|
|
|
9297
9322
|
const name =
|
|
9298
9323
|
askedName !== null
|
|
9299
9324
|
? askedName
|
|
9300
|
-
: String(source).split(/[\\/]/).pop().replace(/\.drawio$/i, '') +
|
|
9325
|
+
: String(source).split(/[\\/]/).pop().replace(/\.drawio$/i, '') +
|
|
9326
|
+
(exportIds !== null ? '-selection' : '') +
|
|
9327
|
+
(transparent ? '-transparent' : '')
|
|
9301
9328
|
if (format === 'svg') {
|
|
9302
9329
|
const markup = new XMLSerializer().serializeToString(built.node)
|
|
9303
9330
|
// AI 要的是"落成工作区里的一个文件":把文本回执给宿主,由它写在 .drawio 旁边。
|
|
@@ -9309,7 +9336,7 @@ window.__ModuleLoader__.load({
|
|
|
9309
9336
|
downloadBlob(new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }), name + '.svg')
|
|
9310
9337
|
return
|
|
9311
9338
|
}
|
|
9312
|
-
downloadPng(built.node, built.width, built.height, name + '.png')
|
|
9339
|
+
downloadPng(built.node, built.width, built.height, name + '.png', { scale: scale, transparent: transparent })
|
|
9313
9340
|
// PNG 走浏览器下载(AI 触发的也一样):宿主写二进制得另开通道,而"点一次导出"就有的东西
|
|
9314
9341
|
// 不值得为它搬 base64。下载成不成浏览器不给回执,所以这里只报"已经交给下载"。
|
|
9315
9342
|
if (requestId !== null) {
|
|
@@ -9333,7 +9360,7 @@ window.__ModuleLoader__.load({
|
|
|
9333
9360
|
return
|
|
9334
9361
|
}
|
|
9335
9362
|
setSaveNote('正在把画布渲成图片给 AI 看…')
|
|
9336
|
-
//
|
|
9363
|
+
// AI 看图用 2×(比手动导出的 4× 更轻);体量不再由客户端预检截断(宿主对 render-result 放开了 body 上限)。
|
|
9337
9364
|
// 部署附件服务若自带 maxImageBytes,仍会在那一侧拒收。
|
|
9338
9365
|
renderPngPayload(built.node, built.width, built.height, 2)
|
|
9339
9366
|
.then((payload) => {
|
|
@@ -11249,12 +11276,20 @@ window.__ModuleLoader__.load({
|
|
|
11249
11276
|
{ hint: '矢量,可再编辑', disabled: empty },
|
|
11250
11277
|
),
|
|
11251
11278
|
item(
|
|
11252
|
-
'导出 PNG(
|
|
11279
|
+
'导出 PNG(4×)',
|
|
11253
11280
|
() => {
|
|
11254
11281
|
setSelectedIds([])
|
|
11255
|
-
setExportRequest('png')
|
|
11282
|
+
setExportRequest({ format: 'png', scale: 4 })
|
|
11256
11283
|
},
|
|
11257
|
-
{ hint: '
|
|
11284
|
+
{ hint: '高质量位图,白/深色底,适合贴到文档里', disabled: empty },
|
|
11285
|
+
),
|
|
11286
|
+
item(
|
|
11287
|
+
'导出透明 PNG(4×)',
|
|
11288
|
+
() => {
|
|
11289
|
+
setSelectedIds([])
|
|
11290
|
+
setExportRequest({ format: 'png', transparent: true, scale: 4 })
|
|
11291
|
+
},
|
|
11292
|
+
{ hint: '高质量透明背景,适合叠到幻灯片/海报上', disabled: empty },
|
|
11258
11293
|
),
|
|
11259
11294
|
item(
|
|
11260
11295
|
'导出选中为 SVG',
|
|
@@ -11263,8 +11298,13 @@ window.__ModuleLoader__.load({
|
|
|
11263
11298
|
),
|
|
11264
11299
|
item(
|
|
11265
11300
|
'导出选中为 PNG',
|
|
11266
|
-
() => setExportRequest({ format: 'png', ids: selectedIds.slice() }),
|
|
11267
|
-
{ hint: '
|
|
11301
|
+
() => setExportRequest({ format: 'png', ids: selectedIds.slice(), scale: 4 }),
|
|
11302
|
+
{ hint: '只导出当前选区(白/深色底,4×)', disabled: empty || selectedIds.length === 0 },
|
|
11303
|
+
),
|
|
11304
|
+
item(
|
|
11305
|
+
'导出选中为透明 PNG',
|
|
11306
|
+
() => setExportRequest({ format: 'png', ids: selectedIds.slice(), transparent: true, scale: 4 }),
|
|
11307
|
+
{ hint: '选区透明背景,4× 分辨率', disabled: empty || selectedIds.length === 0 },
|
|
11268
11308
|
),
|
|
11269
11309
|
],
|
|
11270
11310
|
},
|
package/lib/index.js
CHANGED
|
@@ -944,15 +944,14 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
944
944
|
}
|
|
945
945
|
|
|
946
946
|
if (kind === 'addNode') {
|
|
947
|
-
// `
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
throw new Error('ops[' + i + '] addNode needs a non-empty string "label"(画图不带字的 svg 节点可以省 label,但要给 "svg" 内容)')
|
|
947
|
+
// 标签在 drawio 里是可选的(`value=""` = 没有文字的单元)。装饰面板 / 底框 /
|
|
948
|
+
// 内嵌 SVG 都可以省 label 或给 "";没给就当空串。给了就必须是 string。
|
|
949
|
+
let label = ''
|
|
950
|
+
if (has(op, 'label') && op.label !== null && op.label !== undefined) {
|
|
951
|
+
if (typeof op.label !== 'string') {
|
|
952
|
+
throw new Error('ops[' + i + '] addNode "label" must be a string(装饰面板可省或给 "")')
|
|
953
|
+
}
|
|
954
|
+
label = op.label
|
|
956
955
|
}
|
|
957
956
|
let id = typeof op.id === 'string' && op.id.length > 0 ? op.id : undefined
|
|
958
957
|
if (id !== undefined && findNode(id) >= 0) {
|
|
@@ -969,17 +968,22 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
969
968
|
// 形状/配色是糖,落盘只有 style 键;不给就是 drawio 缺省(空串 = 普通矩形 + 缺省配色)。
|
|
970
969
|
style: style,
|
|
971
970
|
// svg 节点缺省是**正方形**:按标签文字估宽(estimateWidth)对图没有意义。
|
|
971
|
+
// 空 label 的装饰面板也请显式给 w/h,否则会落到最短标签估宽(130)。
|
|
972
972
|
w: numberOr(op.w, svgNode ? DEFAULT_SVG_W : estimateWidth(label)),
|
|
973
973
|
h: numberOr(op.h, svgNode ? DEFAULT_SVG_H : DEFAULT_H),
|
|
974
974
|
}
|
|
975
975
|
if (typeof op.layer === 'string' && op.layer.length > 0) node.layer = resolveLayerRef(op.layer, 'ops[' + i + '] addNode layer')
|
|
976
|
-
//
|
|
976
|
+
// 位置:只有显式给了坐标才写入。缺省写 (0,0) 会让 placeMissing 以为"坐标齐全"而不补位,
|
|
977
977
|
// 新节点就会堆在原点压住别人。
|
|
978
978
|
if (has(op, 'x')) node.x = numberOr(op.x, 0)
|
|
979
979
|
if (has(op, 'y')) node.y = numberOr(op.y, 0)
|
|
980
980
|
doc.nodes.push(node)
|
|
981
981
|
recordCreated(id, 'node', label)
|
|
982
|
-
notes.push(
|
|
982
|
+
notes.push(
|
|
983
|
+
'+ node ' + id +
|
|
984
|
+
(svgNode ? ' [svg]' : label.length > 0 ? ' "' + label + '"' : ' (empty label)') +
|
|
985
|
+
(has(op, 'x') || has(op, 'y') ? ' at explicit coords' : ' (position pending)'),
|
|
986
|
+
)
|
|
983
987
|
continue
|
|
984
988
|
}
|
|
985
989
|
|
|
@@ -2018,6 +2022,8 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
|
|
|
2018
2022
|
- **改一组用 ids**:{op:"setStyle", ids:["n1","n2","n5"], style:"green"} —— setStyle / setLabel /
|
|
2019
2023
|
move / remove 都收 ids,不要为了"把这几个改成绿色"发 12 个 op。批量 move 只收 dx/dy
|
|
2020
2024
|
(给一个绝对 x/y 等于把它们叠成一摞,宿主会直接拒绝)。
|
|
2025
|
+
- **addNode 的 label 可省或给 ""**:装饰面板 / 底框 / 纯色块不需要字时直接省掉;空 label 时请显式给 w/h
|
|
2026
|
+
(否则按最短标签估宽 130)。有字的节点照常写 label。
|
|
2021
2027
|
- **引用刚建的那个用 as**:{op:"addNode", label:"开始", as:"start"} 之后,同一个 ops 数组里
|
|
2022
2028
|
from/to/id 可以直接写 "start"。id 省略时是自动分配的(n 几/e 几),猜编号在非空画布上必错。
|
|
2023
2029
|
- **返回值里看 created/changed/removed**:新建单元的 id(含自动分配的)在这里,接着引用它们
|
|
@@ -2072,7 +2078,7 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
|
|
|
2072
2078
|
浏览器按 XML 解析会直接丢弃这份图 —— 屏幕上什么都没有,也不报错)。
|
|
2073
2079
|
· 默认 **imageAspect=0 = 拉伸铺满**节点框;想让图形等比缩放居中就加 keys:{imageAspect:1}
|
|
2074
2080
|
(那就把 w/h 设成图形的比例,否则四周会留白)。
|
|
2075
|
-
· svg
|
|
2081
|
+
· svg 节点通常不带 label(画图不带字);要图上叠字就照常给 label。普通矩形/面板同样可以省 label。
|
|
2076
2082
|
· 颜色写在 SVG 自己身上(fill/stroke)—— 配色参数对它是无效的(图片不受 fillColor/strokeColor 影响)。
|
|
2077
2083
|
· 内容上限 250000 字符;**别放位图/base64**(那是图片不是图形,本画布画不出来)。
|
|
2078
2084
|
· 改内容只能整段替换(没有"改某一根线"的 op):先 read 拿回当前 svg,改完整段写回。
|