leafer-connector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +386 -0
  3. package/dist/cjs/Connector.cjs +806 -0
  4. package/dist/cjs/Connector.js.map +1 -0
  5. package/dist/cjs/anchors.cjs +140 -0
  6. package/dist/cjs/anchors.js.map +1 -0
  7. package/dist/cjs/bezier.cjs +101 -0
  8. package/dist/cjs/bezier.js.map +1 -0
  9. package/dist/cjs/index.cjs +21 -0
  10. package/dist/cjs/index.js.map +1 -0
  11. package/dist/cjs/route.cjs +185 -0
  12. package/dist/cjs/route.js.map +1 -0
  13. package/dist/cjs/types.cjs +3 -0
  14. package/dist/cjs/types.js.map +1 -0
  15. package/dist/cjs/utils.cjs +11 -0
  16. package/dist/cjs/utils.js.map +1 -0
  17. package/dist/esm/Connector.d.ts +49 -0
  18. package/dist/esm/Connector.d.ts.map +1 -0
  19. package/dist/esm/Connector.js +802 -0
  20. package/dist/esm/Connector.js.map +1 -0
  21. package/dist/esm/anchors.d.ts +9 -0
  22. package/dist/esm/anchors.d.ts.map +1 -0
  23. package/dist/esm/anchors.js +132 -0
  24. package/dist/esm/anchors.js.map +1 -0
  25. package/dist/esm/bezier.d.ts +10 -0
  26. package/dist/esm/bezier.d.ts.map +1 -0
  27. package/dist/esm/bezier.js +95 -0
  28. package/dist/esm/bezier.js.map +1 -0
  29. package/dist/esm/index.d.ts +3 -0
  30. package/dist/esm/index.d.ts.map +1 -0
  31. package/dist/esm/index.js +3 -0
  32. package/dist/esm/index.js.map +1 -0
  33. package/dist/esm/route.d.ts +23 -0
  34. package/dist/esm/route.d.ts.map +1 -0
  35. package/dist/esm/route.js +179 -0
  36. package/dist/esm/route.js.map +1 -0
  37. package/dist/esm/types.d.ts +202 -0
  38. package/dist/esm/types.d.ts.map +1 -0
  39. package/dist/esm/types.js +2 -0
  40. package/dist/esm/types.js.map +1 -0
  41. package/dist/esm/utils.d.ts +3 -0
  42. package/dist/esm/utils.d.ts.map +1 -0
  43. package/dist/esm/utils.js +7 -0
  44. package/dist/esm/utils.js.map +1 -0
  45. package/package.json +67 -0
