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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # dsh-drawai
6
6
 
7
- [![GitHub stars](https://img.shields.io/github/stars/fourzkw/dsh-drawai?style=social)](https://github.com/fourzkw/dsh-drawai) [![MIT License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
7
+ [![GitHub stars](https://img.shields.io/github/stars/fourzkw/dsh-drawai?style=social)](https://github.com/fourzkw/dsh-drawai) [![npm downloads](https://img.shields.io/npm/dt/dsh-drawai)](https://www.npmjs.com/package/dsh-drawai) [![MIT License](https://img.shields.io/badge/license-MIT-blue)](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(2×);「看一眼画布效果」走 DSH 附件服务,**不在工作区落文件** |
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 与 PNG(2×):没有 PDF。
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
- const grid = clone.querySelector('rect[fill^="url(#drawai-grid-major"]')
7721
- if (grid !== null) {
7722
- grid.setAttribute('x', String(bounds.minX - pad))
7723
- grid.setAttribute('y', String(bounds.minY - pad))
7724
- grid.setAttribute('width', String(w))
7725
- grid.setAttribute('height', String(h))
7726
- }
7727
- // 网格图案是半透明的:不加一层底色的话,PNG 出来是透明背景。
7728
- const paper = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
7729
- paper.setAttribute('x', String(bounds.minX - pad))
7730
- paper.setAttribute('y', String(bounds.minY - pad))
7731
- paper.setAttribute('width', String(w))
7732
- paper.setAttribute('height', String(h))
7733
- paper.setAttribute('fill', mode === 'dark' ? '#1b1b1b' : '#ffffff')
7734
- clone.insertBefore(paper, clone.firstChild)
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
- const scale = 2
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
- context.fillStyle = mode === 'dark' ? '#1b1b1b' : '#ffffff'
7855
- context.fillRect(0, 0, canvas.width, canvas.height)
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')→ 浏览器下载;AI 要的是对象
9278
- // (`{format, requestId, name}`)→ svg 把文本回执给宿主落盘、png 仍走浏览器下载。
9279
- // 导出选中:`{format, ids}` —— 不清选区(还要用 ids),从克隆里剔掉未选中的单元。
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 fromAi = typeof request === 'object' && request !== null
9285
- const format = fromAi ? request.format : request
9286
- const requestId = fromAi && typeof request.requestId === 'string' ? request.requestId : null
9287
- const askedName = fromAi && typeof request.name === 'string' && request.name.length > 0 ? request.name : null
9288
- const exportIds = fromAi && Array.isArray(request.ids) ? request.ids : null
9289
- const built = buildExportSvg(exportIds !== null ? { ids: exportIds } : undefined)
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, '') + (exportIds !== null ? '-selection' : '')
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
- // 与手动导出同一档 2×;体量不再由客户端预检截断(宿主对 render-result 放开了 body 上限)。
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(2×)',
11279
+ '导出 PNG(4×)',
11253
11280
  () => {
11254
11281
  setSelectedIds([])
11255
- setExportRequest('png')
11282
+ setExportRequest({ format: 'png', scale: 4 })
11256
11283
  },
11257
- { hint: '位图,适合贴到文档里', disabled: empty },
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: '只导出当前选区', disabled: empty || selectedIds.length === 0 },
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
- // `svg` 给了(非 null)= 这个节点的内容是一张图,标签可以省。
948
- // 注意**内容合不合法**交给 nodeStyleFromOp 去判(那里才有精确的报错)——
949
- // 在这里按"是不是合法 SVG"决定要不要 label,会让一段写坏的标记先撞上"label 缺失"。
950
- const svgOp = has(op, 'svg') && op.svg !== null && op.svg !== undefined
951
- // 标签在 drawio 里是**可选**的(`value=""` 就是一个没有文字的单元)——
952
- // 内嵌 SVG 的节点通常只画图不带字,所以给 svg 内容时可以省掉 label。
953
- const label = typeof op.label === 'string' && op.label.length > 0 ? op.label : svgOp ? '' : undefined
954
- if (label === undefined) {
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
- // 关键:只有显式给了坐标才写入。缺省写 (0,0) 会让 placeMissing 以为"坐标齐全"而不补位,
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('+ node ' + id + (svgNode ? ' [svg]' : ' "' + label + '"') + (has(op, 'x') || has(op, 'y') ? ' at explicit coords' : ' (position pending)'))
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 节点**可以没有 label**(画图不带字);要图下面带文字就照常给 label,它画在图上。
2081
+ · svg 节点通常不带 label(画图不带字);要图上叠字就照常给 label。普通矩形/面板同样可以省 label。
2076
2082
  · 颜色写在 SVG 自己身上(fill/stroke)—— 配色参数对它是无效的(图片不受 fillColor/strokeColor 影响)。
2077
2083
  · 内容上限 250000 字符;**别放位图/base64**(那是图片不是图形,本画布画不出来)。
2078
2084
  · 改内容只能整段替换(没有"改某一根线"的 op):先 read 拿回当前 svg,改完整段写回。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-drawai",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "description": "DSH 右侧栏的可编辑画布 + AI 语义绘图工具(diagram_apply / diagram_read),直接读写原生 .drawio 文件",
6
6
  "license": "MIT",