dsh-drawai 0.1.0 → 0.1.1

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.
Files changed (3) hide show
  1. package/README.md +12 -5
  2. package/lib/client.js +640 -142
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -27,13 +27,16 @@ drawio 本体 ┘ ↑ 无损写回:画布读不懂的单元
27
27
  ## 安装 / Install
28
28
 
29
29
  ```sh
30
+ # 从 npm 安装(推荐:预构建,跳过 allowBuilds 构建授权)
31
+ dsh plugin --profile web add dsh-drawai
32
+
33
+ # 或者直接从 GitHub 装(仓库里没有 prepare 脚本,同样不需要构建)
30
34
  dsh plugin --profile web add github:fourzkw/dsh-drawai
31
35
  ```
32
36
 
33
37
  然后**重启 `dsh web`** —— 宿主侧只在启动时加载,这一步不能省。
34
38
 
35
- > **更新**:从 GitHub 安装的包,重新执行上面这条命令即可。等发布到 npm 之后,才会有
36
- > `dsh plugin --profile web update dsh-drawai@latest` 这种按版本更新的写法。
39
+ > **更新到最新版**:`dsh plugin --profile web update dsh-drawai@latest`
37
40
 
38
41
  重启之后,你会看到:
39
42
 
@@ -43,8 +46,7 @@ dsh plugin --profile web add github:fourzkw/dsh-drawai
43
46
  | `diagram_read` / `diagram_apply` | 模型工具集 |
44
47
  | `drawai-canvas` 技能 | 会话技能目录(模型按需取全文) |
45
48
 