@@ -0,0 +1,806 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Connector = void 0;
4
+ const leafer_editor_1 = require("leafer-editor");
5
+ const utils_1 = require("./utils");
6
+ const route_1 = require("./route");
7
+ const bezier_1 = require("./bezier");
8
+ function asArrowStyle(style, scale) {
9
+ if (!style)
10
+ return style;
11
+ if (scale == null)
12
+ return style;
13
+ if (typeof style === "string")
14
+ return { type: style, scale };
15
+ if (typeof style === "object" && "type" in style) {
16
+ const old = style.scale;
17
+ return { ...style, scale: old != null ? old * scale : scale };
18
+ }
19
+ return style;
20
+ }
21
+ function sideOutDir(side) {
22
+ switch (side) {
23
+ case "left":
24
+ return { x: -1, y: 0 };
25
+ case "right":
26
+ return { x: 1, y: 0 };
27
+ case "top":
28
+ return { x: 0, y: -1 };
29
+ case "bottom":
30
+ return { x: 0, y: 1 };
31
+ }
32
+ }
33
+ // local 坐标下:点是否落在轴对齐矩形内部(用于有效 side 过滤/避障)
34
+ function pointInRect(p, r) {
35
+ return (p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height);
36
+ }
37
+ // 去掉连续重复点,避免生成 0 长度线段/影响 rounded path
38
+ function dedupePoints(points) {
39
+ const out = [];
40
+ for (const p of points) {
41
+ const last = out[out.length - 1];
42
+ if (!last || last.x !== p.x || last.y !== p.y)
43
+ out.push(p);
44
+ }
45
+ return out;
46
+ }
47
+ // 计算三次贝塞尔曲线上某个 t 的点(主要用于 label 定位)
48
+ function cubicBezierPoint(p0, p1, p2, p3, t) {
49
+ const u = 1 - t;
50
+ const tt = t * t;
51
+ const uu = u * u;
52
+ const uuu = uu * u;
53
+ const ttt = tt * t;
54
+ return {
55
+ x: uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x,
56
+ y: uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y,
57
+ };
58
+ }
59
+ function transformSvgPath(path, map) {
60
+ // 支持命令:M/L/C/Q/Z(绝对坐标)
61
+ // 用于 world <-> local 的 path 坐标批量转换(onDraw 入参/出参都走这里)
62
+ const segRe = /([MLCQZ])([^MLCQZ]*)/gi;
63
+ let out = "";
64
+ let m;
65
+ while ((m = segRe.exec(path))) {
66
+ const cmd = m[1];
67
+ const body = (m[2] || "").trim();
68
+ if (cmd.toUpperCase() === "Z") {
69
+ out += `${cmd} `;
70
+ continue;
71
+ }
72
+ const nums = body
73
+ .replace(/,/g, " ")
74
+ .trim()
75
+ .split(/\s+/)
76
+ .filter(Boolean)
77
+ .map((n) => Number(n));
78
+ if (!nums.length) {
79
+ out += `${cmd} `;
80
+ continue;
81
+ }
82
+ const mapped = [];
83
+ for (let i = 0; i < nums.length; i += 2) {
84
+ const x = nums[i];
85
+ const y = nums[i + 1];
86
+ if (typeof x !== "number" || typeof y !== "number")
87
+ break;
88
+ const p = map({ x, y });
89
+ mapped.push(p.x, p.y);
90
+ }
91
+ out += `${cmd} ${mapped.join(" ")} `;
92
+ }
93
+ return out.trim();
94
+ }
95
+ function stableStringify(value) {
96
+ // 用于 onChange.diff:将对象做 means-preserving 的“稳定序列化”,避免 key 顺序不同导致误判
97
+ const seen = new WeakSet();
98
+ const norm = (v) => {
99
+ if (v == null)
100
+ return v;
101
+ const t = typeof v;
102
+ if (t === "string" || t === "number" || t === "boolean")
103
+ return v;
104
+ if (t === "bigint")
105
+ return String(v);
106
+ if (t === "function")
107
+ return undefined;
108
+ if (Array.isArray(v))
109
+ return v.map(norm);
110
+ if (t === "object") {
111
+ if (seen.has(v))
112
+ return undefined;
113
+ seen.add(v);
114
+ const out = {};
115
+ const keys = Object.keys(v).sort();
116
+ for (const k of keys) {
117
+ const nv = norm(v[k]);
118
+ if (nv !== undefined)
119
+ out[k] = nv;
120
+ }
121
+ return out;
122
+ }
123
+ return undefined;
124
+ };
125
+ return JSON.stringify(norm(value));
126
+ }
127
+ class Connector extends leafer_editor_1.Group {
128
+ /**
129
+ * 删除 label 节点(当用户清空文本时)
130
+ * - 会同步清理 this.options.label,确保 getState/onChange 结果一致
131
+ */
132
+ removeLabelNode(oldText) {
133
+ const label = this._label;
134
+ if (!label)
135
+ return;
136
+ // 尽可能从树中移除(不同版本 API 可能叫 remove/removeSelf)
137
+ const anyLabel = label;
138
+ anyLabel.destroy();
139
+ this._label = undefined;
140
+ this._labelMid = null;
141
+ this._lastLabelText = null;
142
+ this.options.label = undefined;
143
+ // 关键:删除 label 后清掉 key,让下一次 update() 必定重算(否则会被 key 去重跳过,导致新建 label 坐标不更新)
144
+ this._lastKey = null;
145
+ // 删除也视为一次 label 变化:对外通知(用于协同)
146
+ const prev = String(oldText ?? "");
147
+ if (prev.trim() !== "") {
148
+ this.options.onLabelChange?.({ oldText: prev, newText: "" });
149
+ }
150
+ this.emitChange("label");
151
+ this.requestUpdate("event");
152
+ }
153
+ constructor(app, options) {
154
+ super({});
155
+ this._lastKey = null;
156
+ this._labelMid = null;
157
+ this._pendingUpdate = false;
158
+ this._lastRenderUpdateAt = 0;
159
+ this._lastLabelText = null;
160
+ this._labelChangePending = false;
161
+ this._boundNodes = new WeakSet();
162
+ this._lastEmittedState = null;
163
+ this._app = app;
164
+ this.fromNode = options.from;
165
+ this.toNode = options.to;
166
+ // 这里先把关键默认值算出来,避免 routeOptions 默认值依赖 margin/padding 时出现顺序问题
167
+ const padding = options.padding ?? 20;
168
+ const margin = options.margin ?? 0;
169
+ // Bezier/Smart-route 的默认参数:你只要 routeType="bezier",就不需要每次手动传
170
+ const mergedRouteOptions = {
171
+ avoidPadding: options.routeOptions?.avoidPadding ?? margin,
172
+ intersectionPenalty: options.routeOptions?.intersectionPenalty ?? 1e6,
173
+ longStraightRatio: options.routeOptions?.longStraightRatio ?? 0.65,
174
+ longStraightWeight: options.routeOptions?.longStraightWeight ?? 2000,
175
+ enableSRoutes: options.routeOptions?.enableSRoutes ?? true,
176
+ // 你在 demo 里手动传的是 0,这里设为默认值:确保默认“就是贝塞尔”
177
+ // 若你希望近距离自动降级为正交圆角(更稳定),可以显式传 140 或更大
178
+ bezierFallbackDistance: options.routeOptions?.bezierFallbackDistance ?? 0,
179
+ };
180
+ this.options = {
181
+ routeType: options.routeType || "orthogonal",
182
+ padding,
183
+ margin,
184
+ cornerRadius: options.cornerRadius ?? 16,
185
+ bezierCurvature: options.bezierCurvature ?? 0.6,
186
+ stroke: options.stroke || "#ffffff",
187
+ strokeWidth: options.strokeWidth ?? 2,
188
+ scaleMode: options.scaleMode || "world",
189
+ arrowBaseScale: options.arrowBaseScale ?? 1,
190
+ labelOnDoubleClick: options.labelOnDoubleClick ?? true,
191
+ updateMode: options.updateMode ?? "event",
192
+ renderThrottleMs: options.renderThrottleMs ?? 16,
193
+ ...options,
194
+ // 深合并 routeOptions:即使用户只传一部分字段,也能带上默认值
195
+ routeOptions: mergedRouteOptions,
196
+ };
197
+ this.wire = new leafer_editor_1.Path({
198
+ stroke: this.options.stroke,
199
+ strokeWidth: this.options.strokeWidth,
200
+ dashPattern: this.options.dashPattern,
201
+ startArrow: this.options.startArrow,
202
+ endArrow: this.options.endArrow ?? "triangle",
203
+ hitStrokeWidth: 12,
204
+ });
205
+ this.addMany(this.wire);
206
+ // 如果用户一开始就传了 label.text,则立即创建显示;如果 text 为空/空白,则不创建(避免出现空 label 节点)
207
+ if (this.options.label &&
208
+ String(this.options.label.text ?? "").trim() !== "")
209
+ this.ensureLabel();
210
+ this.bindInteractions();
211
+ this.update();
212
+ // 协同/程序更新场景(可选)
213
+ if (this.options.updateMode === "render") {
214
+ this._app.tree?.on_?.(leafer_editor_1.RenderEvent.END, () => this.requestUpdate("render"));
215
+ }
216
+ }
217
+ bind(from, to) {
218
+ this.fromNode = from;
219
+ this.toNode = to;
220
+ this.invalidate();
221
+ }
222
+ invalidate() {
223
+ this._lastKey = null;
224
+ this.requestUpdate("invalidate");
225
+ }
226
+ requestUpdate(_reason = "event") {
227
+ // render 模式下允许节流,把同一帧/短时间内的多次变化合并成一次 update()
228
+ if (this.options.updateMode === "render") {
229
+ const now = Date.now();
230
+ const throttle = Math.max(0, this.options.renderThrottleMs ?? 0);
231
+ if (throttle > 0 && now - this._lastRenderUpdateAt < throttle) {
232
+ if (this._pendingUpdate)
233
+ return;
234
+ this._pendingUpdate = true;
235
+ setTimeout(() => {
236
+ this._pendingUpdate = false;
237
+ this._lastRenderUpdateAt = Date.now();
238
+ this.update();
239
+ }, throttle);
240
+ return;
241
+ }
242
+ this._lastRenderUpdateAt = now;
243
+ this.update();
244
+ return;
245
+ }
246
+ // event/manual:不需要节流(用户可以自己做)
247
+ this.update();
248
+ }
249
+ getState() {
250
+ const fromId = this.fromNode.id ?? this.fromNode.innerId;
251
+ const toId = this.toNode.id ?? this.toNode.innerId;
252
+ if (!fromId || !toId) {
253
+ throw new Error("Connector.getState: missing fromId/toId");
254
+ }
255
+ return {
256
+ fromId,
257
+ toId,
258
+ routeType: this.options.routeType,
259
+ padding: this.options.padding,
260
+ margin: this.options.margin,
261
+ cornerRadius: this.options.cornerRadius,
262
+ bezierCurvature: this.options.bezierCurvature,
263
+ opt1: this.options.opt1,
264
+ opt2: this.options.opt2,
265
+ stroke: this.options.stroke,
266
+ strokeWidth: this.options.strokeWidth,
267
+ dashPattern: this.options.dashPattern,
268
+ startArrow: this.options.startArrow,
269
+ endArrow: this.options.endArrow,
270
+ scaleMode: this.options.scaleMode,
271
+ arrowBaseScale: this.options.arrowBaseScale,
272
+ label: this.options.label,
273
+ };
274
+ }
275
+ computeDiff(prev, next) {
276
+ const diff = {};
277
+ const changedKeys = [];
278
+ const keys = [
279
+ "fromId",
280
+ "toId",
281
+ "routeType",
282
+ "padding",
283
+ "margin",
284
+ "cornerRadius",
285
+ "bezierCurvature",
286
+ "opt1",
287
+ "opt2",
288
+ "stroke",
289
+ "strokeWidth",
290
+ "dashPattern",
291
+ "startArrow",
292
+ "endArrow",
293
+ "scaleMode",
294
+ "arrowBaseScale",
295
+ "label",
296
+ ];
297
+ for (const k of keys) {
298
+ const a = prev[k];
299
+ const b = next[k];
300
+ const same = typeof a === "object" || typeof b === "object"
301
+ ? stableStringify(a) === stableStringify(b)
302
+ : a === b;
303
+ if (!same) {
304
+ diff[k] = b;
305
+ changedKeys.push(k);
306
+ }
307
+ }
308
+ return { diff, changedKeys };
309
+ }
310
+ emitChange(reason) {
311
+ if (!this.options.onChange)
312
+ return;
313
+ try {
314
+ const next = this.getState();
315
+ // prev:从 _lastKey 无法逆推,改为缓存一次上次 state
316
+ const prev = this._lastEmittedState || next;
317
+ const { diff, changedKeys } = this.computeDiff(prev, next);
318
+ if (!changedKeys.length)
319
+ return;
320
+ this._lastEmittedState = next;
321
+ this.options.onChange({ reason, prev, next, diff, changedKeys });
322
+ }
323
+ catch {
324
+ // 缺少 getNodeId 或其它异常时跳过(避免影响渲染)
325
+ }
326
+ }
327
+ setState(state, resolveNode) {
328
+ const from = resolveNode(state.fromId);
329
+ const to = resolveNode(state.toId);
330
+ if (!from || !to)
331
+ throw new Error("Connector.setState: resolveNode failed for fromId/toId");
332
+ this.fromNode = from;
333
+ this.toNode = to;
334
+ this.options.routeType = state.routeType;
335
+ this.options.padding = state.padding;
336
+ this.options.margin = state.margin;
337
+ this.options.cornerRadius = state.cornerRadius;
338
+ this.options.bezierCurvature = state.bezierCurvature;
339
+ this.options.opt1 = state.opt1;
340
+ this.options.opt2 = state.opt2;
341
+ this.options.stroke = state.stroke ?? this.options.stroke;
342
+ this.options.strokeWidth = state.strokeWidth ?? this.options.strokeWidth;
343
+ this.options.dashPattern = state.dashPattern;
344
+ this.options.startArrow = state.startArrow;
345
+ this.options.endArrow = state.endArrow;
346
+ this.options.scaleMode = state.scaleMode ?? this.options.scaleMode;
347
+ this.options.arrowBaseScale =
348
+ state.arrowBaseScale ?? this.options.arrowBaseScale;
349
+ // style 直接写到 wire
350
+ this.wire.set({
351
+ stroke: this.options.stroke,
352
+ strokeWidth: this.options.strokeWidth,
353
+ dashPattern: this.options.dashPattern,
354
+ startArrow: this.options.startArrow,
355
+ endArrow: this.options.endArrow ?? "triangle",
356
+ });
357
+ // label
358
+ this.options.label = state.label;
359
+ if (state.label) {
360
+ this.ensureLabel();
361
+ if (state.label.text != null)
362
+ this._label.text = state.label.text;
363
+ if (state.label.style)
364
+ this.setLabelStyle(state.label.style);
365
+ }
366
+ this.invalidate();
367
+ this._lastEmittedState = state;
368
+ this.emitChange("setState");
369
+ }
370
+ setLabelText(text) {
371
+ // 规范化:
372
+ // - 允许回车换行(不要剔除 \n/\r)
373
+ // - 去掉整体前后空白字符(用户提的“前后空字符”)
374
+ const next = String(text ?? "").trim();
375
+ // 空文本:删除 label 节点
376
+ if (next === "") {
377
+ const old = String(this._label?.text ?? this.options.label?.text ?? "");
378
+ this.removeLabelNode(old);
379
+ return;
380
+ }
381
+ const label = this.ensureLabel();
382
+ const old = String(label.text ?? "");
383
+ label.text = next;
384
+ const now = String(label.text ?? "");
385
+ // 同步到 options(用于 getState/onChange)
386
+ this.options.label = { ...(this.options.label || {}), text: now };
387
+ if (old !== now) {
388
+ this.options.onLabelChange?.({ oldText: old, newText: now });
389
+ this.emitChange("label");
390
+ }
391
+ this.requestUpdate("event");
392
+ }
393
+ setLabelStyle(style) {
394
+ const label = this.ensureLabel();
395
+ label.set({
396
+ ...style,
397
+ textAlign: "center",
398
+ verticalAlign: "middle",
399
+ autoSizeAlign: true,
400
+ });
401
+ this.requestUpdate("event");
402
+ }
403
+ ensureLabel() {
404
+ if (this._label)
405
+ return this._label;
406
+ const cfg = this.options.label || {};
407
+ const style = (cfg.style || {});
408
+ // 默认背景遮线(用户自定义 boxStyle/padding 时不覆盖)
409
+ // 目的:label 永远可读,不会被线条穿过影响识别
410
+ const withDefaultBg = { fill: "#ffffff", fontSize: 12, padding: [2, 6], boxStyle: { fill: "#00000099", cornerRadius: 6 }, ...style };
411
+ const label = new leafer_editor_1.Text({
412
+ ...withDefaultBg,
413
+ text: cfg.text ?? withDefaultBg.text ?? "",
414
+ textAlign: "center",
415
+ verticalAlign: "middle",
416
+ autoSizeAlign: true,
417
+ editable: cfg.editable !== false,
418
+ editConfig: {
419
+ movable: false,
420
+ moveable: false,
421
+ resizeable: false,
422
+ rotateable: false,
423
+ skewable: false,
424
+ },
425
+ draggable: false,
426
+ hitStrokeWidth: 8,
427
+ });
428
+ this._label = label;
429
+ this._lastLabelText = String(label.text ?? "");
430
+ // 同步:确保 options.label 至少包含当前 text(用于 getState/onChange)
431
+ this.options.label = {
432
+ ...cfg,
433
+ text: String(label.text ?? ""),
434
+ };
435
+ this.add(label);
436
+ // 关键:新建 label 后清掉 key,强制下一次 update() 计算 labelMid 并把 label 放回连线中点
437
+ this._lastKey = null;
438
+ this.update();
439
+ // 编辑关闭:做一次最终规范化(去掉前后空白);若清空则删除 label 节点
440
+ label.on_(leafer_editor_1.InnerEditorEvent.CLOSE, () => {
441
+ if (this._label !== label)
442
+ return;
443
+ // CLOSE 时做最终规范化:允许回车换行,仅做 trim
444
+ const raw = String(label.text ?? "");
445
+ const trimmed = raw.trim();
446
+ const prev = String(this._lastLabelText ?? "");
447
+ if (trimmed === "") {
448
+ this.removeLabelNode(prev);
449
+ return;
450
+ }
451
+ if (trimmed !== raw)
452
+ label.text = trimmed;
453
+ this._lastLabelText = trimmed;
454
+ this.options.label = {
455
+ ...(this.options.label || {}),
456
+ text: trimmed,
457
+ };
458
+ const editor = label.app.editor;
459
+ if (!editor)
460
+ return;
461
+ if (editor.getItem?.() === label)
462
+ editor.cancel?.();
463
+ if (prev !== trimmed) {
464
+ this.options.onLabelChange?.({ oldText: prev, newText: trimmed });
465
+ this.emitChange("label");
466
+ }
467
+ this.requestUpdate("event");
468
+ });
469
+ // 监听 label 文本变化:用 RenderEvent.END 兜底(编辑器输入时一般会触发渲染)
470
+ label.on_(leafer_editor_1.RenderEvent.END, () => {
471
+ // 若 label 已被删除(例如用户清空文本触发 remove),则忽略后续事件
472
+ if (this._label !== label)
473
+ return;
474
+ const cur = String(label.text ?? "");
475
+ const prev = this._lastLabelText ?? "";
476
+ if (cur === prev)
477
+ return;
478
+ this._lastLabelText = cur;
479
+ // 合并同一微任务内的多次输入(避免 END 事件过于频繁导致 onLabelChange 抖动)
480
+ // 注意:这里选择 microtask,而不是 setTimeout(0),是为了尽量在同一帧内完成合并。
481
+ if (this._labelChangePending)
482
+ return;
483
+ this._labelChangePending = true;
484
+ queueMicrotask(() => {
485
+ this._labelChangePending = false;
486
+ if (this._label !== label)
487
+ return;
488
+ let now = String(label.text ?? "");
489
+ const old = prev;
490
+ // 允许回车换行:不再剔除换行符
491
+ // 规则:trim 后为空 => 删除 label 节点
492
+ if (now.trim() === "") {
493
+ this.removeLabelNode(old);
494
+ return;
495
+ }
496
+ if (now !== old) {
497
+ // 同步到 options(用于 getState/onChange)
498
+ this.options.label = {
499
+ ...(this.options.label || {}),
500
+ text: now,
501
+ };
502
+ this.options.onLabelChange?.({ oldText: old, newText: now });
503
+ this.emitChange("label");
504
+ this.requestUpdate("event");
505
+ }
506
+ });
507
+ });
508
+ return label;
509
+ }
510
+ openOrCreateLabelEditor() {
511
+ if (!this._label) {
512
+ const cur = String(this.options.label?.text ?? "");
513
+ if (cur.trim() === "") {
514
+ this.options.label = {
515
+ ...(this.options.label || {}),
516
+ text: "默认文案",
517
+ editable: true,
518
+ };
519
+ }
520
+ }
521
+ this.ensureLabel();
522
+ // 再 update 一次兜底:保证 labelMid 已产生(尤其是刚刚重建 label 的场景)
523
+ this.update();
524
+ }
525
+ buildCandidatePoints(node, opt, nodeRectLocal, otherRectLocal) {
526
+ const percent = (0, utils_1.clamp)(opt?.percent ?? 0.5, 0, 1);
527
+ const margin = opt?.margin ?? this.options.margin;
528
+ const padding = opt?.padding ?? this.options.padding;
529
+ // fixed linkPoint (world)
530
+ if (opt?.linkPoint) {
531
+ const lp = this.getLocalPoint(opt.linkPoint);
532
+ const side = (0, bezier_1.inferSideByPoint)(node, opt.linkPoint);
533
+ const dir = sideOutDir(side);
534
+ const linkPoint = this.getWorldPoint(lp);
535
+ const paddingPointLocal = {
536
+ x: lp.x + dir.x * padding,
537
+ y: lp.y + dir.y * padding,
538
+ };
539
+ const paddingPoint = this.getWorldPoint(paddingPointLocal);
540
+ return [
541
+ {
542
+ node,
543
+ side,
544
+ percent,
545
+ margin,
546
+ padding,
547
+ linkPoint,
548
+ paddingPoint,
549
+ },
550
+ ];
551
+ }
552
+ const sides = opt?.side && opt.side !== "auto"
553
+ ? [opt.side]
554
+ : ["top", "right", "bottom", "left"];
555
+ // 用 local rect 计算点位(避免画布平移/缩放带来的 world 误差)
556
+ // 这是修复“平移画布后连线漂移”的关键:Path.path 需要写 local 坐标
557
+ const r = (0, route_1.expandRect)(nodeRectLocal, margin);
558
+ const otherExpanded = (0, route_1.expandRect)(otherRectLocal, this.options.margin);
559
+ const points = [];
560
+ for (const s of sides) {
561
+ const linkLocal = s === "top"
562
+ ? { x: r.x + r.width * percent, y: r.y }
563
+ : s === "bottom"
564
+ ? { x: r.x + r.width * percent, y: r.y + r.height }
565
+ : s === "left"
566
+ ? { x: r.x, y: r.y + r.height * percent }
567
+ : { x: r.x + r.width, y: r.y + r.height * percent };
568
+ const dir = sideOutDir(s);
569
+ const padLocal = {
570
+ x: linkLocal.x + dir.x * padding,
571
+ y: linkLocal.y + dir.y * padding,
572
+ };
573
+ // valid side:padding 点落入对方 bounds 则判无效(避免“出线就插进对方节点内部”)
574
+ if (pointInRect(padLocal, otherExpanded))
575
+ continue;
576
+ points.push({
577
+ node,
578
+ side: s,
579
+ percent,
580
+ margin,
581
+ padding,
582
+ linkPoint: this.getWorldPoint(linkLocal),
583
+ paddingPoint: this.getWorldPoint(padLocal),
584
+ });
585
+ }
586
+ // 如果全部被过滤掉:fallback 回所有 side(避免无解)
587
+ if (!points.length) {
588
+ for (const s of ["top", "right", "bottom", "left"]) {
589
+ const linkLocal = s === "top"
590
+ ? { x: r.x + r.width * percent, y: r.y }
591
+ : s === "bottom"
592
+ ? { x: r.x + r.width * percent, y: r.y + r.height }
593
+ : s === "left"
594
+ ? { x: r.x, y: r.y + r.height * percent }
595
+ : { x: r.x + r.width, y: r.y + r.height * percent };
596
+ const dir = sideOutDir(s);
597
+ const padLocal = {
598
+ x: linkLocal.x + dir.x * padding,
599
+ y: linkLocal.y + dir.y * padding,
600
+ };
601
+ points.push({
602
+ node,
603
+ side: s,
604
+ percent,
605
+ margin,
606
+ padding,
607
+ linkPoint: this.getWorldPoint(linkLocal),
608
+ paddingPoint: this.getWorldPoint(padLocal),
609
+ });
610
+ }
611
+ }
612
+ return points;
613
+ }
614
+ update() {
615
+ if (this.options.updateMode === "manual" && this._lastKey != null) {
616
+ // manual 模式:默认不自动更新(除非用户 invalidate / update 触发)
617
+ // 这里不强制 return,因为用户可能手动调用 update() 进行刷新
618
+ }
619
+ // 去重 key:基于两端 bounds + 配置(粗略即可)
620
+ // 协同场景下,频繁 END 帧/坐标同步会调用 update(),key 去重能大幅减少重算/重绘
621
+ const fb = this.fromNode.worldBoxBounds;
622
+ const tb = this.toNode.worldBoxBounds;
623
+ const key = `${fb.x.toFixed(1)},${fb.y.toFixed(1)},${fb.width.toFixed(1)},${fb.height.toFixed(1)}|${tb.x.toFixed(1)},${tb.y.toFixed(1)},${tb.width.toFixed(1)},${tb.height.toFixed(1)}|${this.options.routeType}|${this.options.padding}|${this.options.margin}|${this.options.cornerRadius}|${this.options.scaleMode}`;
624
+ if (this._lastKey === key) {
625
+ if (this._label && this._labelMid)
626
+ this._label.set({ x: this._labelMid.x, y: this._labelMid.y });
627
+ return;
628
+ }
629
+ this._lastKey = key;
630
+ // world bounds -> local rect
631
+ // 注意:worldBoxBounds 会随着画布/父容器 transform 变化;转换到 local 后再做路由/避障更稳定
632
+ const rectToLocal = (r) => {
633
+ const p1 = this.getLocalPoint({ x: r.x, y: r.y });
634
+ const p2 = this.getLocalPoint({ x: r.x + r.width, y: r.y + r.height });
635
+ const x1 = Math.min(p1.x, p2.x);
636
+ const y1 = Math.min(p1.y, p2.y);
637
+ const x2 = Math.max(p1.x, p2.x);
638
+ const y2 = Math.max(p1.y, p2.y);
639
+ return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
640
+ };
641
+ const fromRectLocal = rectToLocal(fb);
642
+ const toRectLocal = rectToLocal(tb);
643
+ const sCandidates = this.buildCandidatePoints(this.fromNode, this.options.opt1, fromRectLocal, toRectLocal);
644
+ const eCandidates = this.buildCandidatePoints(this.toNode, this.options.opt2, toRectLocal, fromRectLocal);
645
+ const avoidRects = [
646
+ (0, route_1.expandRect)(fromRectLocal, this.options.routeOptions?.avoidPadding ?? this.options.margin),
647
+ (0, route_1.expandRect)(toRectLocal, this.options.routeOptions?.avoidPadding ?? this.options.margin),
648
+ ];
649
+ const routeType = this.options.routeType;
650
+ let best = null;
651
+ for (const s of sCandidates) {
652
+ const sLinkL = this.getLocalPoint(s.linkPoint);
653
+ const sPadL = this.getLocalPoint(s.paddingPoint);
654
+ for (const e of eCandidates) {
655
+ const eLinkL = this.getLocalPoint(e.linkPoint);
656
+ const ePadL = this.getLocalPoint(e.paddingPoint);
657
+ // 中间段(从 padding 到 padding)
658
+ // 两端的 linkPoint -> paddingPoint 用于“出线段”;中间段才是 routing(直线/正交/Bezier)
659
+ let midPoints;
660
+ let midScore = 0;
661
+ if (routeType === "straight") {
662
+ midPoints = [sPadL, ePadL];
663
+ midScore = Math.hypot(ePadL.x - sPadL.x, ePadL.y - sPadL.y);
664
+ }
665
+ else if (routeType === "bezier") {
666
+ // bezier:只做中间段的 C;最终还是用 Path 绘制
667
+ // 控制点方向由 side 推导(若 side 为 auto 选出的)
668
+ const fromSide = s.side;
669
+ const toSide = e.side;
670
+ const dist = Math.hypot(ePadL.x - sPadL.x, ePadL.y - sPadL.y);
671
+ const overlapX = fromRectLocal.x < toRectLocal.x + toRectLocal.width &&
672
+ fromRectLocal.x + fromRectLocal.width > toRectLocal.x;
673
+ const overlapY = fromRectLocal.y < toRectLocal.y + toRectLocal.height &&
674
+ fromRectLocal.y + fromRectLocal.height > toRectLocal.y;
675
+ // 近距离/重叠:Bezier 很容易丑(回勾/贴边),自动降级为正交圆角(更像流程图工具)
676
+ const bezFallback = this.options.routeOptions?.bezierFallbackDistance ?? 140;
677
+ if ((overlapX && overlapY) || dist < bezFallback) {
678
+ const mid = (0, route_1.buildOrthogonalBetween)(sPadL, ePadL, avoidRects, {
679
+ radius: this.options.cornerRadius,
680
+ intersectionPenalty: this.options.routeOptions?.intersectionPenalty,
681
+ longStraightRatio: this.options.routeOptions?.longStraightRatio,
682
+ longStraightWeight: this.options.routeOptions?.longStraightWeight,
683
+ enableSRoutes: this.options.routeOptions?.enableSRoutes,
684
+ });
685
+ const full = dedupePoints([sLinkL, ...mid.points, eLinkL]);
686
+ const pathLocal = (0, route_1.buildRoundedPolylinePath)(full, this.options.cornerRadius);
687
+ const labelMid = (0, route_1.polylineMidpoint)(full);
688
+ const score = mid.score + 1000; // 轻微偏好真 bezier(当两者都可用时)
689
+ if (!best || score < best.score)
690
+ best = { s, e, pointsLocal: full, pathLocal, score, labelMid };
691
+ continue;
692
+ }
693
+ const { c1, c2 } = (0, bezier_1.getCubicBezierControls)(sPadL, ePadL, fromSide, toSide, this.options.bezierCurvature);
694
+ const d = `M ${sLinkL.x} ${sLinkL.y} L ${sPadL.x} ${sPadL.y} C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${ePadL.x} ${ePadL.y} L ${eLinkL.x} ${eLinkL.y}`;
695
+ const labelMid = cubicBezierPoint(sPadL, c1, c2, ePadL, 0.5);
696
+ const score = dist;
697
+ if (!best || score < best.score)
698
+ best = {
699
+ s,
700
+ e,
701
+ pointsLocal: [sLinkL, sPadL, ePadL, eLinkL],
702
+ pathLocal: d,
703
+ score,
704
+ labelMid,
705
+ };
706
+ continue;
707
+ }
708
+ else if (routeType === "custom") {
709
+ // custom:默认仍给出一条可用的“直连 padding”结果,真正自定义交给 onDraw 覆盖
710
+ midPoints = [sPadL, ePadL];
711
+ midScore = Math.hypot(ePadL.x - sPadL.x, ePadL.y - sPadL.y);
712
+ }
713
+ else {
714
+ const mid = (0, route_1.buildOrthogonalBetween)(sPadL, ePadL, avoidRects, {
715
+ radius: this.options.cornerRadius,
716
+ intersectionPenalty: this.options.routeOptions?.intersectionPenalty,
717
+ longStraightRatio: this.options.routeOptions?.longStraightRatio,
718
+ longStraightWeight: this.options.routeOptions?.longStraightWeight,
719
+ enableSRoutes: this.options.routeOptions?.enableSRoutes,
720
+ });
721
+ midPoints = mid.points;
722
+ midScore = mid.score;
723
+ }
724
+ const full = dedupePoints([sLinkL, ...midPoints, eLinkL]);
725
+ const pathLocal = (0, route_1.buildRoundedPolylinePath)(full, this.options.cornerRadius);
726
+ const labelMid = (0, route_1.polylineMidpoint)(full);
727
+ const score = midScore;
728
+ if (!best || score < best.score)
729
+ best = { s, e, pointsLocal: full, pathLocal, score, labelMid };
730
+ }
731
+ }
732
+ if (!best)
733
+ return;
734
+ // onDraw(可覆盖):默认结果以 world 坐标提供
735
+ // 原因:业务侧通常以 world 坐标理解场景;同时可避免误把 local path 当 world 导致漂移
736
+ const defaultWorldPoints = best.pointsLocal.map((p) => this.getWorldPoint(p));
737
+ const defaultWorldPath = transformSvgPath(best.pathLocal, (p) => this.getWorldPoint(p));
738
+ const defaultResult = {
739
+ points: defaultWorldPoints,
740
+ path: defaultWorldPath,
741
+ };
742
+ if (this.options.onDraw) {
743
+ const override = this.options.onDraw({
744
+ s: best.s,
745
+ e: best.e,
746
+ defaultResult,
747
+ });
748
+ if (override?.path && typeof override.path === "string") {
749
+ // path 视为 world 坐标,转成 local 后再写入 Path.path
750
+ best.pathLocal = transformSvgPath(override.path, (p) => this.getLocalPoint(p));
751
+ // label:若没有提供 points,则沿用默认中点
752
+ }
753
+ if (override?.points?.length) {
754
+ const ptsLocal = dedupePoints(override.points.map((p) => this.getLocalPoint(p)));
755
+ best.pointsLocal = ptsLocal;
756
+ best.pathLocal = (0, route_1.buildRoundedPolylinePath)(ptsLocal, this.options.cornerRadius);
757
+ best.labelMid = (0, route_1.polylineMidpoint)(ptsLocal);
758
+ }
759
+ }
760
+ this.wire.path = best.pathLocal;
761
+ // label
762
+ this._labelMid = best.labelMid;
763
+ if (this._label)
764
+ this._label.set({ x: best.labelMid.x, y: best.labelMid.y });
765
+ this.applyScaleMode();
766
+ }
767
+ applyScaleMode() {
768
+ const strokeTarget = this.wire;
769
+ if (this.options.scaleMode === "pixel") {
770
+ strokeTarget.strokeWidthFixed = true;
771
+ const scale = Math.max(Math.abs(strokeTarget.worldTransform.scaleX || 1), Math.abs(strokeTarget.worldTransform.scaleY || 1));
772
+ const inv = scale ? 1 / scale : 1;
773
+ const s = inv * this.options.arrowBaseScale;
774
+ strokeTarget.startArrow = asArrowStyle(this.options.startArrow, s);
775
+ strokeTarget.endArrow = asArrowStyle(this.options.endArrow ?? "triangle", s);
776
+ }
777
+ else {
778
+ strokeTarget.strokeWidthFixed = false;
779
+ strokeTarget.startArrow = this.options.startArrow;
780
+ strokeTarget.endArrow = this.options.endArrow ?? "triangle";
781
+ }
782
+ }
783
+ bindInteractions() {
784
+ // 双击连线:创建/编辑 label
785
+ if (this.options.labelOnDoubleClick) {
786
+ const onDbl = () => this.openOrCreateLabelEditor();
787
+ this.wire.on_(leafer_editor_1.PointerEvent.DOUBLE_CLICK, onDbl);
788
+ this.wire.on_(leafer_editor_1.PointerEvent.DOUBLE_TAP, onDbl);
789
+ }
790
+ // 监听节点拖动,实时更新
791
+ // 注意:协同场景如果节点是“程序更新位置”,不会触发 DragEvent,需要用 updateMode="render"
792
+ const bindNode = (node) => {
793
+ if (this._boundNodes.has(node))
794
+ return;
795
+ this._boundNodes.add(node);
796
+ node.on_(leafer_editor_1.DragEvent.DRAG, () => this.requestUpdate("event"));
797
+ node.on_(leafer_editor_1.DragEvent.END, () => this.requestUpdate("event"));
798
+ };
799
+ if (this.options.updateMode !== "manual") {
800
+ bindNode(this.fromNode);
801
+ bindNode(this.toNode);
802
+ }
803
+ }
804
+ }
805
+ exports.Connector = Connector;
806
+ //# sourceMappingURL=Connector.js.map