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.
- package/README.md +12 -5
- package/lib/client.js +640 -142
- 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
|
-
>
|
|
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
|
-
-
|
|
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 编解码 / 路由预览 / 宿主行为 / 渲染 / 组件),
|
|
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
|
-
|
|
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
|
-
|
|
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],
|
|
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
|
-
*
|
|
2296
|
-
*
|
|
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
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
const
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
if (
|
|
2311
|
-
|
|
2312
|
-
if (
|
|
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 =
|
|
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
|
|
3961
|
-
|
|
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 =
|
|
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
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
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
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6496
|
-
|
|
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
|
-
/**
|
|
7188
|
-
|
|
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),
|
|
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
|
-
{
|
|
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
|
-
//
|
|
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
|
-
//
|
|
8716
|
+
// 就地改标签:用**绝对定位的元素盖在节点的文字区上**。
|
|
8255
8717
|
// 不用 SVG foreignObject —— React 会把 <svg> 后代一律建成 SVG 命名空间元素,
|
|
8256
|
-
//
|
|
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,
|