46
- - 仓库里**没有 `prepare` / `postinstall` 脚本**,`lib/` 是已提交的构建产物 —— 从 GitHub 源码安装**不需要** `allowBuilds` 构建授权。
47
- - **还没有发布到 npm**,所以 `dsh plugin add dsh-drawai` 这种预构建安装暂时用不了,请用上面的 GitHub 形式。
49
+ - 两种装法都不需要 `allowBuilds` 构建授权:npm 包是预构建的;GitHub 源码包里 `lib/` 也已提交、没有 `prepare` / `postinstall` 脚本。
48
50
  - 手动挂载的兜底写法,以及本地 link 的开发做法,见[设计文档的「安装」一节](docs/design.md#安装)。
49
51
 
50
52
  ## 使用 / Usage
@@ -160,9 +162,14 @@ id / layer / parent、选区能上报给模型、写回是逐单元的、画布
160
162
  | 节点与独立文字默认字号 12、连线 10 | 画布右键「字号」,或 ops 的 `fontSize:18`(`null` = 删键回缺省) |
161
163
  | 缺省线型(实线、单向箭头、直角折线) | 画布右键,或 ops 的 `dash` / `arrow` / `line` |
162
164
  | 像素格 10px、吸附半格 5px | 菜单「整理几何(吸附到格线)」做一次性对齐 |
165
+ | 文字多的节点**纵向自适应**(只长不缩,独立文字不参与) | 不用调 —— 标签换行所需的高度会写回文件的 `h` |
163
166
  | 主题跟随 DSH 的明暗 | —— |
164
167
  | 显示层、当前层 | 「图层」菜单(会写进文件) |
165
168
 
169
+ 右键空白处就是**元素库**:形状平铺成一片缩略图,点哪个就直接放哪个形状的节点(没有"先在下拉里选中、
170
+ 再点新增"这一步)。节点上右键则是同一片缩略图,点一下就换成那个形状。就地改标签用 `Enter` 提交、
171
+ `Shift+Enter` 换行、`Esc` 取消。
172
+
166
173
  ## 须知 / Good to know
167
174
 
168
175
  - **兼容性**:DSH(DeepSeek Harness)Web;官方包以 `peerDependencies` 声明(`@deepseek-ai/dsh-tools`),Node `>= 20`。
@@ -188,7 +195,7 @@ id / layer / parent、选区能上报给模型、写回是逐单元的、画布
188
195
  npm run watch # 常驻:监听 src/,变化即重建 lib/
189
196
  npm run build # 一次性构建
190
197
  npm run check # 安装前烟测(含 lib 与 src 是否同步)
191
- npm test # 五份自测(mxfile 编解码 / 路由预览 / 宿主行为 / 渲染 / 组件),1472 项断言
198
+ npm test # 五份自测(mxfile 编解码 / 路由预览 / 宿主行为 / 渲染 / 组件),1526 项断言
192
199
  ```
193
200
 
194
201
  > ⚠️ **绝不改 `lib/` 下的文件** —— 它们是构建产物,会被覆盖。改 `src/`。
package/lib/client.js CHANGED
@@ -1044,7 +1044,12 @@ window.__ModuleLoader__.load({
1044
1044
  '.drawai-rs-n{cursor:ns-resize}.drawai-rs-s{cursor:ns-resize}',
1045
1045
  '.drawai-rs-e{cursor:ew-resize}.drawai-rs-w{cursor:ew-resize}',
1046
1046
  // 就地编辑框是画布里唯一需要能选中文字的元素 —— 必须把 user-select 放开回来。
1047
- '.drawai-edit{position:absolute;z-index:5;box-sizing:border-box;border:2px solid #1a73e8;border-radius:4px;background:#fff;color:#000;font:12px Helvetica,Arial,sans-serif;text-align:center;padding:0 4px;outline:none;user-select:text;-webkit-user-select:text}',
1047
+ //
1048
+ // 它是 **textarea**(不是单行 input):需求"输入过程中也要自动换行"+"输入时的效果与
1049
+ // 输完后一致"要求它能按节点的文字区宽度换行、并按节点自己的字号字色画。
1050
+ // 用 `outline` 而不是 `border`:边框会占掉 2px 内容宽度,断行位置就跟最终效果差一个字符。
1051
+ // 背景透明、颜色与字号由 inline style 按**这个节点**给(见 renderDiagram 的 labelTextBox)。
1052
+ '.drawai-edit{position:absolute;z-index:5;box-sizing:border-box;outline:2px solid #1a73e8;outline-offset:-2px;border:none;border-radius:4px;background:transparent;color:#000;font-family:Helvetica, Arial, sans-serif;font-size:12px;line-height:14px;text-align:center;padding:0;margin:0;resize:none;overflow:hidden;white-space:pre-wrap;word-break:break-word;user-select:text;-webkit-user-select:text}',
1048
1053
  '.drawai-tools{display:flex;gap:6px;align-items:center;flex-wrap:wrap}',
1049
1054
  // 右键菜单 / 元素库
1050
1055
  '.drawai-menu{position:absolute;z-index:20;min-width:196px;padding:8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,#555);background:var(--dsw-alias-bg-overlay,#1e1e1e);box-shadow:0 10px 28px rgba(0,0,0,.4);color:var(--dsw-alias-label-primary,#e6e6e6)}',
@@ -1053,6 +1058,10 @@ window.__ModuleLoader__.load({
1053
1058
  '.drawai-chip{display:flex;align-items:center;justify-content:center;border:1px solid transparent;border-radius:6px;background:transparent;padding:1px;cursor:pointer}',
1054
1059
  '.drawai-chip:hover{border-color:var(--dsw-alias-brand-primary,#4c8dff);background:rgba(76,141,255,.14)}',
1055
1060
  '.drawai-swatches{display:flex;gap:6px;margin-top:8px}',
1061
+ // 形状**平铺**那一块:与下面那些「类:当前值」的行用同一条分隔线,
1062
+ // 但里面的缩略图按原尺寸自动填充(5 列 1fr 会把 44px 的缩略图挤扁)。
1063
+ '.drawai-pick{margin-top:8px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-l1,#3a3a3a)}',
1064
+ '.drawai-pick>.drawai-grid{grid-template-columns:repeat(auto-fill,minmax(42px,1fr))}',
1056
1065
  '.drawai-swatch{width:18px;height:18px;border-radius:4px;border:1px solid rgba(0,0,0,.3);cursor:pointer;padding:0}',
1057
1066
  '.drawai-swatch.on{outline:2px solid var(--dsw-alias-brand-primary,#4c8dff);outline-offset:1px}',
1058
1067
  // 当前生效的选项(线型/箭头方向)—— 和色板用同一套"被选中"语言。
@@ -1122,7 +1131,15 @@ window.__ModuleLoader__.load({
1122
1131
  return total
1123
1132
  }
1124
1133
 
1125
- function wrapLabel(label, maxWidth) {
1134
+ /**
1135
+ * 按宽度把标签切成若干行。
1136
+ *
1137
+ * `size` 是**标签真实的字号**(节点 style 里的 fontSize,缺省 FSIZE)——
1138
+ * 不传的话大字号标签会按 12px 量宽度,断行位置跟着画出来的东西对不上
1139
+ * ("输入时和输完后不一样"的一类来源)。
1140
+ */
1141
+ function wrapLabel(label, maxWidth, size) {
1142
+ const fs = numberOr(size, FSIZE)
1126
1143
  const lines = []
1127
1144
  const paras = String(label).split('\n')
1128
1145
  for (let p = 0; p < paras.length; p += 1) {
@@ -1130,7 +1147,7 @@ window.__ModuleLoader__.load({
1130
1147
  let current = ''
1131
1148
  let width = 0
1132
1149
  for (let i = 0; i < chars.length; i += 1) {
1133
- const cw = charWidth(chars[i], FSIZE)
1150
+ const cw = charWidth(chars[i], fs)
1134
1151
  if (width + cw > maxWidth && current.length > 0) {
1135
1152
  lines.push(current)
1136
1153
  current = ''
@@ -1195,6 +1212,144 @@ window.__ModuleLoader__.load({
1195
1212
  return nodeShapeFromStyle(typeof node.style === 'string' ? node.style : '')
1196
1213
  }
1197
1214
 
1215
+ /**
1216
+ * 一个节点里,标签**能不能按宽度换行**。与渲染那一处读的是同一个键
1217
+ * (drawio 的语义:`whiteSpace=nowrap` 不换行,其余含缺省都换行)。
1218
+ */
1219
+ function labelWraps(node) {
1220
+ const style = typeof node.style === 'string' ? node.style : ''
1221
+ return styleGet(style, 'whiteSpace', 'wrap') !== 'nowrap'
1222
+ }
1223
+
1224
+ /**
1225
+ * 标签能用的横向宽度 —— 就是渲染时传给 `wrapLabel` 的那个 maxWidth。
1226
+ *
1227
+ * 菱形/椭圆里的字要缩进来才不压到斜边,所以可用宽度比节点宽度窄。
1228
+ * **只有这一处**:换行位置、自适应高度、就地编辑框的内边距全都读它,
1229
+ * 三处各写一份迟早会漂移("输入时和输完后不一样"最常见的原因)。
1230
+ */
1231
+ function labelTextWidth(node, w) {
1232
+ const shape = shapeOf(node)
1233
+ if (shape === 'diamond') return w * 0.6
1234
+ if (shape === 'ellipse') return w * 0.72
1235
+ return w - 12
1236
+ }
1237
+
1238
+ /** 字号 → 行高。渲染与就地编辑框共用,否则编辑时会与最终效果差几像素。 */
1239
+ function labelLineHeight(fontSize) {
1240
+ return LHEIGHT * (numberOr(fontSize, FSIZE) / FSIZE)
1241
+ }
1242
+
1243
+ /**
1244
+ * 就地编辑框该摆在哪儿、多大 —— **文字区盒子 × 缩放 + 画布偏移**,纯函数。
1245
+ *
1246
+ * 为什么不按整个节点框铺:编辑框必须与最终文字**同一个宽度**,否则断行位置都不一样
1247
+ * (需求"输入时的效果与输入完成后一致")。菱形/椭圆的文字区比节点窄,这一条尤其明显。
1248
+ * 所以左/上/宽取 `labelTextBox`,高度取"节点框高与行数所需高度的大者"——
1249
+ * 打到第 3 行时编辑框自己长高,不会把字裁掉。
1250
+ *
1251
+ * `ctm` 是 SVG 的屏幕变换(只有缩放与平移,没有旋转),`origin` 是画布容器左上角的屏幕坐标。
1252
+ */
1253
+ function nodeEditorBox(node, text, ctm, origin) {
1254
+ const scale = numberOr(ctm.a, 1)
1255
+ const geo = nodeBoxOf(node)
1256
+ const label = typeof text === 'string' ? text : ''
1257
+ const textBox = labelTextBox({ node: node, geo: geo, text: label })
1258
+ const size = nodeEditorTextSize(node, label)
1259
+ // 高度:行数不够时就是原来那个节点框高(编辑一个小节点不该让它看起来先跳高),
1260
+ // 行数超过时按行数长高 —— 打到第 3 行不会把字裁掉。
1261
+ const boxH = Math.max(textBox.h, FALLBACK_NODE_H)
1262
+ return {
1263
+ left: textBox.x * scale + numberOr(ctm.e, 0) - origin.left,
1264
+ top: (geo.y - (boxH - geo.h) / 2) * scale + numberOr(ctm.f, 0) - origin.top,
1265
+ width: textBox.w * scale,
1266
+ height: boxH * scale,
1267
+ fontSize: size.fontSize * scale,
1268
+ lineHeight: size.lineHeight * scale,
1269
+ color: colorOf(node, 'light').font,
1270
+ // 编辑框里的换行方式与画布上的标签一致:nowrap 的标签在编辑时也不换行。
1271
+ wraps: size.wraps,
1272
+ }
1273
+ }
1274
+
1275
+ /**
1276
+ * 标签的**文字区**(不是节点框):文字在这里居中、也在这里换行。
1277
+ *
1278
+ * @param {{ node: object, geo: object, fontSize?: number }} args
1279
+ * @returns {{ x: number, y: number, w: number, h: number, fontSize: number, lineHeight: number, color: string }}
1280
+ *
1281
+ * 就地编辑框(`openNodeEditor` 的 textarea)就按这个盒子摆 —— 同一宽度 → 同一断行位置,
1282
+ * 同一字色字号行高 → 同一观感。需求里"输入时的效果与输入完成后不一致"根子就在
1283
+ * 编辑框原来是个**单行 input**(不换行、字号写死 12px、颜色写死白色)。
1284
+ * 不过不把字体样式返回出去:那是 CSS 的事,见 `.drawai-edit`。
1285
+ */
1286
+ function labelTextBox(box) {
1287
+ const geo = box.geo
1288
+ const fontSize = numberOr(box.fontSize, styleNumber(typeof box.node.style === 'string' ? box.node.style : '', 'fontSize', FSIZE))
1289
+ const w = Math.max(12, labelTextWidth(box.node, geo.w))
1290
+ const lineHeight = labelLineHeight(fontSize)
1291
+ const text = typeof box.text === 'string' ? box.text : typeof box.node.label === 'string' ? box.node.label : ''
1292
+ const cols = labelWraps(box.node) ? wrapLabel(text, w, fontSize).length : 1
1293
+ // 文字区的**左右边界**在节点里是居中的:右栏窄,菱形/椭圆的可用宽度比节点小很多,
1294
+ // 从节点左边起排会让编辑框里的字整体偏左。
1295
+ const x = geo.x + (geo.w - w) / 2
1296
+ const h = Math.max(lineHeight * cols, lineHeight)
1297
+ return { x: x, y: geo.y + Math.max(0, (geo.h - h) / 2), w: w, h: h, fontSize: fontSize, lineHeight: lineHeight }
1298
+ }
1299
+
1300
+ /** 标签的字号与行高(就地编辑框要按它画字,否则输入时和输完后大小不一样)。 */
1301
+ function nodeEditorTextSize(node, text) {
1302
+ const fontSize = styleNumber(typeof node.style === 'string' ? node.style : '', 'fontSize', FSIZE)
1303
+ const lineHeight = labelLineHeight(fontSize)
1304
+ // 不换行的标签(whiteSpace=nowrap)在编辑框里也不该换行 —— 否则一打字就换行、
1305
+ // 一提交又缩回一行,正是"输入时/输完后不一致"。
1306
+ const wraps = labelWraps(node)
1307
+ const cols = wraps ? wrapLabel(typeof text === 'string' ? text : '', Math.max(12, labelTextWidth(node, numberOr(node.w, FALLBACK_NODE_W))), fontSize).length : 1
1308
+ return { fontSize: fontSize, lineHeight: lineHeight, lines: cols, wraps: wraps }
1309
+ }
1310
+
1311
+ /**
1312
+ * 这个节点"装得下自己的文字"需要多高(含上下各半行留白)。
1313
+ *
1314
+ * 需求:"节点内文字过多时,节点要纵向自动拉伸,避免文字溢出或被截断"。
1315
+ * 这是**纯函数**:渲染(`buildGeometry` 用它算有效高度)、就地编辑框的尺寸、
1316
+ * 以及落盘那一次自适应写回,三处都调它 —— 于是"看到的高度"和"盘上的高度"必然一致。
1317
+ *
1318
+ * 不适用/不打算拉伸的情形一律返回 0(= 不用长):
1319
+ * · 不换行的标签(nowrap)—— 它本来就横向溢出,纵向拉伸解决不了;
1320
+ * · 独立文字(drawio 的 `text` 形状)—— 没有边框底色,拉高只是把透明命中框撑大。
1321
+ */
1322
+ function nodeTextFitHeight(node) {
1323
+ if (node === null || node === undefined || typeof node !== 'object') return 0
1324
+ if (!labelWraps(node)) return 0
1325
+ const shape = shapeOf(node)
1326
+ if (shape === 'text') return 0
1327
+ const w = numberOr(node.w, FALLBACK_NODE_W)
1328
+ const label = typeof node.label === 'string' ? node.label : ''
1329
+ if (label.length === 0) return 0
1330
+ const fontSize = styleNumber(typeof node.style === 'string' ? node.style : '', 'fontSize', FSIZE)
1331
+ const lines = wrapLabel(label, labelTextWidth(node, w), fontSize).length
1332
+ if (lines <= 1) return 0
1333
+ return (lines + 1) * labelLineHeight(fontSize)
1334
+ }
1335
+
1336
+ /**
1337
+ * 节点几何:文档里的高度,与"装得下文字所需的高度"取大者,再吸到整格。
1338
+ *
1339
+ * 取大不取小:用户把节点拉大了就保持大(拉伸只解决溢出,不做"自动缩回去"——
1340
+ * 那会把用户特意留出的空白吃掉)。`h > stored` 就是"这一帧被文字撑高了",
1341
+ * 盘上的高度由 autoHeightPass 写回(见那里的说明)。
1342
+ */
1343
+ function nodeBoxOf(node) {
1344
+ const w = numberOr(node.w, FALLBACK_NODE_W)
1345
+ const stored = numberOr(node.h, FALLBACK_NODE_H)
1346
+ const x = numberOr(node.x, 0)
1347
+ const y = numberOr(node.y, 0)
1348
+ const need = nodeTextFitHeight(node)
1349
+ if (need > stored) return { x: x, y: y, w: w, h: snapTo(need, GRID), stored: stored }
1350
+ return { x: x, y: y, w: w, h: stored, stored: stored }
1351
+ }
1352
+
1198
1353
  // ---- 正交折线路由:6 个候选 + 包围盒避让 ----
1199
1354
  //
1200
1355
  // 单位规则:**路由器自己选出来的坐标一律吸附到半格(EDGE_GRID = 5px)**,而贴着节点边框的
@@ -2286,30 +2441,83 @@ window.__ModuleLoader__.load({
2286
2441
  }
2287
2442
 
2288
2443
  /**
2289
- * 文档内容的包围盒(不含留白):节点框 **∪ 连线的几何**。
2444
+ * 标签**画出来**的包围盒(用户单位)—— 与 `renderDiagram` 同一套文字区、断行与行高。
2445
+ *
2446
+ * 为什么标签要单独算:**节点框装不下文字是常态** ——
2447
+ * · `whiteSpace=nowrap` 的标签**天生横向溢出**(drawio 的语义就是不换行);
2448
+ * · 字号调大、或单个宽字(一个汉字就是 12px 宽)比 `labelTextWidth` 还宽;
2449
+ * · 独立文字(`shape=text`)没有边框底色,既不换行也**不会**自动长高。
2450
+ * 只算节点框的话,这些字会顶在导出图外面被切掉。
2451
+ *
2452
+ * 竖向按**行盒**算(行数 × 行高,首行基线上下各半行):比字形实际高度略大一点,这是有意的 ——
2453
+ * 宁可多留几像素,也不要"以为够了、其实切了半个字"。
2454
+ *
2455
+ * @returns {{minX:number,minY:number,maxX:number,maxY:number}|null} 没有文字就是 null
2456
+ */
2457
+ function labelPaintBounds(node, geo) {
2458
+ const label = typeof node.label === 'string' ? node.label : ''
2459
+ if (label.length === 0) return null
2460
+ const style = typeof node.style === 'string' ? node.style : ''
2461
+ const fontSize = styleNumber(style, 'fontSize', FSIZE)
2462
+ const lineHeight = labelLineHeight(fontSize)
2463
+ const box = labelTextBox({ node: node, geo: geo, fontSize: fontSize })
2464
+ // 与渲染同一处断行:maxWidth 就是那一个 labelTextWidth(nowrap = 一行到底)。
2465
+ const lines = labelWraps(node) ? wrapLabel(label, box.w, fontSize) : [label]
2466
+ let widest = 0
2467
+ for (let i = 0; i < lines.length; i += 1) {
2468
+ const w = textWidth(lines[i], fontSize)
2469
+ if (w > widest) widest = w
2470
+ }
2471
+ // 渲染是"以节点中心为锚、多行整体居中":首行基线 = 中心 - (行数-1)*行高/2。
2472
+ const cx = geo.x + geo.w / 2
2473
+ const first = geo.y + geo.h / 2 - ((lines.length - 1) * lineHeight) / 2
2474
+ return {
2475
+ minX: cx - widest / 2,
2476
+ maxX: cx + widest / 2,
2477
+ minY: first - lineHeight / 2,
2478
+ maxY: first + (lines.length - 1) * lineHeight + lineHeight / 2,
2479
+ }
2480
+ }
2481
+
2482
+ /**
2483
+ * 文档内容的包围盒(不含留白):**节点框(渲染几何) ∪ 标签文字 ∪ 连线的几何**。
2290
2484
  *
2291
2485
  * 连线必须算进来:**独立线**(两端都是自由点)、**伸出节点外的折点**、悬空端的自由点,
2292
2486
  * 都属于"内容"。只算节点框的话,导出/看图会按节点框把线裁掉(实测:画好的猫,胡须与尾巴
2293
2487
  * 被切成贴着画框的两截,看图的人/AI 都会以为"线断了"),「视图 → 适应内容」也会看不全。
2294
2488
  *
2295
- * 边自己的文字位置(labelX/labelY 是**沿边相对量**)算不出来 —— 它得先有走线。所以这一层
2296
- * 只保证"线的几何"进来;标签由那 24px 的留白兜着。
2489
+ * 文字同样算进来(见 `labelPaintBounds`),节点高度用**渲染几何** `nodeBoxOf`
2490
+ * (被多行文字撑高的那一份)而不是盘上的 `h` —— 否则多行标签的下半截就在框外。
2491
+ *
2492
+ * 边自己的文字位置(labelX/labelY 是**沿边相对量**)这里仍算不出来 —— 它得先有走线;
2493
+ * 导出那一路由 `paintedBounds` 兜住:它把渲染器**这一刻真会算出来的走线**也算进去
2494
+ * (见 `buildExportSvg`)。
2297
2495
  */
2298
2496
  function contentBounds(doc) {
2299
2497
  let minX = Infinity
2300
2498
  let minY = Infinity
2301
2499
  let maxX = -Infinity
2302
2500
  let maxY = -Infinity
2303
- for (let i = 0; i < doc.nodes.length; i += 1) {
2304
- const n = doc.nodes[i]
2305
- const x = numberOr(n.x, 0)
2306
- const y = numberOr(n.y, 0)
2307
- const w = numberOr(n.w, FALLBACK_NODE_W)
2308
- const h = numberOr(n.h, FALLBACK_NODE_H)
2309
- if (x < minX) minX = x
2310
- if (y < minY) minY = y
2311
- if (x + w > maxX) maxX = x + w
2312
- if (y + h > maxY) maxY = y + h
2501
+ const hidden = hiddenLayerIds(doc)
2502
+ const nodes = Array.isArray(doc.nodes) ? doc.nodes : []
2503
+ for (let i = 0; i < nodes.length; i += 1) {
2504
+ const n = nodes[i]
2505
+ if (n === null || n === undefined || typeof n !== 'object') continue
2506
+ // 隐藏图层里的单元**不画**:框里也就不该为它留白(否则「适应内容」会缩得很小、
2507
+ // 导出会白留一圈 —— 与 `paintedBounds` 同一个口径)。
2508
+ if (isHiddenCell(hidden, n)) continue
2509
+ const geo = nodeBoxOf(n)
2510
+ if (geo.x < minX) minX = geo.x
2511
+ if (geo.y < minY) minY = geo.y
2512
+ if (geo.x + geo.w > maxX) maxX = geo.x + geo.w
2513
+ if (geo.y + geo.h > maxY) maxY = geo.y + geo.h
2514
+ const text = labelPaintBounds(n, geo)
2515
+ if (text !== null) {
2516
+ if (text.minX < minX) minX = text.minX
2517
+ if (text.minY < minY) minY = text.minY
2518
+ if (text.maxX > maxX) maxX = text.maxX
2519
+ if (text.maxY > maxY) maxY = text.maxY
2520
+ }
2313
2521
  }
2314
2522
  const edges = Array.isArray(doc.edges) ? doc.edges : []
2315
2523
  const grow = (point) => {
@@ -2325,6 +2533,7 @@ window.__ModuleLoader__.load({
2325
2533
  for (let i = 0; i < edges.length; i += 1) {
2326
2534
  const e = edges[i]
2327
2535
  if (e === null || typeof e !== 'object') continue
2536
+ if (isHiddenCell(hidden, e)) continue
2328
2537
  const points = Array.isArray(e.points) ? e.points : []
2329
2538
  for (let k = 0; k < points.length; k += 1) grow(points[k])
2330
2539
  grow(e.sourcePoint)
@@ -2334,6 +2543,89 @@ window.__ModuleLoader__.load({
2334
2543
  return { minX: minX, minY: minY, maxX: maxX, maxY: maxY }
2335
2544
  }
2336
2545
 
2546
+ /**
2547
+ * **这一刻真会画出来**的东西的包围盒:节点框 ∪ 节点标签 ∪ 每条边的**实际走线** ∪ 线上的文字。
2548
+ *
2549
+ * 与 `contentBounds` 的分工:
2550
+ * · `contentBounds` 便宜、只认文档(盘上的折点、自由端点),用于「适应内容」与兜底;
2551
+ * · 这一份把渲染器**同一批函数**跑一遍(`buildGeometry` + `routeEdgeStyled`),
2552
+ * 于是自动路由怎么绕、线上的文字被拖到了哪儿(`labelX/labelY` 是沿边相对量,
2553
+ * 得先有走线才算得出)都算得出来 —— 导出的框和画出来的东西**同一个口径**。
2554
+ *
2555
+ * 为什么不用 `getBBox()` 量 DOM:那是"另一条真相"。实测过一次教训 —— 量出来的框
2556
+ * 变成了**当前视口**(网格纸铺满视口,比内容大得多),导出图于是白留一大圈。
2557
+ * 几何从同一份文档算,就不存在"量到的"和"画出来的"两套口径。
2558
+ *
2559
+ * 隐藏图层的内容**不算**(画布不画它,导出也就不该为它留白);算不出来(空文档)返回 null。
2560
+ */
2561
+ function paintedBounds(doc) {
2562
+ if (doc === null || doc === undefined || typeof doc !== 'object') return null
2563
+ const geometry = buildGeometry(doc)
2564
+ let minX = Infinity
2565
+ let minY = Infinity
2566
+ let maxX = -Infinity
2567
+ let maxY = -Infinity
2568
+ const put = (x, y) => {
2569
+ const px = Number(x)
2570
+ const py = Number(y)
2571
+ if (Number.isFinite(px) === false || Number.isFinite(py) === false) return
2572
+ if (px < minX) minX = px
2573
+ if (py < minY) minY = py
2574
+ if (px > maxX) maxX = px
2575
+ if (py > maxY) maxY = py
2576
+ }
2577
+ // 两种盒子形状别混:`labelPaintBounds` 给 minX/maxX/minY/maxY,`labelBox` 给 x/y/w/h。
2578
+ const putBounds = (box) => {
2579
+ if (box === null || box === undefined) return
2580
+ put(box.minX, box.minY)
2581
+ put(box.maxX, box.maxY)
2582
+ }
2583
+ const putRect = (rect) => {
2584
+ if (rect === null || rect === undefined) return
2585
+ put(rect.x, rect.y)
2586
+ put(rect.x + rect.w, rect.y + rect.h)
2587
+ }
2588
+ for (let i = 0; i < geometry.boxes.length; i += 1) {
2589
+ const box = geometry.boxes[i]
2590
+ put(box.geo.x, box.geo.y)
2591
+ put(box.geo.x + box.geo.w, box.geo.y + box.geo.h)
2592
+ putBounds(labelPaintBounds(box.node, box.geo))
2593
+ }
2594
+ const hidden = hiddenLayerIds(doc)
2595
+ const edges = Array.isArray(doc.edges) ? doc.edges : []
2596
+ for (let i = 0; i < edges.length; i += 1) {
2597
+ const edge = edges[i]
2598
+ if (edge === null || edge === undefined || typeof edge !== 'object') continue
2599
+ if (isHiddenCell(hidden, edge)) continue
2600
+ const from = endpointBoxOf(geometry.byId, edge, 'source')
2601
+ const to = endpointBoxOf(geometry.byId, edge, 'target')
2602
+ if (from === null || to === null) continue
2603
+ const pts = routeEdgeStyled(from, to, geometry.boxes, geometry.bounds, edge, undefined)
2604
+ if (Array.isArray(pts) === false || pts.length === 0) continue
2605
+ for (let k = 0; k < pts.length; k += 1) put(pts[k].x, pts[k].y)
2606
+ // 线上的文字:位置与渲染**同一套**(edgeLabelPosition + labelBox),字号也取同一个缺省。
2607
+ const edgeStyle = typeof edge.style === 'string' ? edge.style : ''
2608
+ if (typeof edge.label === 'string' && edge.label.length > 0) {
2609
+ const at = edgeLabelPosition(pts, edge)
2610
+ const pos = at === null ? { x: pts[0].x, y: pts[0].y } : at
2611
+ putRect(labelBox(pos, edge.label, styleNumber(edgeStyle, 'fontSize', 10)))
2612
+ }
2613
+ // drawio 的独立边标签单元(画布只读显示,但导出会画出来)。
2614
+ const attached = Array.isArray(doc.labels) ? doc.labels : []
2615
+ for (let L = 0; L < attached.length; L += 1) {
2616
+ const item = attached[L]
2617
+ if (item === null || item === undefined || item.edgeId !== edge.id) continue
2618
+ const text = typeof item.text === 'string' ? item.text : ''
2619
+ if (text.length === 0) continue
2620
+ const pos = edgeLabelPointAt(pts, item.x, item.y, item.offsetX, item.offsetY)
2621
+ if (pos === null) continue
2622
+ putRect(labelBox(pos, text, styleNumber(item.style, 'fontSize', 10)))
2623
+ }
2624
+ }
2625
+ if (Number.isFinite(minX) === false || Number.isFinite(minY) === false) return null
2626
+ return { minX: minX, minY: minY, maxX: maxX, maxY: maxY }
2627
+ }
2628
+
2337
2629
  /**
2338
2630
  * 把内容装进一个宽高比等于容器的视口。
2339
2631
  *
@@ -2464,7 +2756,12 @@ window.__ModuleLoader__.load({
2464
2756
  *
2465
2757
  * **隐藏图层的节点不进来** —— 这里是"什么可见"的唯一咽喉:渲染、命中(`hitNodeAt`)、
2466
2758
  * 框选、路由障碍全都读它,于是"隐藏一层"在这些地方自动一致(不会出现
2467
- * "看不见却能点到/线还绕着它走")。 */
2759
+ * "看不见却能点到/线还绕着它走")。
2760
+ *
2761
+ * 高度走 `nodeBoxOf`:**文字多的节点在这一层就被撑高**。放在这里而不是只在渲染里加高,
2762
+ * 是因为渲染、命中、连线落点、框选读的都是这一份几何 —— 只改渲染会出现
2763
+ * "线接在文字溢出前的旧边框上",那正是要修掉的不一致。
2764
+ */
2468
2765
  function buildGeometry(doc) {
2469
2766
  const hidden = hiddenLayerIds(doc)
2470
2767
  const boxes = []
@@ -2476,8 +2773,8 @@ window.__ModuleLoader__.load({
2476
2773
  for (let i = 0; i < doc.nodes.length; i += 1) {
2477
2774
  const n = doc.nodes[i]
2478
2775
  if (isHiddenCell(hidden, n)) continue
2479
- const geo = { x: numberOr(n.x, 0), y: numberOr(n.y, 0), w: numberOr(n.w, FALLBACK_NODE_W), h: numberOr(n.h, FALLBACK_NODE_H) }
2480
- const box = { id: n.id, node: n, geo: geo, label: typeof n.label === 'string' ? n.label : n.id }
2776
+ const geo = nodeBoxOf(n)
2777
+ const box = { id: n.id, node: n, geo: geo, label: typeof n.label === 'string' ? n.label : n.id, storedH: geo.stored }
2481
2778
  boxes.push(box)
2482
2779
  byId[n.id] = box
2483
2780
  if (geo.x < minX) minX = geo.x
@@ -3887,7 +4184,6 @@ window.__ModuleLoader__.load({
3887
4184
  const box = boxes[i]
3888
4185
  const geo = box.geo
3889
4186
  const node = box.node
3890
- const shape = shapeOf(node)
3891
4187
  const selected = ui !== undefined && ui !== null && Array.isArray(ui.selectedIds) && ui.selectedIds.indexOf(node.id) >= 0
3892
4188
  // 形状 + 标签放在同一个 <g> 里承载指针事件 —— 让浏览器做命中测试,
3893
4189
  // 比自己算"点是否落在菱形内"可靠得多。
@@ -3954,39 +4250,46 @@ window.__ModuleLoader__.load({
3954
4250
  }
3955
4251
  // 标签的画法也在文档里:fontSize / fontColor / whiteSpace。
3956
4252
  // whiteSpace=nowrap 不换行(drawio 的语义就是这个),其余(含缺省)按宽度换行。
4253
+ //
4254
+ // **正在就地编辑的节点不画标签**:编辑框是透明的(这样才能"输入时看到的就是输完后的样子"),
4255
+ // 画出来的标签会从它底下透出来,两层字叠在一起。以前没这问题只是因为那个单行 input
4256
+ // 带白底、把标签盖住了 —— 换成透明 textarea 之后必须显式让位。
4257
+ const editingHere = ui !== undefined && ui !== null && ui.editingNodeId === node.id
3957
4258
  const labelStyle = typeof node.style === 'string' ? node.style : ''
3958
4259
  const labelFontSize = styleNumber(labelStyle, 'fontSize', FSIZE)
3959
4260
  const labelFill = colorOf(node, mode).font
3960
- const wrap = styleGet(labelStyle, 'whiteSpace', 'wrap') !== 'nowrap'
3961
- let maxText = geo.w - 12
3962
- if (shape === 'diamond') maxText = geo.w * 0.6
3963
- if (shape === 'ellipse') maxText = geo.w * 0.72
3964
- const lines = wrap ? wrapLabel(box.label, maxText) : [String(box.label)]
4261
+ const boxText = labelTextBox({ node: node, geo: geo, fontSize: labelFontSize })
4262
+ const lines = labelWraps(node) ? wrapLabel(box.label, boxText.w, labelFontSize) : [String(box.label)]
3965
4263
  const cx = geo.x + geo.w / 2
3966
- const lineHeight = LHEIGHT * (labelFontSize / FSIZE)
4264
+ const lineHeight = labelLineHeight(labelFontSize)
3967
4265
  const first = geo.y + geo.h / 2 - ((lines.length - 1) * lineHeight) / 2
3968
4266
  const spans = []
3969
4267
  for (let li = 0; li < lines.length; li += 1) {
3970
4268
  spans.push(React.createElement('tspan', { key: 'l' + li, x: cx, dy: li === 0 ? 0 : lineHeight }, lines[li]))
3971
4269
  }
3972
- groupChildren.push(
3973
- React.createElement(
3974
- 'text',
3975
- {
3976
- key: 'label-' + i,
3977
- x: cx,
3978
- y: first,
3979
- textAnchor: 'middle',
3980
- dominantBaseline: 'middle',
3981
- fontSize: labelFontSize,
3982
- fontFamily: FONT,
3983
- fill: labelFill,
3984
- pointerEvents: 'none',
3985
- style: { fill: labelFill, fontFamily: FONT, fontSize: labelFontSize + 'px', dominantBaseline: 'middle' },
3986
- },
3987
- spans,
3988
- ),
3989
- )
4270
+ if (!editingHere) {
4271
+ groupChildren.push(
4272
+ React.createElement(
4273
+ 'text',
4274
+ {
4275
+ key: 'label-' + i,
4276
+ x: cx,
4277
+ y: first,
4278
+ textAnchor: 'middle',
4279
+ dominantBaseline: 'middle',
4280
+ // 渲染出来的标签带上节点 id:一是自测能精确定位"哪个节点的标签在不在",
4281
+ // 二是排查"文字重叠 / 没画出来"时一眼能看出是哪一格。
4282
+ 'data-node-id': node.id,
4283
+ fontSize: labelFontSize,
4284
+ fontFamily: FONT,
4285
+ fill: labelFill,
4286
+ pointerEvents: 'none',
4287
+ style: { fill: labelFill, fontFamily: FONT, fontSize: labelFontSize + 'px', lineHeight: lineHeight + 'px', dominantBaseline: 'middle' },
4288
+ },
4289
+ spans,
4290
+ ),
4291
+ )
4292
+ }
3990
4293
  // 四面引出端点:上下左右各一个,从这里拖到另一个节点 = 连一条边,
3991
4294
  // 并且**记住是从哪一边引出的**(拖出来的线从那一边出来,不会按算法另挑一边)。
3992
4295
  // 位置推在边框外 16px —— 边框上有 8 个缩放手柄,挤在一起会点错。
@@ -4168,6 +4471,86 @@ window.__ModuleLoader__.load({
4168
4471
  return React.createElement('svg', { ref: svgRef, viewBox: viewBox, width: '100%', height: '100%', preserveAspectRatio: 'none', style: { display: 'block', background: skin.page } }, children)
4169
4472
  }
4170
4473
 
4474
+ /**
4475
+ * 选区锚点所属的那个 **DrawAI 面板根**(`.drawai-root`);不在面板里就返回 null。
4476
+ *
4477
+ * 用它判断"这段选中的文字是不是画布自己的"。为什么一路往上找到面板根而不是看某个具体元素:
4478
+ * 画布里的文字(节点标签、线上的字)各式各样,但都长在这一个面板下面。
4479
+ *
4480
+ * 注意 SVG 元素**没有 `closest`**,所以不能一遇到没有 closest 的节点就放弃 ——
4481
+ * 节点标签的选区锚点正是一个 `<text>`,那会把"画布里的文字"误判成"外面的文字"。
4482
+ */
4483
+ function drawaiPaneOf(node) {
4484
+ let el = node
4485
+ while (el !== null && el !== undefined) {
4486
+ if (typeof el.closest === 'function') {
4487
+ const found = el.closest('.drawai-root')
4488
+ if (found !== null && found !== undefined) return found
4489
+ }
4490
+ el = el.parentNode
4491
+ }
4492
+ return null
4493
+ }
4494
+
4495
+ /**
4496
+ * 用户此刻选中的文字,是不是**画布面板之外**的(比如他自己在对话里划了一段准备复制)。
4497
+ *
4498
+ * 为什么需要这一条:画布的快捷键挂在 `window` 上,而 owner 判定只看"焦点在谁那儿"。
4499
+ * 用户点过画布之后焦点就落在 body(`activeElement === null`)→ 判定为"画布拥有键盘"
4500
+ * → Ctrl+C 被 `preventDefault()` 掉,**对话里的文字就复制不出来**(实测报过)。
4501
+ * 所以在动手之前多问一句:你手里现在有没有一段"不属于画布"的选中文字?
4502
+ */
4503
+ function hasTextSelectionOutside(root) {
4504
+ if (root === null || root === undefined) return false
4505
+ if (typeof window === 'undefined' || window === null || typeof window.getSelection !== 'function') return false
4506
+ let selection = null
4507
+ try {
4508
+ selection = window.getSelection()
4509
+ } catch (error) {
4510
+ return false
4511
+ }
4512
+ if (selection === null || selection === undefined) return false
4513
+ const text = typeof selection.toString === 'function' ? selection.toString() : ''
4514
+ if (text.length === 0) return false
4515
+ const anchor = selection.anchorNode
4516
+ if (anchor === null || anchor === undefined) {
4517
+ // 拿不到锚点(老浏览器/被清过)但确实有选中文字:**倾向放手** ——
4518
+ // 让浏览器复制一次远比"按住 Ctrl+C 没反应"好。
4519
+ return true
4520
+ }
4521
+ const pane = drawaiPaneOf(anchor)
4522
+ return pane !== root
4523
+ }
4524
+
4525
+ /**
4526
+ * 清掉"残留在别处"的浏览器文字选区。
4527
+ *
4528
+ * 为什么需要:用户先在对话里划了一段字(选区留在那边),然后**点画布上的一个元素**。
4529
+ * 点 SVG 不会清掉别处的选区 —— 于是接下来按 Ctrl+C 时,
4530
+ * `hasTextSelectionOutside` 判定"你手里有画布外的选中文字"而放手,
4531
+ * 结果**画布里的元素也复制不了**(实测报过:"又无法复制画布中的元素了")。
4532
+ *
4533
+ * 反过来看,这也正是用户期望的语义:我开始在画布上选东西了,
4534
+ * 上一次那点文字高亮就该消失(drawio / Figma 都一样)。
4535
+ *
4536
+ * 护栏:
4537
+ * · 目标在**就地编辑框**里 → 不动(那是他自己在框里选字);
4538
+ * · 只有左键才清(右键要留给"复制这段文字"的右键菜单);
4539
+ * · 拿不到 selection 一律静默返回(老浏览器、测试环境)。
4540
+ */
4541
+ function clearStaleTextSelection(target, button) {
4542
+ if (button !== 0) return
4543
+ if (target !== null && target !== undefined && typeof target.closest === 'function' && target.closest('.drawai-edit') !== null) return
4544
+ if (typeof window === 'undefined' || window === null || typeof window.getSelection !== 'function') return
4545
+ let selection = null
4546
+ try {
4547
+ selection = window.getSelection()
4548
+ } catch (error) {
4549
+ return
4550
+ }
4551
+ if (selection !== null && selection !== undefined && typeof selection.removeAllRanges === 'function') selection.removeAllRanges()
4552
+ }
4553
+
4171
4554
  /**
4172
4555
  * 从宿主 `action: 'read'` 的响应里取出**可以直接画**的文档。
4173
4556
  *
@@ -5333,7 +5716,13 @@ window.__ModuleLoader__.load({
5333
5716
  })
5334
5717
  }
5335
5718
 
5336
- /** 打开节点的就地编辑框。双击与右键菜单共用同一套定位。 */
5719
+ /**
5720
+ * 打开节点的就地编辑框。双击与右键菜单共用同一套定位。
5721
+ *
5722
+ * 位置/尺寸/字号全部由 `nodeEditorBox` 算 —— 它按**这个节点的文字区**摆,
5723
+ * 于是编辑框里的断行与字号跟提交后画出来的标签一模一样(需求"输入时的效果与
5724
+ * 输入完成后统一")。定位仍然换算到**画布容器**的坐标系(画布是定位上下文)。
5725
+ */
5337
5726
  function openNodeEditor(id) {
5338
5727
  const node = nodeById(id)
5339
5728
  const svg = svgRef.current
@@ -5341,20 +5730,14 @@ window.__ModuleLoader__.load({
5341
5730
  if (node === null || svg === null || canvas === null) return
5342
5731
  const ctm = svg.getScreenCTM()
5343
5732
  if (ctm === null) return
5344
- // CTM 只有缩放与平移(没有旋转),屏幕位置 = 用户坐标 × scale + 平移。
5345
5733
  const rect = canvas.getBoundingClientRect()
5346
- const scale = ctm.a
5347
5734
  setSelectedIds([id])
5348
5735
  setMenu(null)
5349
- setEditing({
5350
- kind: 'node',
5351
- id: id,
5352
- text: typeof node.label === 'string' ? node.label : '',
5353
- left: numberOr(node.x, 0) * scale + ctm.e - rect.left,
5354
- top: numberOr(node.y, 0) * scale + ctm.f - rect.top,
5355
- width: numberOr(node.w, FALLBACK_NODE_W) * scale,
5356
- height: numberOr(node.h, FALLBACK_NODE_H) * scale,
5357
- })
5736
+ const text = typeof node.label === 'string' ? node.label : ''
5737
+ const box = nodeEditorBox(node, text, ctm, { left: rect.left, top: rect.top })
5738
+ setEditing(
5739
+ Object.assign({ kind: 'node', id: id, text: text }, box),
5740
+ )
5358
5741
  }
5359
5742
 
5360
5743
  function onNodeDoubleClick(id, event) {
@@ -5740,7 +6123,12 @@ window.__ModuleLoader__.load({
5740
6123
  for (let i = 0; i < junk.length; i += 1) {
5741
6124
  if (junk[i].parentNode !== null) junk[i].parentNode.removeChild(junk[i])
5742
6125
  }
5743
- const bounds = contentBounds(current)
6126
+ // 框按**这一刻会画出来的东西**取:`paintedBounds` 用渲染器同一批函数算出
6127
+ // 节点框、标签、每条边的实际走线与线上的文字(与 `renderDiagram` 同一个口径)。
6128
+ // 少了任何一样,导出图都会"画上有、图里没有";而如果按 DOM 的 getBBox 去量,
6129
+ // 铺满视口的网格纸会把框撑成整个视口 —— 那正是"导出图白留一大圈"的来源。
6130
+ const bounds = paintedBounds(current) || contentBounds(current)
6131
+ // 24px 留白兜住几何**算不到**的东西:描边宽度与箭头(marker)。
5744
6132
  const pad = 24
5745
6133
  const w = Math.max(1, bounds.maxX - bounds.minX + pad * 2)
5746
6134
  const h = Math.max(1, bounds.maxY - bounds.minY + pad * 2)
@@ -5989,25 +6377,36 @@ window.__ModuleLoader__.load({
5989
6377
  return 'e' + (max + 1)
5990
6378
  }
5991
6379
 
6380
+ /** 新节点:形状与配色都落成 drawio 的 style 键,高度**一开始就装得下自己的标签**。 */
6381
+ function freshNode(id, shape, styleName, userX, userY, label) {
6382
+ const isText = shape === 'text'
6383
+ const text = typeof label === 'string' ? label : isText ? '文字' : '新节点'
6384
+ const node = {
6385
+ id: id,
6386
+ label: text,
6387
+ // 形状与配色都落成 drawio 的 style 键:rect/plain 落成空串(= drawio 的 defaultVertexStyle)。
6388
+ // 文字**不吃配色**:它的样式就是 drawio 的 defaultTextStyle(无边框无底色),
6389
+ // 往上盖 fillColor/strokeColor 只会让文件与 drawio 不一致,画面上也什么都看不出来。
6390
+ style: isText ? styleWithNodeShape('', shape) : styleWithColorName(styleWithNodeShape('', shape), styleName),
6391
+ x: snap(userX - NEW_NODE_W / 2),
6392
+ y: snap(userY - NEW_NODE_H / 2),
6393
+ w: NEW_NODE_W,
6394
+ h: NEW_NODE_H,
6395
+ }
6396
+ // 与 autoHeightPass 同一套规则:新建时就落成撑开后的高度,
6397
+ // 免得"新节点先按原样画一帧、再被自动长高"多出一步莫名其妙的撤销历史。
6398
+ const fit = nodeTextFitHeight(node)
6399
+ if (fit > node.h) node.h = snapTo(fit, GRID)
6400
+ return node
6401
+ }
6402
+
5992
6403
  function createNodeAt(shape, styleName, userX, userY) {
5993
6404
  const current = docRef.current
5994
6405
  if (current === null) return
5995
6406
  const id = nextNodeId(current)
5996
6407
  const isText = shape === 'text'
5997
- // 形状与配色都落成 drawio 的 style 键:rect/plain 落成空串(= drawio 的 defaultVertexStyle)。
5998
- // 文字**不吃配色**:它的样式就是 drawio 的 defaultTextStyle(无边框无底色),
5999
- // 往上盖 fillColor/strokeColor 只会让文件与 drawio 不一致,画面上也什么都看不出来。
6000
- const style = isText ? styleWithNodeShape('', shape) : styleWithColorName(styleWithNodeShape('', shape), styleName)
6001
6408
  applyLocal((next) => {
6002
- const node = {
6003
- id: id,
6004
- label: isText ? '文字' : '新节点',
6005
- style: style,
6006
- x: snap(userX - NEW_NODE_W / 2),
6007
- y: snap(userY - NEW_NODE_H / 2),
6008
- w: NEW_NODE_W,
6009
- h: NEW_NODE_H,
6010
- }
6409
+ const node = freshNode(id, shape, styleName, userX, userY)
6011
6410
  // 新节点进**当前图层**(没设过就是第一层)—— drawio 的"当前图层"就是这个意思。
6012
6411
  if (activeLayerId !== null) node.layer = activeLayerId
6013
6412
  next.nodes.push(node)
@@ -6484,7 +6883,9 @@ window.__ModuleLoader__.load({
6484
6883
  const ctm = svg.getScreenCTM()
6485
6884
  if (ctm === null) return
6486
6885
  const rect = canvas.getBoundingClientRect()
6886
+ // 只有连线标签那一支用这两个(节点那一支整份交给 nodeEditorBox 算)。
6487
6887
  const scale = ctm.a
6888
+ const toCanvas = { left: rect.left, top: rect.top }
6488
6889
  let next = null
6489
6890
  if (editing.kind === 'node') {
6490
6891
  const node = nodeById(editing.id)
@@ -6492,12 +6893,8 @@ window.__ModuleLoader__.load({
6492
6893
  setEditing(null)
6493
6894
  return
6494
6895
  }
6495
- next = {
6496
- left: numberOr(node.x, 0) * scale + ctm.e - rect.left,
6497
- top: numberOr(node.y, 0) * scale + ctm.f - rect.top,
6498
- width: numberOr(node.w, FALLBACK_NODE_W) * scale,
6499
- height: numberOr(node.h, FALLBACK_NODE_H) * scale,
6500
- }
6896
+ // 连**打字的内容**一起交给纯函数:行数变了编辑框就长高,断行位置也与画出来的标签一致。
6897
+ next = nodeEditorBox(node, editing.text, ctm, toCanvas)
6501
6898
  } else {
6502
6899
  const edge = edgeById(editing.id)
6503
6900
  if (edge === null) {
@@ -6517,6 +6914,42 @@ window.__ModuleLoader__.load({
6517
6914
  if (moved) setEditing(Object.assign({}, editing, next))
6518
6915
  })
6519
6916
 
6917
+ /**
6918
+ * 文字撑高的节点:把"有效高度"写回文档。
6919
+ *
6920
+ * 为什么需要这一步:`buildGeometry` 已经让**看到**的高度随机长,但文件里的 `h` 还是旧的。
6921
+ * 不写回去的话,"盘上的图"与"屏幕上的图"不一致 —— 导出/别人用 drawio 打开、
6922
+ * 以及 AI 读回来的坐标都会是溢出前的高度。
6923
+ *
6924
+ * 四个约定:
6925
+ * · **只长不缩**:`nodeTextFitHeight` 比现在矮时一个字都不动(不把用户拉大的空白吃掉);
6926
+ * · 走 `applyLocal` 但用**同一个 coalesceKey**:一次加载里多个节点需要调整只压一步撤销历史;
6927
+ * · 高度取整格(`snapTo(…, GRID)`)—— 与"新建节点的尺寸都整格"同一条理由:
6928
+ * 中心落在半像素上,连线的自动路由会多出"差一像素"的台阶;
6929
+ * · 没打开文件的空舞台不写(`hasPath`),否则"新建画布"会在用户还没命名时就把文件落盘。
6930
+ */
6931
+ React.useEffect(() => {
6932
+ if (hasPath !== true) return
6933
+ const current = docRef.current
6934
+ if (current === null || Array.isArray(current.nodes) === false) return
6935
+ let changed = false
6936
+ for (let i = 0; i < current.nodes.length; i += 1) {
6937
+ const n = current.nodes[i]
6938
+ if (nodeTextFitHeight(n) > numberOr(n.h, FALLBACK_NODE_H)) {
6939
+ changed = true
6940
+ break
6941
+ }
6942
+ }
6943
+ if (changed === false) return
6944
+ applyLocal((next) => {
6945
+ for (let i = 0; i < next.nodes.length; i += 1) {
6946
+ const n = next.nodes[i]
6947
+ const fit = nodeTextFitHeight(n)
6948
+ if (fit > numberOr(n.h, FALLBACK_NODE_H)) n.h = snapTo(fit, GRID)
6949
+ }
6950
+ }, 'auto-height')
6951
+ }, [doc, hasPath])
6952
+
6520
6953
  function onEdgeDoubleClick(edgeId, event) {
6521
6954
  event.preventDefault()
6522
6955
  event.stopPropagation()
@@ -6704,6 +7137,19 @@ window.__ModuleLoader__.load({
6704
7137
  // 于是输入框里按退格会删掉画布里的选中内容、Ctrl+C/V 也被吞掉。
6705
7138
  if (!canvasOwnsKeyboard(event, rootRef.current, document.activeElement)) return
6706
7139
  const accel = event.ctrlKey === true || event.metaKey === true
7140
+ // **用户手里有"画布之外"的选中文字时,Ctrl+C/X/A 交给浏览器**。
7141
+ //
7142
+ // 为什么:焦点在 body(点过画布之后就是这样)也算"画布拥有键盘",于是
7143
+ // 用户在对话里划了一段字按 Ctrl+C,被这里 preventDefault 掉 —— 复制不出任何东西
7144
+ // (实测报过)。只放开这三个"浏览器本来就能做对"的组合:复制 / 剪切 / 全选;
7145
+ // 撤销、粘贴、删除仍然归画布(那些在对话里由 DSH 自己处理,不靠我们的窗口监听)。
7146
+ //
7147
+ // 半路踩过的坑:**画布里有选中元素时不能放手**。用户先在对话里划了字、再点画布上的
7148
+ // 元素,那次点击不会清掉别处的选区,于是"外部有选区"成立、Ctrl+C 被让给浏览器,
7149
+ // 画布里的元素就**复制不了**了。所以判据是两条同时成立:画布空着 **且** 外面有选中的字。
7150
+ if (accel && (event.key === 'c' || event.key === 'C' || event.key === 'x' || event.key === 'X' || event.key === 'a' || event.key === 'A')) {
7151
+ if (selectedIds.length === 0 && hasTextSelectionOutside(rootRef.current)) return
7152
+ }
6707
7153
  if (accel && (event.key === 'z' || event.key === 'Z')) {
6708
7154
  event.preventDefault()
6709
7155
  if (event.shiftKey === true) redo()
@@ -7118,6 +7564,8 @@ window.__ModuleLoader__.load({
7118
7564
  const zoomPercent = view === null || size.w <= 0 ? 100 : Math.round((size.w / view.w) * 100)
7119
7565
 
7120
7566
  const singleSelectedNodeId = selectedIds.length === 1 && nodeById(selectedIds[0]) !== null ? selectedIds[0] : null
7567
+ // 正在就地编辑的那个节点:渲染时要把它的标签**让位**给编辑框(否则两层字叠在一起)。
7568
+ const editingNodeId = editing !== null && editing.kind === 'node' ? editing.id : null
7121
7569
 
7122
7570
  const ui = {
7123
7571
  selectedIds: selectedIds,
@@ -7125,6 +7573,7 @@ window.__ModuleLoader__.load({
7125
7573
  routeMemory: routeMemoryRef.current,
7126
7574
  marquee: marquee,
7127
7575
  singleSelectedNodeId: singleSelectedNodeId,
7576
+ editingNodeId: editingNodeId,
7128
7577
  connectFrom: connectFrom,
7129
7578
  connectTo: connectTo,
7130
7579
  connectSide: connectSide,
@@ -7184,19 +7633,41 @@ window.__ModuleLoader__.load({
7184
7633
  return React.createElement('div', { className: 'drawai-swatches' }, buttons)
7185
7634
  }
7186
7635
 
7187
- /** 元素库用 shapeElement 自己画缩略图 —— 库里的预览和画布上的形状永远一致。 */
7188
- function shapeGrid(onPick) {
7636
+ /**
7637
+ * 元素库:**全部形状直接平铺**(不再收进下拉列表)。
7638
+ *
7639
+ * 需求:"去掉形状的下拉列表选择方式,把形状直接排开、平铺展示"。点一个形状就是**动作本身**:
7640
+ * · 空白处右键(`picker` 为 null)→ 直接在那个位置新增一个该形状的节点;
7641
+ * · 节点右键(传了 `picker`)→ 直接把选中的节点换成那个形状。
7642
+ * 两种菜单都调用同一个 onPick,「+ 新增节点」那个按钮因此不需要了。
7643
+ *
7644
+ * 缩略图仍然由 `shapeElement` 自己画 —— 库里的预览与画布上的形状永远一致。
7645
+ *
7646
+ * @param {(shape: string) => void} onPick
7647
+ * @param {{ color?: string, current?: string }} opts
7648
+ * color:缩略图用的配色(节点菜单按这个节点现在的配色画,空白处按当前选中的配色画);
7649
+ * current:当前形状,给它加一圈"选中"的高亮。
7650
+ */
7651
+ function shapeGrid(onPick, opts) {
7652
+ const options = opts === undefined || opts === null ? {} : opts
7653
+ const color = typeof options.color === 'string' ? options.color : menuStyle
7654
+ const current = typeof options.current === 'string' ? options.current : ''
7189
7655
  const buttons = []
7190
7656
  for (let i = 0; i < SHAPE_LIBRARY.length; i += 1) {
7191
7657
  const entry = SHAPE_LIBRARY[i]
7192
7658
  // node 与 palette 必须来自同一个 colorOf —— shapeElement 的描边取自 style,填充取自 palette,
7193
7659
  // 只传 palette 不传 style 的话,缩略图会变成"默认填充 + 主题描边"。
7194
- const chipNode = { style: styleWithColorName(styleWithNodeShape('', entry.shape), menuStyle) }
7660
+ const chipNode = { style: styleWithColorName(styleWithNodeShape('', entry.shape), color) }
7195
7661
  const palette = colorOf(chipNode, 'light')
7196
7662
  buttons.push(
7197
7663
  React.createElement(
7198
7664
  'button',
7199
- { key: 'shape-' + entry.shape, className: 'drawai-chip', title: entry.label, onClick: () => onPick(entry.shape) },
7665
+ {
7666
+ key: 'shape-' + entry.shape,
7667
+ className: current === entry.shape ? 'drawai-chip on' : 'drawai-chip',
7668
+ title: entry.label,
7669
+ onClick: () => onPick(entry.shape),
7670
+ },
7200
7671
  React.createElement(
7201
7672
  'svg',
7202
7673
  { width: 44, height: 30, viewBox: '0 0 44 30' },
@@ -7280,12 +7751,15 @@ window.__ModuleLoader__.load({
7280
7751
  /**
7281
7752
  * 把若干"类"渲染成**一行横排的当前值按钮** + (打开的那一类的)选项体。
7282
7753
  *
7283
- * 一类只占一个按钮:`形状:矩形 ▾`,几个类并排在同一行里(放不下就换行 —— 右栏窄,
7754
+ * 一类只占一个按钮:`配色:蓝 ▾`,几个类并排在同一行里(放不下就换行 —— 右栏窄,
7284
7755
  * 连线的四类会折成两行)。点某一类才把它的选项铺在下面**整行**里。
7285
7756
  *
7286
7757
  * 以前两类做法都试过:全部选项铺在菜单里(一屏都是按钮、看不出现在是什么)、
7287
7758
  * 一类占一整行(菜单太矮太长的竖条)。现在这样菜单更矮,一眼能看到每一类的当前值。
7288
7759
  *
7760
+ * **形状不再走这里**:需求要的是"把形状直接排开、平铺展示",所以形状用 `shapeGrid`
7761
+ * 常驻平铺(见 drawai-pick),点一下就生效。这里剩下的是配色/字色/字号/线型/箭头。
7762
+ *
7289
7763
  * 展开状态记在 menu 对象上(`menu.openKey`)—— 换一个元素右键时 menu 是新对象,下拉自然收起;
7290
7764
  * 同一个菜单里点另一类会把上一类收起来。
7291
7765
  *
@@ -7360,22 +7834,19 @@ window.__ModuleLoader__.load({
7360
7834
  }
7361
7835
 
7362
7836
  if (menu.kind === 'canvas') {
7363
- rows.push(menuTitle('元素库 —— 选一个放到这里'))
7364
- // 形状/配色也只显示"当前值"(并排一行),选项在下拉里;
7365
- // 「+ 新增节点」放的就是当前形状 + 当前配色。
7837
+ rows.push(menuTitle('元素库 —— 点一个形状,就在这里放一个'))
7838
+ // 形状**平铺**(需求:去掉下拉、直接排开),点某个形状 = 直接在这里新增该形状的节点。
7839
+ // 原来的「+ 新增节点」按钮因此没有了:它做的事(用"当前形状"放一个)现在就是
7840
+ // "点那个形状自己",少一步"先在下拉里选中、再点新增"。
7841
+ rows.push(React.createElement('div', { key: 'shape-pick', className: 'drawai-pick' }, menuTitle('形状(点一下直接新建)'), shapeGrid((shape) => {
7842
+ setMenuShape(shape)
7843
+ createNodeAt(shape, menuStyle, menu.userX, menu.userY)
7844
+ }, { color: menuStyle, current: menuShape })))
7845
+ // 配色仍然只显示当前值:8 个色块 + 10 个形状缩略图都平铺会把菜单撑成一屏。
7846
+ // 它与形状是**两件事**(形状决定放什么,配色决定放出来的颜色),所以留在这一行里。
7366
7847
  rows.push.apply(
7367
7848
  rows,
7368
7849
  menuSelectRow([
7369
- {
7370
- key: 'shape',
7371
- label: '形状',
7372
- value: styleSummary(styleWithNodeShape('', menuShape), 'shape'),
7373
- hint: '换一个形状(「+ 新增节点」用的就是它)',
7374
- body: shapeGrid((shape) => {
7375
- setMenuShape(shape)
7376
- closeSelect()
7377
- }),
7378
- },
7379
7850
  {
7380
7851
  key: 'color',
7381
7852
  label: '配色',
@@ -7388,21 +7859,13 @@ window.__ModuleLoader__.load({
7388
7859
  },
7389
7860
  ]),
7390
7861
  )
7391
- // 工具条的「+ 节点」去掉之后,这里补一格"不挑形状、就放一个"的快捷入口:
7392
- // 和上面点某个形状等价(同一个 createNodeAt、同一套新 id 规则),
7393
- // 但不是每个人都记得九宫格里哪个是自己要的方框。
7862
+ // 这一排只剩下**独立连线**两种:文字已经在上面那片形状里了
7863
+ // (「文字」就是其中一个缩略图,点它一样是 `createNodeAt('text', …)`)——
7864
+ // 同一件事摆两个入口只会让人猜"这两个是不是不一样"。
7394
7865
  rows.push(
7395
7866
  React.createElement(
7396
7867
  'div',
7397
7868
  { className: 'drawai-menu-row' },
7398
- React.createElement('button', { className: 'drawai-btn', onClick: () => createNodeAt(menuShape, menuStyle, menu.userX, menu.userY) }, '+ 新增节点'),
7399
- // 独立文字:一个只有文字、没有边框底色的元素(drawio 的 text 形状)。
7400
- // 放在这里的原因是"要在哪就放哪":它不接节点、也不接边,位置就是点的地方。
7401
- React.createElement(
7402
- 'button',
7403
- { className: 'drawai-btn', onClick: () => createNodeAt('text', menuStyle, menu.userX, menu.userY), title: '放一段独立文字(无边框无底色,可拖动/改字/改字色)' },
7404
- 'T 文字',
7405
- ),
7406
7869
  // 独立线:两端都不接节点(drawio 里的自由形态),之后拖端点就能接到节点上。
7407
7870
  React.createElement(
7408
7871
  'button',
@@ -7450,22 +7913,18 @@ window.__ModuleLoader__.load({
7450
7913
  rows.push(menuTitle('节点:' + label + (nodeTargets.length > 1 ? '(共 ' + nodeTargets.length + ' 个)' : '')))
7451
7914
  // 形状与配色都改成**键级改写**:不再往文档里写 shape 字段,也不再用颜色名当 style。
7452
7915
  // 整组改色时以**点中的那个**的当前样式为基准(把它的配色推广给其它成员)。
7453
- // 三个类并排成一行、各自只显示当前值(形状:矩形 ▾ / 配色:蓝 ▾ / 字号:默认 ▾),
7454
- // 点开的那一类把选项铺在下面整行里。
7916
+ // 形状**平铺**(需求:去掉下拉):点一个缩略图就把选中的节点换成那个形状。
7917
+ // 配色/字色/字号仍然走「类:当前值 ▾」那一排(它们的选项是与形状并列的另一回事)。
7455
7918
  // 配色对独立文字换的是**字色**(它没有填充与描边)—— 否则点了一圈颜色屏幕上毫无变化。
7456
7919
  const isTextNode = nodeShapeFromStyle(nodeStyle) === 'text'
7920
+ const currentShape = nodeShapeFromStyle(nodeStyle)
7921
+ // 缩略图用的配色:认得出的调色板名照用;认不出的(drawio 文件里本来就只有十六进制)
7922
+ // 退回缺省色画缩略图 —— 这里只是预览,不写文档。
7923
+ const chipColor = colorNameFromStyle(nodeStyle)
7924
+ rows.push(React.createElement('div', { key: 'shape-pick', className: 'drawai-pick' }, shapeGrid((shape) => updateNode(nodeTargets, { style: styleWithNodeShape(nodeStyle, shape) }), { color: chipColor === null ? 'plain' : chipColor, current: currentShape })))
7457
7925
  rows.push.apply(
7458
7926
  rows,
7459
7927
  menuSelectRow([
7460
- {
7461
- key: 'shape',
7462
- label: '形状',
7463
- value: styleSummary(nodeStyle, 'shape'),
7464
- body: shapeGrid((shape) => {
7465
- updateNode(nodeTargets, { style: styleWithNodeShape(nodeStyle, shape) })
7466
- closeSelect()
7467
- }),
7468
- },
7469
7928
  {
7470
7929
  key: 'color',
7471
7930
  label: isTextNode ? '字色' : '配色',
@@ -8041,6 +8500,9 @@ window.__ModuleLoader__.load({
8041
8500
  function onRootPointerDown(event) {
8042
8501
  const target = event === null || event === undefined ? null : event.target
8043
8502
  const closest = target !== null && target !== undefined && typeof target.closest === 'function' ? target.closest.bind(target) : null
8503
+ // ③ 在画布上按下 = "我要操作画布了":把残留在别处(比如对话里)的文字选区清掉。
8504
+ // 不清的话,画布的新元素会因为"外面还有选中文字"而复制不了(见 clearStaleTextSelection)。
8505
+ clearStaleTextSelection(target, event === null || event === undefined ? undefined : event.button)
8044
8506
  if (editing !== null && (closest === null || closest('.drawai-edit') === null)) commitEdit()
8045
8507
  if (docMenu === null) return
8046
8508
  if (closest !== null && closest('.drawai-menu') !== null) return
@@ -8251,34 +8713,54 @@ window.__ModuleLoader__.load({
8251
8713
  const menuNode = renderMenu()
8252
8714
  if (menuNode !== null) canvasChildren.push(menuNode)
8253
8715
  if (editing !== null) {
8254
- // 就地改标签:用 HTML input 绝对定位盖在节点上。
8716
+ // 就地改标签:用**绝对定位的元素盖在节点的文字区上**。
8255
8717
  // 不用 SVG foreignObject —— React 会把 <svg> 后代一律建成 SVG 命名空间元素,
8256
- // 里面的 <input> 不会按 HTML 渲染。
8718
+ // 里面的输入控件不会按 HTML 渲染。
8719
+ //
8720
+ // 节点用 textarea(能换行、能长高),连线标签仍然是一行 input:
8721
+ // 需求"输入过程中也要自动换行"针对的是节点里的长文字。
8722
+ const isNodeEditor = editing.kind === 'node'
8723
+ const editorStyle = {
8724
+ left: editing.left + 'px',
8725
+ top: editing.top + 'px',
8726
+ width: Math.max(isNodeEditor ? 24 : 60, editing.width) + 'px',
8727
+ height: Math.max(isNodeEditor ? 24 : 24, editing.height) + 'px',
8728
+ }
8729
+ if (isNodeEditor) {
8730
+ // 字号/行高/字色按这个节点给:编辑时看到的字,就是提交后画出来的字。
8731
+ editorStyle.fontSize = Math.max(1, numberOr(editing.fontSize, FSIZE)) + 'px'
8732
+ editorStyle.lineHeight = Math.max(1, numberOr(editing.lineHeight, LHEIGHT)) + 'px'
8733
+ editorStyle.color = editing.color === undefined || editing.color === null ? '#000000' : editing.color
8734
+ // whiteSpace=nowrap 的标签在编辑框里也不换行(否则一打字就换行、一提交又缩回一行)。
8735
+ if (editing.wraps === false) {
8736
+ editorStyle.whiteSpace = 'pre'
8737
+ editorStyle.wordBreak = 'normal'
8738
+ editorStyle.overflowX = 'auto'
8739
+ }
8740
+ }
8741
+ const editorProps = {
8742
+ key: 'label-editor',
8743
+ className: 'drawai-edit',
8744
+ autoFocus: true,
8745
+ value: editing.text,
8746
+ style: editorStyle,
8747
+ // 保留 kind 等全部字段:这里曾经重建对象时漏了 kind,于是"编辑过再平移"会走错分支。
8748
+ onChange: (event) => setEditing(Object.assign({}, editing, { text: event.target.value })),
8749
+ onBlur: commitEdit,
8750
+ onKeyDown: (event) => {
8751
+ // Enter 提交、Esc 取消;**Shift+Enter 换行**(多行标签的入口 ——
8752
+ // 原来它是个单行 input,连手写多行标签都做不到)。
8753
+ if (event.key === 'Enter' && event.shiftKey !== true) {
8754
+ event.preventDefault()
8755
+ commitEdit()
8756
+ } else if (event.key === 'Escape') {
8757
+ event.preventDefault()
8758
+ setEditing(null)
8759
+ }
8760
+ },
8761
+ }
8257
8762
  canvasChildren.push(
8258
- React.createElement('input', {
8259
- key: 'label-editor',
8260
- className: 'drawai-edit',
8261
- autoFocus: true,
8262
- value: editing.text,
8263
- style: {
8264
- left: editing.left + 'px',
8265
- top: editing.top + 'px',
8266
- width: Math.max(60, editing.width) + 'px',
8267
- height: Math.max(24, editing.height) + 'px',
8268
- },
8269
- // 保留 kind 等全部字段:这里曾经重建对象时漏了 kind,于是"编辑过再平移"会走错分支。
8270
- onChange: (event) => setEditing(Object.assign({}, editing, { text: event.target.value })),
8271
- onBlur: commitEdit,
8272
- onKeyDown: (event) => {
8273
- if (event.key === 'Enter') {
8274
- event.preventDefault()
8275
- commitEdit()
8276
- } else if (event.key === 'Escape') {
8277
- event.preventDefault()
8278
- setEditing(null)
8279
- }
8280
- },
8281
- }),
8763
+ isNodeEditor ? React.createElement('textarea', editorProps) : React.createElement('input', editorProps),
8282
8764
  )
8283
8765
  }
8284
8766
  body = React.createElement(
@@ -8636,11 +9118,23 @@ window.__ModuleLoader__.load({
8636
9118
  defaultLayer: defaultLayer,
8637
9119
  styleSummary: styleSummary,
8638
9120
  clampFontSize: clampFontSize,
9121
+ // 文字自适应高度:纯函数,自测直接喂节点夹具断言高度
9122
+ //(也断言编辑框的定位/宽度与最终标签用的是同一套文字区)。
9123
+ nodeTextFitHeight: nodeTextFitHeight,
9124
+ nodeBoxOf: nodeBoxOf,
9125
+ labelTextBox: labelTextBox,
9126
+ labelTextWidth: labelTextWidth,
9127
+ labelWraps: labelWraps,
9128
+ labelLineHeight: labelLineHeight,
9129
+ nodeEditorBox: nodeEditorBox,
9130
+ wrapLabel: wrapLabel,
8639
9131
  pathFromAddress: pathFromAddress,
8640
9132
  computeFitView: computeFitView,
8641
9133
  resizeViewFor: resizeViewFor,
8642
9134
  zoomViewAt: zoomViewAt,
8643
9135
  contentBounds: contentBounds,
9136
+ labelPaintBounds: labelPaintBounds,
9137
+ paintedBounds: paintedBounds,
8644
9138
  edgeLabelPointAt: edgeLabelPointAt,
8645
9139
  edgeLabelPosition: edgeLabelPosition,
8646
9140
  relativePointOnPath: relativePointOnPath,
@@ -8700,6 +9194,10 @@ window.__ModuleLoader__.load({
8700
9194
  // 按键归属:谁在打字、这次按键算不算画布的 —— 纯函数,自测直接喂假 DOM 节点。
8701
9195
  isTextEntry: isTextEntry,
8702
9196
  canvasOwnsKeyboard: canvasOwnsKeyboard,
9197
+ // 「你有没有选中画布之外的文字」——决定 Ctrl+C/X/A 放不放手让浏览器去复制。
9198
+ hasTextSelectionOutside: hasTextSelectionOutside,
9199
+ drawaiPaneOf: drawaiPaneOf,
9200
+ clearStaleTextSelection: clearStaleTextSelection,
8703
9201
  ensurePinned: ensurePinned,
8704
9202
  SIDES: SIDES,
8705
9203
  HOT_PAD: HOT_PAD,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-drawai",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "DSH 右侧栏的可编辑画布 + AI 语义绘图工具(diagram_apply / diagram_read),直接读写原生 .drawio 文件",
6
6
  "license": "MIT",