@ngroznykh/papirus 0.6.4 → 0.7.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 (82) hide show
  1. package/README.md +13 -2
  2. package/README.ru.md +13 -2
  3. package/dist/core/ClipboardManager.d.ts +25 -0
  4. package/dist/core/ClipboardManager.d.ts.map +1 -0
  5. package/dist/core/ConnectionManager.d.ts +8 -3
  6. package/dist/core/ConnectionManager.d.ts.map +1 -1
  7. package/dist/core/DiagramRenderer.d.ts +20 -29
  8. package/dist/core/DiagramRenderer.d.ts.map +1 -1
  9. package/dist/core/DiagramSurface.d.ts +63 -0
  10. package/dist/core/DiagramSurface.d.ts.map +1 -0
  11. package/dist/core/DragManager.d.ts +2 -2
  12. package/dist/core/DragManager.d.ts.map +1 -1
  13. package/dist/core/EdgeEndpointUpdater.d.ts +25 -0
  14. package/dist/core/EdgeEndpointUpdater.d.ts.map +1 -0
  15. package/dist/core/HistoryManager.d.ts +0 -1
  16. package/dist/core/HistoryManager.d.ts.map +1 -1
  17. package/dist/core/InteractionManager.d.ts +4 -10
  18. package/dist/core/InteractionManager.d.ts.map +1 -1
  19. package/dist/core/NavigationManager.d.ts +2 -2
  20. package/dist/core/NavigationManager.d.ts.map +1 -1
  21. package/dist/core/PropertyChangeBatcher.d.ts +21 -0
  22. package/dist/core/PropertyChangeBatcher.d.ts.map +1 -0
  23. package/dist/core/ResizeManager.d.ts +2 -2
  24. package/dist/core/ResizeManager.d.ts.map +1 -1
  25. package/dist/core/ScrollbarController.d.ts +50 -0
  26. package/dist/core/ScrollbarController.d.ts.map +1 -0
  27. package/dist/core/SearchManager.d.ts.map +1 -1
  28. package/dist/core/SelectionManager.d.ts +2 -2
  29. package/dist/core/SelectionManager.d.ts.map +1 -1
  30. package/dist/core/overlays/MiniMap.d.ts +4 -7
  31. package/dist/core/overlays/MiniMap.d.ts.map +1 -1
  32. package/dist/elements/Edge.d.ts +20 -20
  33. package/dist/elements/Edge.d.ts.map +1 -1
  34. package/dist/elements/Element.d.ts +1 -0
  35. package/dist/elements/Element.d.ts.map +1 -1
  36. package/dist/elements/Node.d.ts.map +1 -1
  37. package/dist/elements/NodeImage.d.ts.map +1 -1
  38. package/dist/elements/Port.d.ts +1 -0
  39. package/dist/elements/Port.d.ts.map +1 -1
  40. package/dist/elements/TextLabel.d.ts +0 -1
  41. package/dist/elements/TextLabel.d.ts.map +1 -1
  42. package/dist/elements/composite/CComponent.d.ts +3 -2
  43. package/dist/elements/composite/CComponent.d.ts.map +1 -1
  44. package/dist/elements/composite/CContainer.d.ts +2 -3
  45. package/dist/elements/composite/CContainer.d.ts.map +1 -1
  46. package/dist/elements/composite/CDivider.d.ts +3 -8
  47. package/dist/elements/composite/CDivider.d.ts.map +1 -1
  48. package/dist/elements/composite/CIcon.d.ts +4 -10
  49. package/dist/elements/composite/CIcon.d.ts.map +1 -1
  50. package/dist/elements/composite/CShape.d.ts +4 -5
  51. package/dist/elements/composite/CShape.d.ts.map +1 -1
  52. package/dist/elements/composite/CText.d.ts +3 -8
  53. package/dist/elements/composite/CText.d.ts.map +1 -1
  54. package/dist/elements/composite/CompositeComponentBase.d.ts +25 -0
  55. package/dist/elements/composite/CompositeComponentBase.d.ts.map +1 -0
  56. package/dist/elements/edge/EdgeLabelRenderer.d.ts +17 -0
  57. package/dist/elements/edge/EdgeLabelRenderer.d.ts.map +1 -0
  58. package/dist/elements/edge/EdgeMarkerRenderer.d.ts +11 -0
  59. package/dist/elements/edge/EdgeMarkerRenderer.d.ts.map +1 -0
  60. package/dist/index.d.ts +15 -6
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/papirus.js +1875 -1776
  63. package/dist/papirus.js.map +1 -1
  64. package/dist/types.d.ts +15 -3
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/utils/AutoRouting.d.ts.map +1 -1
  67. package/dist/utils/Serializer.d.ts.map +1 -1
  68. package/dist/utils/SvgExporter.d.ts +0 -8
  69. package/dist/utils/SvgExporter.d.ts.map +1 -1
  70. package/dist/utils/direction.d.ts +4 -0
  71. package/dist/utils/direction.d.ts.map +1 -1
  72. package/dist/utils/edgePath.d.ts +21 -0
  73. package/dist/utils/edgePath.d.ts.map +1 -0
  74. package/dist/utils/iconLayout.d.ts +34 -0
  75. package/dist/utils/iconLayout.d.ts.map +1 -0
  76. package/dist/utils/keymap.d.ts +11 -0
  77. package/dist/utils/keymap.d.ts.map +1 -0
  78. package/dist/utils/svgAssetLoader.d.ts +23 -0
  79. package/dist/utils/svgAssetLoader.d.ts.map +1 -0
  80. package/dist/utils/textMeasurer.d.ts +13 -0
  81. package/dist/utils/textMeasurer.d.ts.map +1 -0
  82. package/package.json +1 -1
package/dist/papirus.js CHANGED
@@ -1047,19 +1047,21 @@ var DragManager = class extends EventEmitter {
1047
1047
  const nodeBindings = /* @__PURE__ */ new Map();
1048
1048
  for (const edge of this.renderer.edges.values()) {
1049
1049
  if (!edge.hasEditableControlPoints()) continue;
1050
- const isFromDragged = draggedIds.has(edge.from.nodeId);
1051
- const isToDragged = draggedIds.has(edge.to.nodeId);
1050
+ const fromId = edge.from.nodeId;
1051
+ const toId = edge.to.nodeId;
1052
+ const isFromDragged = !!fromId && draggedIds.has(fromId);
1053
+ const isToDragged = !!toId && draggedIds.has(toId);
1052
1054
  if (isFromDragged && isToDragged) {
1053
1055
  this.editablePolylineFullyConnectedEdges.add(edge.id);
1054
1056
  this.initialControlPointsForFullyConnected.set(edge.id, clonePoints(edge.controlPoints));
1055
1057
  continue;
1056
1058
  }
1057
1059
  for (const node of this.draggedNodes) {
1058
- const isFrom = edge.from.nodeId === node.id;
1059
- const isTo = edge.to.nodeId === node.id;
1060
+ const isFrom = fromId === node.id;
1061
+ const isTo = toId === node.id;
1060
1062
  if (!isFrom && !isTo) continue;
1061
- const oppositeNodeId = isFrom ? edge.to.nodeId : edge.from.nodeId;
1062
- if (draggedIds.has(oppositeNodeId)) continue;
1063
+ const oppositeNodeId = isFrom ? toId : fromId;
1064
+ if (!oppositeNodeId || draggedIds.has(oppositeNodeId)) continue;
1063
1065
  const controlPointIndex = isFrom ? 0 : edge.controlPoints.length - 1;
1064
1066
  const nearestPoint = edge.controlPoints[controlPointIndex];
1065
1067
  const endpoint = isFrom ? edge.startPoint : edge.endPoint;
@@ -1668,6 +1670,51 @@ var NavigationManager = class extends EventEmitter {
1668
1670
  }
1669
1671
  };
1670
1672
  //#endregion
1673
+ //#region src/utils/direction.ts
1674
+ /**
1675
+ * Map outline param [0,1) to cardinal direction (top → right → bottom → left).
1676
+ */
1677
+ function getDirectionFromOutlineParam(param) {
1678
+ const p = (param % 1 + 1) % 1;
1679
+ if (p < .25) return "top";
1680
+ if (p < .5) return "right";
1681
+ if (p < .75) return "bottom";
1682
+ return "left";
1683
+ }
1684
+ function isHorizontal(dir) {
1685
+ return dir === "left" || dir === "right";
1686
+ }
1687
+ function isVertical(dir) {
1688
+ return dir === "top" || dir === "bottom";
1689
+ }
1690
+ function isOppositeDirections(fromDir, toDir) {
1691
+ return fromDir === "left" && toDir === "right" || fromDir === "right" && toDir === "left" || fromDir === "top" && toDir === "bottom" || fromDir === "bottom" && toDir === "top";
1692
+ }
1693
+ function directionToVector(dir) {
1694
+ switch (dir) {
1695
+ case "top": return {
1696
+ x: 0,
1697
+ y: -1
1698
+ };
1699
+ case "bottom": return {
1700
+ x: 0,
1701
+ y: 1
1702
+ };
1703
+ case "left": return {
1704
+ x: -1,
1705
+ y: 0
1706
+ };
1707
+ case "right": return {
1708
+ x: 1,
1709
+ y: 0
1710
+ };
1711
+ default: return {
1712
+ x: 0,
1713
+ y: 0
1714
+ };
1715
+ }
1716
+ }
1717
+ //#endregion
1671
1718
  //#region src/core/ConnectionManager.ts
1672
1719
  /**
1673
1720
  * Manages port-to-port connection creation
@@ -1689,6 +1736,8 @@ var ConnectionManager = class extends EventEmitter {
1689
1736
  this.sourceOutlineParam = null;
1690
1737
  this.previewTargetOutlineParam = null;
1691
1738
  this.previewTargetNodeId = null;
1739
+ this.previewTargetEdgeId = null;
1740
+ this.previewTargetPathParam = null;
1692
1741
  this.isReconnecting = false;
1693
1742
  this.reconnectingEdge = null;
1694
1743
  this.reconnectingEndpoint = null;
@@ -1867,8 +1916,8 @@ var ConnectionManager = class extends EventEmitter {
1867
1916
  updateReconnectingEdge(point) {
1868
1917
  if (!this.reconnectingEdge) return;
1869
1918
  const edge = this.reconnectingEdge;
1870
- const fromNode = this.renderer.getNode(edge.from.nodeId);
1871
- const toNode = this.renderer.getNode(edge.to.nodeId);
1919
+ const fromNode = edge.from.nodeId ? this.renderer.getNode(edge.from.nodeId) : void 0;
1920
+ const toNode = edge.to.nodeId ? this.renderer.getNode(edge.to.nodeId) : void 0;
1872
1921
  let snappedPoint = point;
1873
1922
  let snappedDir;
1874
1923
  let targetNode = this.getNodeAtPoint(point, true);
@@ -1897,14 +1946,14 @@ var ConnectionManager = class extends EventEmitter {
1897
1946
  snappedPoint = bestResult.point;
1898
1947
  this.reconnectingOutlineParam = bestResult.param;
1899
1948
  this.reconnectingTargetNodeId = bestResult.node.id;
1900
- snappedDir = this.getDirectionFromOutlineParam(bestResult.param, bestResult.node);
1949
+ snappedDir = getDirectionFromOutlineParam(bestResult.param);
1901
1950
  if (this.reconnectingEndpoint === "start" ? toNode : fromNode) {
1902
1951
  const alignRef = edge.isEditablePolyline() ? this.getNearestBendPointForAxisAlignment(edge, this.reconnectingEndpoint) : this.reconnectingEndpoint === "start" ? this.getTargetPointForReconnect(edge, toNode, "to", snappedPoint) : this.getTargetPointForReconnect(edge, fromNode, "from", snappedPoint);
1903
1952
  snappedPoint = this.applyAxisAlignmentToPoint(snappedPoint, alignRef);
1904
1953
  const reprojected = targetNode.getClosestPointOnOutline(snappedPoint);
1905
1954
  snappedPoint = reprojected.point;
1906
1955
  this.reconnectingOutlineParam = reprojected.param;
1907
- snappedDir = this.getDirectionFromOutlineParam(reprojected.param, targetNode);
1956
+ snappedDir = getDirectionFromOutlineParam(reprojected.param);
1908
1957
  }
1909
1958
  } else {
1910
1959
  this.reconnectingOutlineParam = null;
@@ -1926,12 +1975,12 @@ var ConnectionManager = class extends EventEmitter {
1926
1975
  if (this.reconnectingEndpoint === "start") {
1927
1976
  if (toNode) {
1928
1977
  const target = this.getTargetPointForReconnect(edge, toNode, "to", snappedPoint);
1929
- const toDir = this.getTargetDirForReconnect(edge, toNode, "to");
1978
+ const toDir = this.getTargetDirForReconnect(edge, "to");
1930
1979
  edge.updateEndpoints(snappedPoint, target, snappedDir, toDir);
1931
1980
  }
1932
1981
  } else if (fromNode) {
1933
1982
  const start = this.getTargetPointForReconnect(edge, fromNode, "from", snappedPoint);
1934
- const fromDir = this.getTargetDirForReconnect(edge, fromNode, "from");
1983
+ const fromDir = this.getTargetDirForReconnect(edge, "from");
1935
1984
  edge.updateEndpoints(start, snappedPoint, fromDir, snappedDir);
1936
1985
  }
1937
1986
  this.renderer.markDirty();
@@ -1943,18 +1992,11 @@ var ConnectionManager = class extends EventEmitter {
1943
1992
  if (anchorId) return node.getAnchorPointById(anchorId) ?? node.getConnectionPoint(fallbackPoint);
1944
1993
  return node.getConnectionPoint(fallbackPoint);
1945
1994
  }
1946
- getTargetDirForReconnect(edge, node, endpoint) {
1995
+ getTargetDirForReconnect(edge, endpoint) {
1947
1996
  const ep = endpoint === "from" ? edge.from : edge.to;
1948
- if (this.attachToOutline && ep.outlineParam !== void 0) return this.getDirectionFromOutlineParam(ep.outlineParam, node);
1997
+ if (this.attachToOutline && ep.outlineParam !== void 0) return getDirectionFromOutlineParam(ep.outlineParam);
1949
1998
  return (ep.portId?.replace(ANCHOR_PORT_PREFIX, ""))?.split(":")[0];
1950
1999
  }
1951
- getDirectionFromOutlineParam(param, _node) {
1952
- const p = (param % 1 + 1) % 1;
1953
- if (p < .25) return "top";
1954
- if (p < .5) return "right";
1955
- if (p < .75) return "bottom";
1956
- return "left";
1957
- }
1958
2000
  /**
1959
2001
  * For editable polyline: return the nearest bend point for axis alignment.
1960
2002
  * When reconnecting 'end', use last control point (or start). When reconnecting 'start', use first control point (or end).
@@ -2018,6 +2060,8 @@ var ConnectionManager = class extends EventEmitter {
2018
2060
  this.previewTargetAnchorId = null;
2019
2061
  this.previewTargetOutlineParam = null;
2020
2062
  this.previewTargetNodeId = null;
2063
+ this.previewTargetEdgeId = null;
2064
+ this.previewTargetPathParam = null;
2021
2065
  let targetNode = this.getNodeAtPoint(cursorPoint, false);
2022
2066
  if (this.attachToOutline) {
2023
2067
  const tolerance = 40 / Math.max(this.renderer.zoom, 1e-4);
@@ -2061,6 +2105,24 @@ var ConnectionManager = class extends EventEmitter {
2061
2105
  }
2062
2106
  }
2063
2107
  }
2108
+ const edgeDrop = this.findEdgeDropTarget(cursorPoint);
2109
+ if (edgeDrop) {
2110
+ let nodeDistance = Infinity;
2111
+ if (targetNode) {
2112
+ const dx = cursorPoint.x - snappedPoint.x;
2113
+ const dy = cursorPoint.y - snappedPoint.y;
2114
+ nodeDistance = Math.sqrt(dx * dx + dy * dy);
2115
+ }
2116
+ if (!targetNode || edgeDrop.distance < nodeDistance) {
2117
+ targetNode = null;
2118
+ this.previewTargetNodeId = null;
2119
+ this.previewTargetOutlineParam = null;
2120
+ this.previewTargetAnchorId = null;
2121
+ snappedPoint = edgeDrop.point;
2122
+ this.previewTargetEdgeId = edgeDrop.edge.id;
2123
+ this.previewTargetPathParam = edgeDrop.pathParam;
2124
+ }
2125
+ }
2064
2126
  if (targetNode) {
2065
2127
  const forbidden = this._connectionValidator && this.sourceNode && !this._connectionValidator(this.sourceNode.id, targetNode.id);
2066
2128
  this.setCursor(forbidden ? "not-allowed" : "crosshair");
@@ -2161,9 +2223,29 @@ var ConnectionManager = class extends EventEmitter {
2161
2223
  y: event.worldY
2162
2224
  };
2163
2225
  let createdEdge = null;
2226
+ const edgeDrop = this.previewTargetEdgeId != null && this.previewTargetPathParam != null ? {
2227
+ edgeId: this.previewTargetEdgeId,
2228
+ pathParam: this.previewTargetPathParam,
2229
+ point
2230
+ } : this.findEdgeDropTarget(point);
2164
2231
  let targetNode = this.getNodeAtPoint(point, false);
2165
2232
  if (!targetNode && this.attachToOutline && this.previewTargetNodeId) targetNode = this.renderer.getNode(this.previewTargetNodeId) ?? null;
2166
- if (targetNode) {
2233
+ if (edgeDrop && this.sourceNode && (!targetNode || this.previewTargetEdgeId != null)) {
2234
+ const from = this.attachToOutline ? {
2235
+ nodeId: this.sourceNode.id,
2236
+ outlineParam: this.sourceOutlineParam ?? void 0
2237
+ } : {
2238
+ nodeId: this.sourceNode.id,
2239
+ portId: this.sourceAnchorId ? `${ANCHOR_PORT_PREFIX}${this.sourceAnchorId}` : void 0
2240
+ };
2241
+ const to = {
2242
+ edgeId: edgeDrop.edgeId,
2243
+ pathParam: edgeDrop.pathParam
2244
+ };
2245
+ createdEdge = this.createEdge(from, to);
2246
+ this.addEdge(createdEdge);
2247
+ this.emit("connect", createdEdge);
2248
+ } else if (targetNode) {
2167
2249
  if (!this._connectionValidator || this._connectionValidator(this.sourceNode.id, targetNode.id)) {
2168
2250
  let from;
2169
2251
  let to;
@@ -2227,8 +2309,8 @@ var ConnectionManager = class extends EventEmitter {
2227
2309
  ctx.lineTo(end.x, end.y);
2228
2310
  ctx.stroke();
2229
2311
  } else {
2230
- const fromDir = this.sourceOutlineParam !== null ? this.getDirectionFromOutlineParam(this.sourceOutlineParam, this.sourceNode) : this.sourceAnchorId?.split(":")[0];
2231
- const toDir = this.previewTargetOutlineParam !== null ? this.getDirectionFromOutlineParam(this.previewTargetOutlineParam, this.sourceNode) : this.previewTargetAnchorId?.split(":")[0];
2312
+ const fromDir = this.sourceOutlineParam !== null ? getDirectionFromOutlineParam(this.sourceOutlineParam) : this.sourceAnchorId?.split(":")[0];
2313
+ const toDir = this.previewTargetOutlineParam !== null ? getDirectionFromOutlineParam(this.previewTargetOutlineParam) : this.previewTargetAnchorId?.split(":")[0];
2232
2314
  this.drawBezierPreview(ctx, start, end, fromDir, toDir);
2233
2315
  }
2234
2316
  ctx.setLineDash([]);
@@ -2300,8 +2382,36 @@ var ConnectionManager = class extends EventEmitter {
2300
2382
  this.sourceOutlineParam = null;
2301
2383
  this.previewTargetOutlineParam = null;
2302
2384
  this.previewTargetNodeId = null;
2385
+ this.previewTargetEdgeId = null;
2386
+ this.previewTargetPathParam = null;
2303
2387
  this.setCursor("");
2304
2388
  }
2389
+ /**
2390
+ * Find the nearest edge under the cursor for junction / note→relation drops.
2391
+ */
2392
+ findEdgeDropTarget(point) {
2393
+ const tolerance = Math.max(14, 20 / Math.max(this.renderer.zoom, 1e-4));
2394
+ let best = null;
2395
+ for (const edge of this.renderer.edges.values()) {
2396
+ if (!edge.visible || edge.path.length < 2) continue;
2397
+ const closest = edge.getClosestPointOnPath(point);
2398
+ if (!closest || closest.distance > tolerance) continue;
2399
+ if (!best || closest.distance < best.distance) best = {
2400
+ edge,
2401
+ pathParam: closest.pathParam,
2402
+ point: closest.point,
2403
+ distance: closest.distance
2404
+ };
2405
+ }
2406
+ if (!best) return null;
2407
+ return {
2408
+ edge: best.edge,
2409
+ edgeId: best.edge.id,
2410
+ pathParam: best.pathParam,
2411
+ point: best.point,
2412
+ distance: best.distance
2413
+ };
2414
+ }
2305
2415
  resetReconnection() {
2306
2416
  this.isReconnecting = false;
2307
2417
  if (this.reconnectingEdge) this.reconnectingEdge.autoUpdateEndpoints = true;
@@ -2577,6 +2687,25 @@ var ConnectionManager = class extends EventEmitter {
2577
2687
  }
2578
2688
  };
2579
2689
  //#endregion
2690
+ //#region src/utils/keymap.ts
2691
+ var DEFAULT_HISTORY_SHORTCUT_KEYS = {
2692
+ undoKey: "z",
2693
+ redoKey: "y"
2694
+ };
2695
+ /**
2696
+ * Resolves a letter key independently of the active keyboard layout.
2697
+ */
2698
+ function getShortcutKey(event) {
2699
+ return event.code.startsWith("Key") ? event.code.slice(3).toLowerCase() : event.key.toLowerCase();
2700
+ }
2701
+ function getHistoryShortcut(event, keys = DEFAULT_HISTORY_SHORTCUT_KEYS) {
2702
+ if (!event.ctrlKey && !event.metaKey) return null;
2703
+ const key = getShortcutKey(event);
2704
+ if (key === keys.undoKey.toLowerCase() && !event.shiftKey) return "undo";
2705
+ if (key === keys.redoKey.toLowerCase() || key === keys.undoKey.toLowerCase() && event.shiftKey) return "redo";
2706
+ return null;
2707
+ }
2708
+ //#endregion
2580
2709
  //#region src/utils/style.ts
2581
2710
  /**
2582
2711
  * Shallow compare two objects
@@ -2608,6 +2737,44 @@ function applyStyleManagerToElements(styleManager, groups, edges, nodes) {
2608
2737
  for (const node of nodes) node.applyStyleManager(styleManager);
2609
2738
  }
2610
2739
  //#endregion
2740
+ //#region src/utils/textMeasurer.ts
2741
+ /**
2742
+ * Wrap text by words while preserving explicit newline boundaries.
2743
+ */
2744
+ function wrapMeasuredText(ctx, text, maxWidth, options = {}) {
2745
+ const lines = [];
2746
+ for (const paragraph of text.split("\n")) {
2747
+ const words = paragraph.split(" ");
2748
+ let currentLine = "";
2749
+ for (const word of words) {
2750
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
2751
+ if (ctx.measureText(testLine).width > maxWidth && currentLine) {
2752
+ lines.push(currentLine);
2753
+ currentLine = word;
2754
+ } else currentLine = testLine;
2755
+ }
2756
+ lines.push(currentLine);
2757
+ }
2758
+ const maxLines = options.maxLines;
2759
+ if (maxLines !== void 0 && lines.length > maxLines) {
2760
+ const truncated = lines.slice(0, Math.max(0, maxLines));
2761
+ if (options.appendEllipsis && truncated.length > 0) {
2762
+ const lastIndex = truncated.length - 1;
2763
+ truncated[lastIndex] = `${truncated[lastIndex]}…`;
2764
+ }
2765
+ return truncated;
2766
+ }
2767
+ return lines;
2768
+ }
2769
+ /**
2770
+ * Return the widest measured line.
2771
+ */
2772
+ function measureTextLines(ctx, lines) {
2773
+ let maxWidth = 0;
2774
+ for (const line of lines) maxWidth = Math.max(maxWidth, ctx.measureText(line).width);
2775
+ return maxWidth;
2776
+ }
2777
+ //#endregion
2611
2778
  //#region src/elements/TextLabel.ts
2612
2779
  var DEFAULT_INSET = 8;
2613
2780
  function normalizeTextInset(value, fallback) {
@@ -2815,16 +2982,9 @@ var TextLabel = class {
2815
2982
  const lineHeight = (this._style.fontSize ?? 14) * 1.2;
2816
2983
  const effectiveMaxWidth = this._maxWidth ?? this._autoMaxWidth;
2817
2984
  const text = this._text ?? "";
2818
- if (effectiveMaxWidth !== void 0) {
2819
- const maxWidth = Math.max(0, effectiveMaxWidth - this._inset.left - this._inset.right);
2820
- this._lines = this.wrapText(ctx, text, maxWidth);
2821
- } else this._lines = text.split("\n");
2822
- let maxLineWidth = 0;
2823
- for (const line of this._lines) {
2824
- const metrics = ctx.measureText(line);
2825
- maxLineWidth = Math.max(maxLineWidth, metrics.width);
2826
- }
2827
- this._measuredWidth = maxLineWidth + this._inset.left + this._inset.right;
2985
+ if (effectiveMaxWidth !== void 0) this._lines = wrapMeasuredText(ctx, text, Math.max(0, effectiveMaxWidth - this._inset.left - this._inset.right));
2986
+ else this._lines = text.split("\n");
2987
+ this._measuredWidth = measureTextLines(ctx, this._lines) + this._inset.left + this._inset.right;
2828
2988
  this._measuredHeight = this._lines.length * lineHeight + this._inset.top + this._inset.bottom;
2829
2989
  this._measureDirty = false;
2830
2990
  return {
@@ -2896,23 +3056,6 @@ var TextLabel = class {
2896
3056
  ctx.textAlign = this._style.align ?? "center";
2897
3057
  ctx.textBaseline = this._style.baseline ?? "middle";
2898
3058
  }
2899
- wrapText(ctx, text, maxWidth) {
2900
- const lines = [];
2901
- const paragraphs = text.split("\n");
2902
- for (const paragraph of paragraphs) {
2903
- const words = paragraph.split(" ");
2904
- let currentLine = "";
2905
- for (const word of words) {
2906
- const testLine = currentLine.length > 0 ? `${currentLine} ${word}` : word;
2907
- if (ctx.measureText(testLine).width > maxWidth && currentLine.length > 0) {
2908
- lines.push(currentLine);
2909
- currentLine = word;
2910
- } else currentLine = testLine;
2911
- }
2912
- if (currentLine.length > 0) lines.push(currentLine);
2913
- }
2914
- return lines.length > 0 ? lines : [""];
2915
- }
2916
3059
  };
2917
3060
  //#endregion
2918
3061
  //#region src/core/history/commands.ts
@@ -3282,16 +3425,14 @@ var HistoryManager = class extends EventEmitter {
3282
3425
  }
3283
3426
  /**
3284
3427
  * Handle keyboard shortcuts.
3285
- * Uses event.code for letter keys to work with any keyboard layout (e.g. Russian).
3286
3428
  */
3287
3429
  handleKeyDown(event) {
3288
- const isCtrlOrMeta = event.ctrlKey || event.metaKey;
3289
- const key = event.code.startsWith("Key") ? event.code.slice(3).toLowerCase() : event.key.toLowerCase();
3290
- if (isCtrlOrMeta && key === "z" && !event.shiftKey) {
3430
+ const shortcut = getHistoryShortcut(event);
3431
+ if (shortcut === "undo") {
3291
3432
  event.preventDefault();
3292
3433
  return this.undo();
3293
3434
  }
3294
- if (isCtrlOrMeta && (key === "y" || key === "z" && event.shiftKey)) {
3435
+ if (shortcut === "redo") {
3295
3436
  event.preventDefault();
3296
3437
  return this.redo();
3297
3438
  }
@@ -3312,6 +3453,7 @@ function generateId(prefix = "el") {
3312
3453
  }
3313
3454
  /**
3314
3455
  * Reset ID counter (for testing)
3456
+ * @internal
3315
3457
  */
3316
3458
  function resetIdCounter() {
3317
3459
  nextId = 1;
@@ -3518,6 +3660,109 @@ var Element = class {
3518
3660
  }
3519
3661
  };
3520
3662
  //#endregion
3663
+ //#region src/utils/edgePath.ts
3664
+ /**
3665
+ * Point and tangent angle at a normalized position along an edge path (0..1 by length).
3666
+ */
3667
+ function getPathPointAt(path, type, position) {
3668
+ if (type !== "bezier" || path.length < 4) return getPointAlongPolyline(path, position);
3669
+ const samples = [];
3670
+ const steps = 20;
3671
+ for (let i = 1; i + 2 < path.length; i += 3) {
3672
+ const start = path[i - 1];
3673
+ const control1 = path[i];
3674
+ const control2 = path[i + 1];
3675
+ const end = path[i + 2];
3676
+ for (let sample = 0; sample <= steps; sample++) {
3677
+ const t = sample / steps;
3678
+ if (samples.length > 0 && t === 0) continue;
3679
+ samples.push(bezierPoint(start, control1, control2, end, t));
3680
+ }
3681
+ }
3682
+ return getPointAlongPolyline(samples, position);
3683
+ }
3684
+ function getPointAlongPolyline(path, position) {
3685
+ if (path.length === 0) return {
3686
+ point: {
3687
+ x: 0,
3688
+ y: 0
3689
+ },
3690
+ angle: 0
3691
+ };
3692
+ if (path.length === 1) return {
3693
+ point: path[0],
3694
+ angle: 0
3695
+ };
3696
+ let totalLength = 0;
3697
+ const segments = [];
3698
+ for (let i = 1; i < path.length; i++) {
3699
+ const start = path[i - 1];
3700
+ const end = path[i];
3701
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
3702
+ segments.push({
3703
+ start,
3704
+ end,
3705
+ length
3706
+ });
3707
+ totalLength += length;
3708
+ }
3709
+ const targetLength = totalLength * Math.max(0, Math.min(1, position));
3710
+ let accumulated = 0;
3711
+ for (const segment of segments) {
3712
+ if (accumulated + segment.length >= targetLength) {
3713
+ const segmentPosition = segment.length > 0 ? (targetLength - accumulated) / segment.length : 0;
3714
+ return {
3715
+ point: {
3716
+ x: segment.start.x + segmentPosition * (segment.end.x - segment.start.x),
3717
+ y: segment.start.y + segmentPosition * (segment.end.y - segment.start.y)
3718
+ },
3719
+ angle: Math.atan2(segment.end.y - segment.start.y, segment.end.x - segment.start.x)
3720
+ };
3721
+ }
3722
+ accumulated += segment.length;
3723
+ }
3724
+ const lastSegment = segments[segments.length - 1];
3725
+ return {
3726
+ point: lastSegment.end,
3727
+ angle: Math.atan2(lastSegment.end.y - lastSegment.start.y, lastSegment.end.x - lastSegment.start.x)
3728
+ };
3729
+ }
3730
+ /** Map tangent angle to a cardinal direction used by polyline routing. */
3731
+ function directionFromAngle(angle) {
3732
+ const twoPi = Math.PI * 2;
3733
+ const a = (angle % twoPi + twoPi) % twoPi;
3734
+ if (a >= Math.PI * .25 && a < Math.PI * .75) return "bottom";
3735
+ if (a >= Math.PI * .75 && a < Math.PI * 1.25) return "left";
3736
+ if (a >= Math.PI * 1.25 && a < Math.PI * 1.75) return "top";
3737
+ return "right";
3738
+ }
3739
+ /**
3740
+ * Nearest point on an edge path to `point`, by sampling along normalized length.
3741
+ */
3742
+ function getClosestPointOnPath(path, type, point, samples = 48) {
3743
+ if (path.length < 2) return null;
3744
+ let bestPoint = getPathPointAt(path, type, 0).point;
3745
+ let bestParam = 0;
3746
+ let bestDistSq = Infinity;
3747
+ for (let i = 0; i <= samples; i++) {
3748
+ const pathParam = i / samples;
3749
+ const at = getPathPointAt(path, type, pathParam).point;
3750
+ const dx = at.x - point.x;
3751
+ const dy = at.y - point.y;
3752
+ const distSq = dx * dx + dy * dy;
3753
+ if (distSq < bestDistSq) {
3754
+ bestDistSq = distSq;
3755
+ bestPoint = at;
3756
+ bestParam = pathParam;
3757
+ }
3758
+ }
3759
+ return {
3760
+ point: bestPoint,
3761
+ pathParam: bestParam,
3762
+ distance: Math.sqrt(bestDistSq)
3763
+ };
3764
+ }
3765
+ //#endregion
3521
3766
  //#region src/elements/paths/StraightPathStrategy.ts
3522
3767
  /**
3523
3768
  * Straight line path strategy
@@ -3532,41 +3777,6 @@ var StraightPathStrategy = class {
3532
3777
  }
3533
3778
  };
3534
3779
  //#endregion
3535
- //#region src/utils/direction.ts
3536
- function isHorizontal(dir) {
3537
- return dir === "left" || dir === "right";
3538
- }
3539
- function isVertical(dir) {
3540
- return dir === "top" || dir === "bottom";
3541
- }
3542
- function isOppositeDirections(fromDir, toDir) {
3543
- return fromDir === "left" && toDir === "right" || fromDir === "right" && toDir === "left" || fromDir === "top" && toDir === "bottom" || fromDir === "bottom" && toDir === "top";
3544
- }
3545
- function directionToVector(dir) {
3546
- switch (dir) {
3547
- case "top": return {
3548
- x: 0,
3549
- y: -1
3550
- };
3551
- case "bottom": return {
3552
- x: 0,
3553
- y: 1
3554
- };
3555
- case "left": return {
3556
- x: -1,
3557
- y: 0
3558
- };
3559
- case "right": return {
3560
- x: 1,
3561
- y: 0
3562
- };
3563
- default: return {
3564
- x: 0,
3565
- y: 0
3566
- };
3567
- }
3568
- }
3569
- //#endregion
3570
3780
  //#region src/elements/paths/PolylinePathStrategy.ts
3571
3781
  var MIN_SEGMENT_LENGTH = 20;
3572
3782
  var SELF_LOOP_MIN_DISTANCE$1 = 1;
@@ -4298,85 +4508,458 @@ var BezierPathStrategy = class {
4298
4508
  }
4299
4509
  };
4300
4510
  //#endregion
4301
- //#region src/elements/Edge.ts
4302
- var DEFAULT_EDGE_STYLE = {
4303
- strokeColor: "#666666",
4304
- strokeWidth: 2,
4305
- opacity: 1
4306
- };
4511
+ //#region src/utils/markers.ts
4307
4512
  /**
4308
- * Connection edge between nodes
4513
+ * Calculate marker points based on edge path
4309
4514
  */
4310
- var Edge = class extends Element {
4311
- constructor(options) {
4312
- super({
4313
- id: options.id ?? generateId("edge"),
4314
- x: 0,
4315
- y: 0,
4316
- width: 0,
4317
- height: 0,
4318
- style: options.style,
4319
- styleClass: options.styleClass
4320
- });
4321
- this._path = [];
4322
- this._autoUpdateEndpoints = true;
4323
- this._fromPoint = {
4324
- x: 0,
4325
- y: 0
4326
- };
4327
- this._toPoint = {
4328
- x: 0,
4329
- y: 0
4330
- };
4331
- this._from = options.from;
4332
- this._to = options.to;
4333
- this._type = options.type ?? "straight";
4334
- this._arrowType = options.arrowType ?? "single";
4335
- this._controlPoints = options.controlPoints;
4336
- this._startMarker = options.startMarker;
4337
- this._endMarker = options.endMarker;
4338
- this._edgeStyle = {
4339
- ...DEFAULT_EDGE_STYLE,
4340
- ...options.style
4515
+ function calculateMarkerPoints(path, position, type) {
4516
+ if (path.length < 2) return null;
4517
+ if (type === "bezier" && path.length >= 4) {
4518
+ const epsilon = .001;
4519
+ const isSame = (a, b) => Math.abs(a.x - b.x) < epsilon && Math.abs(a.y - b.y) < epsilon;
4520
+ if (position === "end") {
4521
+ const endIndex = path.length - 1;
4522
+ const endPoint = path[endIndex];
4523
+ let from = path[endIndex - 1];
4524
+ if (isSame(from, endPoint)) {
4525
+ from = path[endIndex - 2];
4526
+ if (isSame(from, endPoint)) from = path[0];
4527
+ }
4528
+ return {
4529
+ from,
4530
+ to: endPoint
4531
+ };
4532
+ }
4533
+ const start = path[0];
4534
+ let next = path[1];
4535
+ if (isSame(next, start)) {
4536
+ next = path[2];
4537
+ if (isSame(next, start)) next = path[path.length - 1];
4538
+ }
4539
+ return {
4540
+ from: next,
4541
+ to: start
4341
4542
  };
4342
- this._pathStrategy = this.getPathStrategy(this._type);
4343
- this._lockAnchors = options.lockAnchors ?? true;
4344
- this._labelOffset = options.labelOffset ?? 0;
4345
- this._labelPosition = options.labelPosition ?? .5;
4346
- this._labelFollowPath = options.labelFollowPath ?? false;
4347
- this._labelBackground = options.labelBackground;
4348
- this._labelLineGap = options.labelLineGap ?? false;
4349
- if (options.label !== void 0) if (typeof options.label === "string") this._label = new TextLabel({
4350
- text: options.label,
4351
- onChange: () => this.markDirty()
4352
- });
4353
- else this._label = new TextLabel({
4354
- ...options.label,
4355
- onChange: () => this.markDirty()
4356
- });
4357
- }
4358
- /**
4359
- * Source endpoint
4360
- */
4361
- get from() {
4362
- return this._from;
4363
4543
  }
4364
- set from(value) {
4365
- this._from = value;
4366
- this.markDirty();
4367
- }
4368
- /**
4369
- * Target endpoint
4370
- */
4371
- get to() {
4372
- return this._to;
4373
- }
4374
- set to(value) {
4375
- this._to = value;
4376
- this.markDirty();
4377
- }
4378
- /**
4379
- * Edge type
4544
+ if (position === "end") return {
4545
+ from: path[path.length - 2],
4546
+ to: path[path.length - 1]
4547
+ };
4548
+ return {
4549
+ from: path[1],
4550
+ to: path[0]
4551
+ };
4552
+ }
4553
+ /**
4554
+ * Calculate arrow marker vertex points
4555
+ */
4556
+ function calculateArrowMarkerPoints(to, angle, size) {
4557
+ return {
4558
+ tip: to,
4559
+ left: {
4560
+ x: to.x - size * Math.cos(angle - ARROW_ANGLE),
4561
+ y: to.y - size * Math.sin(angle - ARROW_ANGLE)
4562
+ },
4563
+ right: {
4564
+ x: to.x - size * Math.cos(angle + ARROW_ANGLE),
4565
+ y: to.y - size * Math.sin(angle + ARROW_ANGLE)
4566
+ }
4567
+ };
4568
+ }
4569
+ /**
4570
+ * Calculate diamond marker vertex points
4571
+ */
4572
+ function calculateDiamondMarkerPoints(to, angle, size) {
4573
+ const halfLength = size / 2;
4574
+ const halfWidth = size * .3;
4575
+ const cos = Math.cos(angle);
4576
+ const sin = Math.sin(angle);
4577
+ return [
4578
+ {
4579
+ x: to.x,
4580
+ y: to.y
4581
+ },
4582
+ {
4583
+ x: to.x - halfLength * cos + halfWidth * sin,
4584
+ y: to.y - halfLength * sin - halfWidth * cos
4585
+ },
4586
+ {
4587
+ x: to.x - size * cos,
4588
+ y: to.y - size * sin
4589
+ },
4590
+ {
4591
+ x: to.x - halfLength * cos - halfWidth * sin,
4592
+ y: to.y - halfLength * sin + halfWidth * cos
4593
+ }
4594
+ ];
4595
+ }
4596
+ /**
4597
+ * Calculate circle marker center
4598
+ */
4599
+ function calculateCircleMarkerCenter(to, angle, size) {
4600
+ return {
4601
+ x: to.x - size * Math.cos(angle),
4602
+ y: to.y - size * Math.sin(angle)
4603
+ };
4604
+ }
4605
+ /**
4606
+ * Generate SVG path for arrow marker
4607
+ */
4608
+ function generateSvgArrowMarker(to, angle, size, fill, fillOpacity, stroke) {
4609
+ const { left, right } = calculateArrowMarkerPoints(to, angle, size);
4610
+ return `<path d="M ${to.x} ${to.y} L ${left.x} ${left.y} L ${right.x} ${right.y} Z" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="1"/>`;
4611
+ }
4612
+ /**
4613
+ * Generate SVG path for open arrow marker
4614
+ */
4615
+ function generateSvgOpenArrowMarker(to, angle, size, stroke) {
4616
+ const { left, right } = calculateArrowMarkerPoints(to, angle, size);
4617
+ return `<path d="M ${to.x} ${to.y} L ${left.x} ${left.y} M ${to.x} ${to.y} L ${right.x} ${right.y}" fill="none" stroke="${stroke}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`;
4618
+ }
4619
+ /**
4620
+ * Generate SVG path for diamond marker
4621
+ */
4622
+ function generateSvgDiamondMarker(to, angle, size, fill, fillOpacity, stroke) {
4623
+ const points = calculateDiamondMarkerPoints(to, angle, size);
4624
+ return `<path d="M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y} L ${points[2].x} ${points[2].y} L ${points[3].x} ${points[3].y} Z" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="1"/>`;
4625
+ }
4626
+ /**
4627
+ * Generate SVG circle marker
4628
+ */
4629
+ function generateSvgCircleMarker(to, angle, size, fill, fillOpacity, stroke) {
4630
+ const { x: cx, y: cy } = calculateCircleMarkerCenter(to, angle, size);
4631
+ return `<circle cx="${cx}" cy="${cy}" r="${size}" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="1"/>`;
4632
+ }
4633
+ /**
4634
+ * Square marker: front edge centered at `to`, depth 2×size along the edge toward `from`.
4635
+ */
4636
+ function generateSvgSquareMarker(to, angle, size, fill, fillOpacity, stroke) {
4637
+ const cos = Math.cos(angle);
4638
+ const sin = Math.sin(angle);
4639
+ const px = -sin;
4640
+ const py = cos;
4641
+ const p1 = {
4642
+ x: to.x + size * px,
4643
+ y: to.y + size * py
4644
+ };
4645
+ const p2 = {
4646
+ x: to.x - size * px,
4647
+ y: to.y - size * py
4648
+ };
4649
+ const bx = 2 * size * cos;
4650
+ const by = 2 * size * sin;
4651
+ const p3 = {
4652
+ x: p2.x - bx,
4653
+ y: p2.y - by
4654
+ };
4655
+ const p4 = {
4656
+ x: p1.x - bx,
4657
+ y: p1.y - by
4658
+ };
4659
+ return `<path d="M ${p1.x} ${p1.y} L ${p2.x} ${p2.y} L ${p3.x} ${p3.y} L ${p4.x} ${p4.y} Z" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="1"/>`;
4660
+ }
4661
+ /**
4662
+ * Generate SVG path for any marker type
4663
+ */
4664
+ function generateSvgMarker(marker, from, to, edgeStroke) {
4665
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
4666
+ const size = marker.size ?? 12;
4667
+ const stroke = marker.strokeColor ?? edgeStroke;
4668
+ const fill = marker.fillColor ?? stroke;
4669
+ const fillOpacity = marker.fillOpacity ?? 1;
4670
+ switch (marker.type) {
4671
+ case "open": return generateSvgOpenArrowMarker(to, angle, size, stroke);
4672
+ case "diamond": return generateSvgDiamondMarker(to, angle, size, fill, fillOpacity, stroke);
4673
+ case "circle": return generateSvgCircleMarker(to, angle, size, fill, fillOpacity, stroke);
4674
+ case "square": return generateSvgSquareMarker(to, angle, size, fill, fillOpacity, stroke);
4675
+ default: return generateSvgArrowMarker(to, angle, size, fill, fillOpacity, stroke);
4676
+ }
4677
+ }
4678
+ //#endregion
4679
+ //#region src/elements/edge/EdgeMarkerRenderer.ts
4680
+ function getCanvasMarkerLength(config) {
4681
+ const size = config.size ?? MARKER_SIZES[config.type] ?? 10;
4682
+ switch (config.type) {
4683
+ case "arrow": return size * Math.cos(ARROW_ANGLE);
4684
+ case "open": return 0;
4685
+ case "diamond": return size;
4686
+ case "circle":
4687
+ case "square": return size * 2;
4688
+ default: return 0;
4689
+ }
4690
+ }
4691
+ function renderEdgeMarkers(ctx, options) {
4692
+ const { path, type, arrowType, startMarker, endMarker } = options;
4693
+ if (path.length < 2) return;
4694
+ if (startMarker !== void 0 || endMarker !== void 0) {
4695
+ renderConfiguredMarker(ctx, path, type, "end", endMarker);
4696
+ renderConfiguredMarker(ctx, path, type, "start", startMarker);
4697
+ return;
4698
+ }
4699
+ if (arrowType === "none") return;
4700
+ const endPoints = calculateMarkerPoints(path, "end", type);
4701
+ if (endPoints) drawLegacyArrowHead(ctx, endPoints.from, endPoints.to);
4702
+ if (arrowType === "double") {
4703
+ const startPoints = calculateMarkerPoints(path, "start", type);
4704
+ if (startPoints) drawLegacyArrowHead(ctx, startPoints.from, startPoints.to);
4705
+ }
4706
+ }
4707
+ function renderConfiguredMarker(ctx, path, type, position, config) {
4708
+ if (!config || config.type === "none") return;
4709
+ const points = calculateMarkerPoints(path, position, type);
4710
+ if (points) drawMarker(ctx, points.from, points.to, config);
4711
+ }
4712
+ function drawMarker(ctx, from, to, config) {
4713
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
4714
+ const size = config.size ?? MARKER_SIZES[config.type] ?? 10;
4715
+ const strokeColor = config.strokeColor ?? ctx.strokeStyle;
4716
+ const fillColor = config.fillColor ?? strokeColor;
4717
+ const fillOpacity = config.fillOpacity ?? 1;
4718
+ ctx.save();
4719
+ ctx.setLineDash([]);
4720
+ ctx.lineDashOffset = 0;
4721
+ ctx.strokeStyle = strokeColor;
4722
+ switch (config.type) {
4723
+ case "arrow":
4724
+ drawArrowMarker(ctx, to, angle, size, fillColor, fillOpacity);
4725
+ break;
4726
+ case "open":
4727
+ drawOpenArrowMarker(ctx, to, angle, size);
4728
+ break;
4729
+ case "diamond":
4730
+ drawDiamondMarker(ctx, to, angle, size, fillColor, fillOpacity);
4731
+ break;
4732
+ case "circle":
4733
+ drawCircleMarker(ctx, to, angle, size, fillColor, fillOpacity);
4734
+ break;
4735
+ case "square":
4736
+ drawSquareMarker(ctx, to, angle, size, fillColor, fillOpacity);
4737
+ break;
4738
+ }
4739
+ ctx.restore();
4740
+ }
4741
+ function drawArrowMarker(ctx, to, angle, size, fillColor, fillOpacity) {
4742
+ const { left, right } = calculateArrowMarkerPoints(to, angle, size);
4743
+ ctx.beginPath();
4744
+ ctx.moveTo(to.x, to.y);
4745
+ ctx.lineTo(left.x, left.y);
4746
+ ctx.lineTo(right.x, right.y);
4747
+ ctx.closePath();
4748
+ fillAndStroke(ctx, fillColor, fillOpacity);
4749
+ }
4750
+ function drawOpenArrowMarker(ctx, to, angle, size) {
4751
+ const { left, right } = calculateArrowMarkerPoints(to, angle, size);
4752
+ ctx.beginPath();
4753
+ ctx.moveTo(to.x, to.y);
4754
+ ctx.lineTo(left.x, left.y);
4755
+ ctx.moveTo(to.x, to.y);
4756
+ ctx.lineTo(right.x, right.y);
4757
+ ctx.stroke();
4758
+ }
4759
+ function drawDiamondMarker(ctx, to, angle, size, fillColor, fillOpacity) {
4760
+ const points = calculateDiamondMarkerPoints(to, angle, size);
4761
+ ctx.beginPath();
4762
+ ctx.moveTo(points[0].x, points[0].y);
4763
+ for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
4764
+ ctx.closePath();
4765
+ fillAndStroke(ctx, fillColor, fillOpacity);
4766
+ }
4767
+ function drawCircleMarker(ctx, to, angle, size, fillColor, fillOpacity) {
4768
+ const center = calculateCircleMarkerCenter(to, angle, size);
4769
+ ctx.beginPath();
4770
+ ctx.arc(center.x, center.y, size, 0, Math.PI * 2);
4771
+ fillAndStroke(ctx, fillColor, fillOpacity);
4772
+ }
4773
+ function drawSquareMarker(ctx, to, angle, size, fillColor, fillOpacity) {
4774
+ const cos = Math.cos(angle);
4775
+ const sin = Math.sin(angle);
4776
+ const perpendicularX = -sin;
4777
+ const perpendicularY = cos;
4778
+ const frontLeft = {
4779
+ x: to.x + size * perpendicularX,
4780
+ y: to.y + size * perpendicularY
4781
+ };
4782
+ const frontRight = {
4783
+ x: to.x - size * perpendicularX,
4784
+ y: to.y - size * perpendicularY
4785
+ };
4786
+ const depthX = 2 * size * cos;
4787
+ const depthY = 2 * size * sin;
4788
+ const backRight = {
4789
+ x: frontRight.x - depthX,
4790
+ y: frontRight.y - depthY
4791
+ };
4792
+ const backLeft = {
4793
+ x: frontLeft.x - depthX,
4794
+ y: frontLeft.y - depthY
4795
+ };
4796
+ ctx.beginPath();
4797
+ ctx.moveTo(frontLeft.x, frontLeft.y);
4798
+ ctx.lineTo(frontRight.x, frontRight.y);
4799
+ ctx.lineTo(backRight.x, backRight.y);
4800
+ ctx.lineTo(backLeft.x, backLeft.y);
4801
+ ctx.closePath();
4802
+ fillAndStroke(ctx, fillColor, fillOpacity);
4803
+ }
4804
+ function fillAndStroke(ctx, fillColor, fillOpacity) {
4805
+ ctx.globalAlpha = fillOpacity;
4806
+ ctx.fillStyle = fillColor;
4807
+ ctx.fill();
4808
+ ctx.globalAlpha = 1;
4809
+ ctx.stroke();
4810
+ }
4811
+ function drawLegacyArrowHead(ctx, from, to) {
4812
+ const { left, right } = calculateArrowMarkerPoints(to, Math.atan2(to.y - from.y, to.x - from.x), 10);
4813
+ ctx.save();
4814
+ ctx.setLineDash([]);
4815
+ ctx.lineDashOffset = 0;
4816
+ ctx.beginPath();
4817
+ ctx.moveTo(to.x, to.y);
4818
+ ctx.lineTo(left.x, left.y);
4819
+ ctx.moveTo(to.x, to.y);
4820
+ ctx.lineTo(right.x, right.y);
4821
+ ctx.stroke();
4822
+ ctx.restore();
4823
+ }
4824
+ //#endregion
4825
+ //#region src/elements/edge/EdgeLabelRenderer.ts
4826
+ function renderEdgeLabel(ctx, options) {
4827
+ const { label, path, background } = options;
4828
+ if (!label || path.length < 2) return;
4829
+ const labelCenter = getEdgeLabelPosition(options);
4830
+ if (!labelCenter) return;
4831
+ const rotation = getEdgeLabelRotation(options);
4832
+ const labelOpacity = label.style.opacity ?? 1;
4833
+ label.measure(ctx);
4834
+ const labelWidth = label.measuredWidth;
4835
+ const labelHeight = label.measuredHeight;
4836
+ const backgroundColor = background?.color ?? "#ffffff";
4837
+ const backgroundOpacity = background?.opacity ?? 1;
4838
+ const backgroundRadius = background?.borderRadius ?? 2;
4839
+ ctx.save();
4840
+ if (rotation !== 0) {
4841
+ ctx.translate(labelCenter.x, labelCenter.y);
4842
+ ctx.rotate(rotation);
4843
+ ctx.translate(-labelCenter.x, -labelCenter.y);
4844
+ }
4845
+ const backgroundX = labelCenter.x - labelWidth / 2;
4846
+ const backgroundY = labelCenter.y - labelHeight / 2;
4847
+ ctx.fillStyle = backgroundColor;
4848
+ ctx.globalAlpha = backgroundOpacity;
4849
+ if (backgroundRadius > 0) {
4850
+ drawRoundedRectPath(ctx, backgroundX, backgroundY, labelWidth, labelHeight, backgroundRadius);
4851
+ ctx.fill();
4852
+ } else ctx.fillRect(backgroundX, backgroundY, labelWidth, labelHeight);
4853
+ ctx.globalAlpha = labelOpacity;
4854
+ label.renderAt(ctx, labelCenter);
4855
+ ctx.globalAlpha = 1;
4856
+ ctx.restore();
4857
+ }
4858
+ function getEdgeLabelPosition(options) {
4859
+ if (options.path.length < 2) return null;
4860
+ const { point, angle } = getPathPointAt(options.path, options.type, options.position);
4861
+ const perpendicularAngle = options.followPath ? angle + Math.PI / 2 : Math.PI / 2;
4862
+ return {
4863
+ x: point.x + options.offset * Math.cos(perpendicularAngle),
4864
+ y: point.y + options.offset * Math.sin(perpendicularAngle)
4865
+ };
4866
+ }
4867
+ function getEdgeLabelRotation(options) {
4868
+ if (!options.followPath || options.path.length < 2) return 0;
4869
+ let angle = getPathPointAt(options.path, options.type, options.position).angle;
4870
+ if (angle > Math.PI / 2) angle -= Math.PI;
4871
+ if (angle < -Math.PI / 2) angle += Math.PI;
4872
+ return angle;
4873
+ }
4874
+ //#endregion
4875
+ //#region src/elements/Edge.ts
4876
+ var DEFAULT_EDGE_STYLE = {
4877
+ strokeColor: "#666666",
4878
+ strokeWidth: 2,
4879
+ opacity: 1
4880
+ };
4881
+ /**
4882
+ * Connection edge between nodes
4883
+ */
4884
+ var Edge = class extends Element {
4885
+ constructor(options) {
4886
+ super({
4887
+ id: options.id ?? generateId("edge"),
4888
+ x: 0,
4889
+ y: 0,
4890
+ width: 0,
4891
+ height: 0,
4892
+ style: options.style,
4893
+ styleClass: options.styleClass
4894
+ });
4895
+ this._path = [];
4896
+ this._autoUpdateEndpoints = true;
4897
+ this._fromPoint = {
4898
+ x: 0,
4899
+ y: 0
4900
+ };
4901
+ this._toPoint = {
4902
+ x: 0,
4903
+ y: 0
4904
+ };
4905
+ this._from = options.from;
4906
+ this._to = options.to;
4907
+ this._type = options.type ?? "straight";
4908
+ this._arrowType = options.arrowType ?? "single";
4909
+ this._controlPoints = options.controlPoints;
4910
+ this._startMarker = options.startMarker;
4911
+ this._endMarker = options.endMarker;
4912
+ this._edgeStyle = {
4913
+ ...DEFAULT_EDGE_STYLE,
4914
+ ...options.style
4915
+ };
4916
+ this._pathStrategy = this.getPathStrategy(this._type);
4917
+ this._lockAnchors = options.lockAnchors ?? true;
4918
+ this._labelOffset = options.labelOffset ?? 0;
4919
+ this._labelPosition = options.labelPosition ?? .5;
4920
+ this._labelFollowPath = options.labelFollowPath ?? false;
4921
+ this._labelBackground = options.labelBackground;
4922
+ this._labelLineGap = options.labelLineGap ?? false;
4923
+ if (options.label !== void 0) if (typeof options.label === "string") this._label = new TextLabel({
4924
+ text: options.label,
4925
+ onChange: () => this.markDirty()
4926
+ });
4927
+ else this._label = new TextLabel({
4928
+ ...options.label,
4929
+ onChange: () => this.markDirty()
4930
+ });
4931
+ }
4932
+ /**
4933
+ * Source endpoint
4934
+ */
4935
+ get from() {
4936
+ return this._from;
4937
+ }
4938
+ set from(value) {
4939
+ this._from = value;
4940
+ this._bindingListener?.();
4941
+ this.markDirty();
4942
+ }
4943
+ /**
4944
+ * Target endpoint
4945
+ */
4946
+ get to() {
4947
+ return this._to;
4948
+ }
4949
+ set to(value) {
4950
+ this._to = value;
4951
+ this._bindingListener?.();
4952
+ this.markDirty();
4953
+ }
4954
+ /**
4955
+ * Notified when from/to binding changes (ports / outline params / node ids).
4956
+ * Used by DiagramRenderer to resync endpoints without doing so on every pan frame.
4957
+ */
4958
+ setBindingListener(listener) {
4959
+ this._bindingListener = listener;
4960
+ }
4961
+ /**
4962
+ * Edge type
4380
4963
  */
4381
4964
  get type() {
4382
4965
  return this._type;
@@ -4640,6 +5223,10 @@ var Edge = class extends Element {
4640
5223
  * @param toDir Direction at end point (top, right, bottom, left)
4641
5224
  */
4642
5225
  updateEndpoints(fromPoint, toPoint, fromDir, toDir, options) {
5226
+ if (this._path.length > 0 && this._fromPoint.x === fromPoint.x && this._fromPoint.y === fromPoint.y && this._toPoint.x === toPoint.x && this._toPoint.y === toPoint.y && this._fromDir === fromDir && this._toDir === toDir) {
5227
+ this._pathOptions = options;
5228
+ return;
5229
+ }
4643
5230
  this._fromPoint = fromPoint;
4644
5231
  this._toPoint = toPoint;
4645
5232
  this._fromDir = fromDir;
@@ -4737,8 +5324,8 @@ var Edge = class extends Element {
4737
5324
  renderPath(ctx) {
4738
5325
  const path = this._path;
4739
5326
  if (path.length < 2) return;
4740
- const startOffset = this._startMarker && this._startMarker.type !== "none" ? this.getMarkerLength(this._startMarker) : 0;
4741
- const endOffset = this._endMarker && this._endMarker.type !== "none" ? this.getMarkerLength(this._endMarker) : 0;
5327
+ const startOffset = this._startMarker && this._startMarker.type !== "none" ? getCanvasMarkerLength(this._startMarker) : 0;
5328
+ const endOffset = this._endMarker && this._endMarker.type !== "none" ? getCanvasMarkerLength(this._endMarker) : 0;
4742
5329
  const buildPolyline = () => {
4743
5330
  if (this._type === "bezier" && path.length >= 4) {
4744
5331
  const samples = [];
@@ -4920,363 +5507,53 @@ var Edge = class extends Element {
4920
5507
  ctx.stroke();
4921
5508
  }
4922
5509
  renderArrows(ctx) {
4923
- if (this._path.length < 2) return;
4924
- if (this._startMarker !== void 0 || this._endMarker !== void 0) {
4925
- if (this._endMarker && this._endMarker.type !== "none") {
4926
- const points = this.getMarkerPoints("end");
4927
- if (points) this.drawMarker(ctx, points.from, points.to, this._endMarker);
4928
- }
4929
- if (this._startMarker && this._startMarker.type !== "none") {
4930
- const points = this.getMarkerPoints("start");
4931
- if (points) this.drawMarker(ctx, points.from, points.to, this._startMarker);
4932
- }
4933
- } else {
4934
- if (this._arrowType === "none") return;
4935
- const endPoints = this.getMarkerPoints("end");
4936
- if (endPoints) this.drawArrowHead(ctx, endPoints.from, endPoints.to);
4937
- if (this._arrowType === "double") {
4938
- const startPoints = this.getMarkerPoints("start");
4939
- if (startPoints) this.drawArrowHead(ctx, startPoints.from, startPoints.to);
4940
- }
4941
- }
5510
+ renderEdgeMarkers(ctx, {
5511
+ path: this._path,
5512
+ type: this._type,
5513
+ arrowType: this._arrowType,
5514
+ startMarker: this._startMarker,
5515
+ endMarker: this._endMarker
5516
+ });
4942
5517
  }
4943
- getMarkerPoints(position) {
4944
- const path = this._path;
4945
- if (path.length < 2) return null;
4946
- if (this._type === "bezier" && path.length >= 4) {
4947
- const start = path[0];
4948
- const cp1 = path[1];
4949
- const cp2 = path[2];
4950
- const end = path[3];
4951
- const epsilon = .001;
4952
- const isSame = (a, b) => Math.abs(a.x - b.x) < epsilon && Math.abs(a.y - b.y) < epsilon;
4953
- if (position === "end") {
4954
- const endIndex = path.length - 1;
4955
- const endPoint = path[endIndex];
4956
- let from = path[endIndex - 1] ?? cp2;
4957
- if (isSame(from, endPoint)) {
4958
- from = path[endIndex - 2] ?? cp1;
4959
- if (isSame(from, endPoint)) from = start;
4960
- }
4961
- return {
4962
- from,
4963
- to: endPoint
4964
- };
4965
- }
4966
- let next = cp1;
4967
- if (isSame(next, start)) {
4968
- next = cp2;
4969
- if (isSame(next, start)) next = end;
4970
- }
4971
- return {
4972
- from: next,
4973
- to: start
4974
- };
4975
- }
4976
- if (position === "end") return {
4977
- from: path[path.length - 2],
4978
- to: path[path.length - 1]
4979
- };
4980
- return {
4981
- from: path[1],
4982
- to: path[0]
4983
- };
5518
+ renderLabel(ctx) {
5519
+ renderEdgeLabel(ctx, {
5520
+ ...this.getLabelLayoutOptions(),
5521
+ label: this._label,
5522
+ background: this._labelBackground
5523
+ });
4984
5524
  }
4985
5525
  /**
4986
- * Get the length of a marker (how much to shorten the line)
5526
+ * Point and tangent on the current path at a normalized length position (0..1).
4987
5527
  */
4988
- getMarkerLength(config) {
4989
- const size = config.size ?? MARKER_SIZES[config.type] ?? 10;
4990
- switch (config.type) {
4991
- case "arrow": return size * Math.cos(ARROW_ANGLE);
4992
- case "open": return 0;
4993
- case "diamond": return size;
4994
- case "circle": return size * 2;
4995
- case "square": return size * 2;
4996
- default: return 0;
4997
- }
4998
- }
4999
- drawMarker(ctx, from, to, config) {
5000
- const angle = Math.atan2(to.y - from.y, to.x - from.x);
5001
- const size = config.size ?? MARKER_SIZES[config.type] ?? 10;
5002
- const strokeColor = config.strokeColor ?? ctx.strokeStyle;
5003
- const fillColor = config.fillColor ?? strokeColor;
5004
- const fillOpacity = config.fillOpacity ?? 1;
5005
- ctx.save();
5006
- ctx.setLineDash([]);
5007
- ctx.lineDashOffset = 0;
5008
- ctx.strokeStyle = strokeColor;
5009
- switch (config.type) {
5010
- case "arrow":
5011
- this.drawArrowMarker(ctx, to, angle, size, fillColor, fillOpacity);
5012
- break;
5013
- case "open":
5014
- this.drawOpenArrowMarker(ctx, to, angle, size);
5015
- break;
5016
- case "diamond":
5017
- this.drawDiamondMarker(ctx, to, angle, size, fillColor, fillOpacity);
5018
- break;
5019
- case "circle":
5020
- this.drawCircleMarker(ctx, to, angle, size, fillColor, fillOpacity);
5021
- break;
5022
- case "square":
5023
- this.drawSquareMarker(ctx, to, angle, size, fillColor, fillOpacity);
5024
- break;
5025
- }
5026
- ctx.restore();
5027
- }
5028
- drawArrowMarker(ctx, to, angle, size, fillColor, fillOpacity) {
5029
- const x1 = to.x - size * Math.cos(angle - ARROW_ANGLE);
5030
- const y1 = to.y - size * Math.sin(angle - ARROW_ANGLE);
5031
- const x2 = to.x - size * Math.cos(angle + ARROW_ANGLE);
5032
- const y2 = to.y - size * Math.sin(angle + ARROW_ANGLE);
5033
- ctx.beginPath();
5034
- ctx.moveTo(to.x, to.y);
5035
- ctx.lineTo(x1, y1);
5036
- ctx.lineTo(x2, y2);
5037
- ctx.closePath();
5038
- ctx.globalAlpha = fillOpacity;
5039
- ctx.fillStyle = fillColor;
5040
- ctx.fill();
5041
- ctx.globalAlpha = 1;
5042
- ctx.stroke();
5043
- }
5044
- drawOpenArrowMarker(ctx, to, angle, size) {
5045
- const x1 = to.x - size * Math.cos(angle - ARROW_ANGLE);
5046
- const y1 = to.y - size * Math.sin(angle - ARROW_ANGLE);
5047
- const x2 = to.x - size * Math.cos(angle + ARROW_ANGLE);
5048
- const y2 = to.y - size * Math.sin(angle + ARROW_ANGLE);
5049
- ctx.beginPath();
5050
- ctx.moveTo(to.x, to.y);
5051
- ctx.lineTo(x1, y1);
5052
- ctx.moveTo(to.x, to.y);
5053
- ctx.lineTo(x2, y2);
5054
- ctx.stroke();
5055
- }
5056
- drawDiamondMarker(ctx, to, angle, size, fillColor, fillOpacity) {
5057
- const halfLength = size / 2;
5058
- const halfWidth = size * .3;
5059
- const cos = Math.cos(angle);
5060
- const sin = Math.sin(angle);
5061
- const points = [
5062
- {
5063
- x: to.x,
5064
- y: to.y
5065
- },
5066
- {
5067
- x: to.x - halfLength * cos + halfWidth * sin,
5068
- y: to.y - halfLength * sin - halfWidth * cos
5069
- },
5070
- {
5071
- x: to.x - size * cos,
5072
- y: to.y - size * sin
5073
- },
5074
- {
5075
- x: to.x - halfLength * cos - halfWidth * sin,
5076
- y: to.y - halfLength * sin + halfWidth * cos
5077
- }
5078
- ];
5079
- ctx.beginPath();
5080
- ctx.moveTo(points[0].x, points[0].y);
5081
- for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
5082
- ctx.closePath();
5083
- ctx.globalAlpha = fillOpacity;
5084
- ctx.fillStyle = fillColor;
5085
- ctx.fill();
5086
- ctx.globalAlpha = 1;
5087
- ctx.stroke();
5088
- }
5089
- drawCircleMarker(ctx, to, angle, size, fillColor, fillOpacity) {
5090
- const cx = to.x - size * Math.cos(angle);
5091
- const cy = to.y - size * Math.sin(angle);
5092
- ctx.beginPath();
5093
- ctx.arc(cx, cy, size, 0, Math.PI * 2);
5094
- ctx.globalAlpha = fillOpacity;
5095
- ctx.fillStyle = fillColor;
5096
- ctx.fill();
5097
- ctx.globalAlpha = 1;
5098
- ctx.stroke();
5099
- }
5100
- drawSquareMarker(ctx, to, angle, size, fillColor, fillOpacity) {
5101
- const cos = Math.cos(angle);
5102
- const sin = Math.sin(angle);
5103
- const px = -sin;
5104
- const py = cos;
5105
- const p1 = {
5106
- x: to.x + size * px,
5107
- y: to.y + size * py
5108
- };
5109
- const p2 = {
5110
- x: to.x - size * px,
5111
- y: to.y - size * py
5112
- };
5113
- const bx = 2 * size * cos;
5114
- const by = 2 * size * sin;
5115
- const p3 = {
5116
- x: p2.x - bx,
5117
- y: p2.y - by
5118
- };
5119
- const p4 = {
5120
- x: p1.x - bx,
5121
- y: p1.y - by
5122
- };
5123
- ctx.beginPath();
5124
- ctx.moveTo(p1.x, p1.y);
5125
- ctx.lineTo(p2.x, p2.y);
5126
- ctx.lineTo(p3.x, p3.y);
5127
- ctx.lineTo(p4.x, p4.y);
5128
- ctx.closePath();
5129
- ctx.globalAlpha = fillOpacity;
5130
- ctx.fillStyle = fillColor;
5131
- ctx.fill();
5132
- ctx.globalAlpha = 1;
5133
- ctx.stroke();
5134
- }
5135
- drawArrowHead(ctx, from, to) {
5136
- const angle = Math.atan2(to.y - from.y, to.x - from.x);
5137
- ctx.save();
5138
- ctx.setLineDash([]);
5139
- ctx.lineDashOffset = 0;
5140
- ctx.beginPath();
5141
- ctx.moveTo(to.x, to.y);
5142
- ctx.lineTo(to.x - 10 * Math.cos(angle - ARROW_ANGLE), to.y - 10 * Math.sin(angle - ARROW_ANGLE));
5143
- ctx.moveTo(to.x, to.y);
5144
- ctx.lineTo(to.x - 10 * Math.cos(angle + ARROW_ANGLE), to.y - 10 * Math.sin(angle + ARROW_ANGLE));
5145
- ctx.stroke();
5146
- ctx.restore();
5147
- }
5148
- renderLabel(ctx) {
5149
- if (this._label === void 0 || this._path.length < 2) return;
5150
- const { point, angle: pathAngle } = this.getPathPointAt(this._labelPosition);
5151
- const rotation = this.getLabelRotation();
5152
- const perpAngle = this._labelFollowPath ? pathAngle + Math.PI / 2 : Math.PI / 2;
5153
- const labelCenter = {
5154
- x: point.x + this._labelOffset * Math.cos(perpAngle),
5155
- y: point.y + this._labelOffset * Math.sin(perpAngle)
5156
- };
5157
- const labelOpacity = this._label.style.opacity ?? 1;
5158
- this._label.measure(ctx);
5159
- const labelWidth = this._label.measuredWidth;
5160
- const labelHeight = this._label.measuredHeight;
5161
- const bgColor = this._labelBackground?.color ?? "#ffffff";
5162
- const bgOpacity = this._labelBackground?.opacity ?? 1;
5163
- const bgRadius = this._labelBackground?.borderRadius ?? 2;
5164
- ctx.save();
5165
- if (rotation !== 0) {
5166
- ctx.translate(labelCenter.x, labelCenter.y);
5167
- ctx.rotate(rotation);
5168
- ctx.translate(-labelCenter.x, -labelCenter.y);
5169
- }
5170
- const bgX = labelCenter.x - labelWidth / 2;
5171
- const bgY = labelCenter.y - labelHeight / 2;
5172
- ctx.fillStyle = bgColor;
5173
- ctx.globalAlpha = bgOpacity;
5174
- if (bgRadius > 0) {
5175
- this.drawRoundedRect(ctx, bgX, bgY, labelWidth, labelHeight, bgRadius);
5176
- ctx.fill();
5177
- } else ctx.fillRect(bgX, bgY, labelWidth, labelHeight);
5178
- ctx.globalAlpha = labelOpacity;
5179
- this._label.renderAt(ctx, labelCenter);
5180
- ctx.globalAlpha = 1;
5181
- ctx.restore();
5182
- }
5183
- drawRoundedRect(ctx, x, y, width, height, radius) {
5184
- drawRoundedRectPath(ctx, x, y, width, height, radius);
5528
+ getPointAt(pathParam = .5) {
5529
+ if (this._path.length < 2) return null;
5530
+ return getPathPointAt(this._path, this._type, pathParam);
5185
5531
  }
5186
5532
  /**
5187
- * Get a point along the sampled path at parameter t (0..1).
5188
- * Also returns the tangent angle in radians at that point.
5533
+ * Nearest point on this edge's path to `point` (world coordinates).
5189
5534
  */
5190
- getPathPointAt(t) {
5191
- const path = this._path;
5192
- let samples;
5193
- if (this._type === "bezier" && path.length >= 4) {
5194
- samples = [];
5195
- const steps = 20;
5196
- for (let i = 1; i + 2 < path.length; i += 3) {
5197
- const p0 = path[i - 1];
5198
- const p1 = path[i];
5199
- const p2 = path[i + 1];
5200
- const p3 = path[i + 2];
5201
- for (let s = 0; s <= steps; s++) {
5202
- const st = s / steps;
5203
- if (samples.length > 0 && st === 0) continue;
5204
- samples.push(bezierPoint(p0, p1, p2, p3, st));
5205
- }
5206
- }
5207
- } else samples = path;
5208
- return this.getPointAlongPolyline(samples, t);
5535
+ getClosestPointOnPath(point) {
5536
+ return getClosestPointOnPath(this._path, this._type, point);
5209
5537
  }
5210
5538
  /**
5211
5539
  * Get world position of label center along path.
5212
5540
  */
5213
5541
  getLabelPosition() {
5214
- if (this._path.length < 2) return null;
5215
- const { point, angle: pathAngle } = this.getPathPointAt(this._labelPosition);
5216
- const perpAngle = this._labelFollowPath ? pathAngle + Math.PI / 2 : Math.PI / 2;
5217
- return {
5218
- x: point.x + this._labelOffset * Math.cos(perpAngle),
5219
- y: point.y + this._labelOffset * Math.sin(perpAngle)
5220
- };
5542
+ return getEdgeLabelPosition(this.getLabelLayoutOptions());
5221
5543
  }
5222
5544
  /**
5223
5545
  * Get label rotation angle in radians (if labelFollowPath is true).
5224
5546
  */
5225
5547
  getLabelRotation() {
5226
- if (!this._labelFollowPath || this._path.length < 2) return 0;
5227
- const { angle } = this.getPathPointAt(this._labelPosition);
5228
- let a = angle;
5229
- if (a > Math.PI / 2) a -= Math.PI;
5230
- if (a < -Math.PI / 2) a += Math.PI;
5231
- return a;
5548
+ return getEdgeLabelRotation(this.getLabelLayoutOptions());
5232
5549
  }
5233
- /**
5234
- * Get point and tangent angle at parameter t along a polyline.
5235
- */
5236
- getPointAlongPolyline(path, t) {
5237
- if (path.length === 0) return {
5238
- point: {
5239
- x: 0,
5240
- y: 0
5241
- },
5242
- angle: 0
5243
- };
5244
- if (path.length === 1) return {
5245
- point: path[0],
5246
- angle: 0
5247
- };
5248
- let totalLength = 0;
5249
- const segments = [];
5250
- for (let i = 1; i < path.length; i++) {
5251
- const start = path[i - 1];
5252
- const end = path[i];
5253
- const length = Math.sqrt((end.x - start.x) ** 2 + (end.y - start.y) ** 2);
5254
- segments.push({
5255
- start,
5256
- end,
5257
- length
5258
- });
5259
- totalLength += length;
5260
- }
5261
- const targetLength = totalLength * Math.max(0, Math.min(1, t));
5262
- let accumulated = 0;
5263
- for (const seg of segments) {
5264
- if (accumulated + seg.length >= targetLength) {
5265
- const segT = seg.length > 0 ? (targetLength - accumulated) / seg.length : 0;
5266
- return {
5267
- point: {
5268
- x: seg.start.x + segT * (seg.end.x - seg.start.x),
5269
- y: seg.start.y + segT * (seg.end.y - seg.start.y)
5270
- },
5271
- angle: Math.atan2(seg.end.y - seg.start.y, seg.end.x - seg.start.x)
5272
- };
5273
- }
5274
- accumulated += seg.length;
5275
- }
5276
- const lastSeg = segments[segments.length - 1];
5550
+ getLabelLayoutOptions() {
5277
5551
  return {
5278
- point: lastSeg.end,
5279
- angle: Math.atan2(lastSeg.end.y - lastSeg.start.y, lastSeg.end.x - lastSeg.start.x)
5552
+ path: this._path,
5553
+ type: this._type,
5554
+ position: this._labelPosition,
5555
+ offset: this._labelOffset,
5556
+ followPath: this._labelFollowPath
5280
5557
  };
5281
5558
  }
5282
5559
  getPathStrategy(type) {
@@ -5412,6 +5689,179 @@ var LabelEditor = class {
5412
5689
  }
5413
5690
  };
5414
5691
  //#endregion
5692
+ //#region src/core/ClipboardManager.ts
5693
+ var ClipboardManager = class {
5694
+ constructor(options) {
5695
+ this.clipboard = null;
5696
+ this.renderer = options.renderer;
5697
+ this.selectionManager = options.selectionManager;
5698
+ this.historyManager = options.historyManager;
5699
+ this.nodeFactory = options.nodeFactory;
5700
+ this.edgeFactory = options.edgeFactory;
5701
+ }
5702
+ copySelection() {
5703
+ const selectedIds = new Set(this.selectionManager.selectedIds);
5704
+ if (selectedIds.size === 0) return;
5705
+ const nodes = [];
5706
+ const edges = [];
5707
+ const nodeIds = /* @__PURE__ */ new Set();
5708
+ for (const id of selectedIds) {
5709
+ const node = this.renderer.getNode(id);
5710
+ if (node) {
5711
+ nodes.push({
5712
+ id: node.id,
5713
+ type: node.typeName,
5714
+ x: node.x,
5715
+ y: node.y,
5716
+ width: node.width,
5717
+ height: node.height,
5718
+ style: node.style,
5719
+ styleClass: node.styleClass,
5720
+ label: node.label?.text,
5721
+ labelStyleClass: node.label?.styleClass,
5722
+ ports: node.ports.map((port) => ({
5723
+ id: port.id,
5724
+ type: port.type,
5725
+ position: port.position,
5726
+ styleClass: port.styleClass
5727
+ })),
5728
+ data: Object.keys(node.data).length > 0 ? node.data : void 0
5729
+ });
5730
+ nodeIds.add(node.id);
5731
+ }
5732
+ }
5733
+ for (const edge of this.renderer.edges.values()) {
5734
+ const fromId = edge.from.nodeId;
5735
+ const toId = edge.to.nodeId;
5736
+ if (!fromId || !toId || !nodeIds.has(fromId) || !nodeIds.has(toId)) continue;
5737
+ edges.push({
5738
+ id: edge.id,
5739
+ from: edge.from,
5740
+ to: edge.to,
5741
+ type: edge.type,
5742
+ controlPoints: edge.controlPoints,
5743
+ arrowType: edge.arrowType,
5744
+ style: edge.style,
5745
+ styleClass: edge.styleClass,
5746
+ label: edge.label?.text,
5747
+ labelStyleClass: edge.label?.styleClass,
5748
+ labelOffset: edge.labelOffset !== 0 ? edge.labelOffset : void 0,
5749
+ labelBackground: edge.labelBackground,
5750
+ labelLineGap: edge.labelLineGap ? true : void 0,
5751
+ data: Object.keys(edge.data).length > 0 ? edge.data : void 0
5752
+ });
5753
+ }
5754
+ this.clipboard = {
5755
+ nodes,
5756
+ edges
5757
+ };
5758
+ }
5759
+ pasteSelection() {
5760
+ if (!this.clipboard || !this.nodeFactory || !this.edgeFactory) return;
5761
+ const offset = {
5762
+ x: 20,
5763
+ y: 20
5764
+ };
5765
+ const idMap = /* @__PURE__ */ new Map();
5766
+ const newNodes = [];
5767
+ const newEdges = [];
5768
+ for (const nodeData of this.clipboard.nodes) {
5769
+ const newId = `${nodeData.id}_copy_${Date.now()}`;
5770
+ idMap.set(nodeData.id, newId);
5771
+ const copyData = {
5772
+ ...nodeData,
5773
+ id: newId,
5774
+ x: nodeData.x + offset.x,
5775
+ y: nodeData.y + offset.y
5776
+ };
5777
+ newNodes.push(this.nodeFactory(copyData));
5778
+ }
5779
+ for (const edgeData of this.clipboard.edges) {
5780
+ const fromKey = edgeData.from.nodeId;
5781
+ const toKey = edgeData.to.nodeId;
5782
+ if (!fromKey || !toKey) continue;
5783
+ const fromNodeId = idMap.get(fromKey);
5784
+ const toNodeId = idMap.get(toKey);
5785
+ if (!fromNodeId || !toNodeId) continue;
5786
+ const newId = `${edgeData.id}_copy_${Date.now()}`;
5787
+ const copyEdge = {
5788
+ ...edgeData,
5789
+ id: newId,
5790
+ from: {
5791
+ ...edgeData.from,
5792
+ nodeId: fromNodeId
5793
+ },
5794
+ to: {
5795
+ ...edgeData.to,
5796
+ nodeId: toNodeId
5797
+ }
5798
+ };
5799
+ newEdges.push(this.edgeFactory(copyEdge));
5800
+ }
5801
+ if (newNodes.length === 0 && newEdges.length === 0) return;
5802
+ this.historyManager.execute({
5803
+ execute: () => {
5804
+ for (const node of newNodes) this.renderer.addNode(node);
5805
+ for (const edge of newEdges) this.renderer.addEdge(edge);
5806
+ },
5807
+ undo: () => {
5808
+ for (const edge of newEdges) this.renderer.removeEdge(edge.id);
5809
+ for (const node of newNodes) this.renderer.removeNode(node.id);
5810
+ }
5811
+ });
5812
+ }
5813
+ };
5814
+ //#endregion
5815
+ //#region src/core/PropertyChangeBatcher.ts
5816
+ var PropertyChangeBatcher = class {
5817
+ constructor(options) {
5818
+ this.pendingChanges = /* @__PURE__ */ new Map();
5819
+ this.renderer = options.renderer;
5820
+ this.historyManager = options.historyManager;
5821
+ this.debounceMs = options.debounceMs ?? 350;
5822
+ }
5823
+ queue(kind, id, before, after) {
5824
+ const key = `${kind}:${id}`;
5825
+ const existing = this.pendingChanges.get(key);
5826
+ if (existing) {
5827
+ existing.after = after;
5828
+ window.clearTimeout(existing.timerId);
5829
+ existing.timerId = window.setTimeout(() => this.flushOne(key), this.debounceMs);
5830
+ return;
5831
+ }
5832
+ const timerId = window.setTimeout(() => this.flushOne(key), this.debounceMs);
5833
+ this.pendingChanges.set(key, {
5834
+ kind,
5835
+ id,
5836
+ before,
5837
+ after,
5838
+ timerId
5839
+ });
5840
+ }
5841
+ flush() {
5842
+ for (const key of Array.from(this.pendingChanges.keys())) this.flushOne(key);
5843
+ }
5844
+ flushOne(key) {
5845
+ const pending = this.pendingChanges.get(key);
5846
+ if (!pending) return;
5847
+ window.clearTimeout(pending.timerId);
5848
+ this.pendingChanges.delete(key);
5849
+ if (shallowEqual(pending.before, pending.after)) return;
5850
+ switch (pending.kind) {
5851
+ case "node":
5852
+ this.historyManager.execute(new ChangeNodePropertiesCommand((id) => this.renderer.getNode(id), pending.id, pending.before, pending.after));
5853
+ break;
5854
+ case "edge":
5855
+ this.historyManager.execute(new ChangeEdgePropertiesCommand((id) => this.renderer.getEdge(id), pending.id, pending.before, pending.after));
5856
+ break;
5857
+ case "group":
5858
+ this.historyManager.execute(new ChangeGroupPropertiesCommand((id) => this.renderer.getGroup(id), pending.id, pending.before, pending.after));
5859
+ break;
5860
+ }
5861
+ this.renderer.markStyleDirty();
5862
+ }
5863
+ };
5864
+ //#endregion
5415
5865
  //#region src/core/InteractionManager.ts
5416
5866
  var DEFAULT_KEYMAP = {
5417
5867
  deleteKeys: ["Delete", "Backspace"],
@@ -5426,9 +5876,6 @@ var InteractionManager = class {
5426
5876
  this.dragStartPositions = /* @__PURE__ */ new Map();
5427
5877
  this.dragStartEditablePolylinePoints = /* @__PURE__ */ new Map();
5428
5878
  this.reconnectOrigins = /* @__PURE__ */ new Map();
5429
- this.clipboard = null;
5430
- this.pendingPropertyChanges = /* @__PURE__ */ new Map();
5431
- this.propertyChangeDebounceMs = 350;
5432
5879
  this.labelEditor = new LabelEditor();
5433
5880
  this.scrollbarDragState = null;
5434
5881
  this.handledScrollbarMouseDown = false;
@@ -5457,6 +5904,17 @@ var InteractionManager = class {
5457
5904
  });
5458
5905
  this.navigationManager = new NavigationManager({ renderer: this.renderer });
5459
5906
  this.historyManager = new HistoryManager();
5907
+ this.clipboardManager = new ClipboardManager({
5908
+ renderer: this.renderer,
5909
+ selectionManager: this.selectionManager,
5910
+ historyManager: this.historyManager,
5911
+ nodeFactory: options.nodeFactory,
5912
+ edgeFactory: options.edgeFactory
5913
+ });
5914
+ this.propertyChangeBatcher = new PropertyChangeBatcher({
5915
+ renderer: this.renderer,
5916
+ historyManager: this.historyManager
5917
+ });
5460
5918
  this.keymap = {
5461
5919
  ...DEFAULT_KEYMAP,
5462
5920
  ...options.keymap
@@ -5484,7 +5942,7 @@ var InteractionManager = class {
5484
5942
  });
5485
5943
  }
5486
5944
  });
5487
- this.setupEvents(options);
5945
+ this.setupEvents();
5488
5946
  }
5489
5947
  get selection() {
5490
5948
  return this.selectionManager;
@@ -5525,7 +5983,9 @@ var InteractionManager = class {
5525
5983
  const idSet = new Set(nodeIds);
5526
5984
  for (const edge of this.renderer.edges.values()) {
5527
5985
  if (!edge.hasEditableControlPoints()) continue;
5528
- if (!idSet.has(edge.from.nodeId) && !idSet.has(edge.to.nodeId)) continue;
5986
+ const fromId = edge.from.nodeId;
5987
+ const toId = edge.to.nodeId;
5988
+ if ((!fromId || !idSet.has(fromId)) && (!toId || !idSet.has(toId))) continue;
5529
5989
  if (this.dragStartEditablePolylinePoints.has(edge.id)) continue;
5530
5990
  this.dragStartEditablePolylinePoints.set(edge.id, clonePoints(edge.controlPoints));
5531
5991
  }
@@ -5547,7 +6007,7 @@ var InteractionManager = class {
5547
6007
  const after = createNodeSnapshot(node);
5548
6008
  if (shallowEqual(before, after)) return;
5549
6009
  this.renderer.markStyleDirty();
5550
- this.queuePropertyChange("node", nodeId, before, after);
6010
+ this.propertyChangeBatcher.queue("node", nodeId, before, after);
5551
6011
  }
5552
6012
  changeEdgeProperties(edgeId, apply) {
5553
6013
  const edge = this.renderer.getEdge(edgeId);
@@ -5557,7 +6017,7 @@ var InteractionManager = class {
5557
6017
  const after = createEdgeSnapshot(edge);
5558
6018
  if (shallowEqual(before, after)) return;
5559
6019
  this.renderer.markStyleDirty();
5560
- this.queuePropertyChange("edge", edgeId, before, after);
6020
+ this.propertyChangeBatcher.queue("edge", edgeId, before, after);
5561
6021
  }
5562
6022
  changeGroupProperties(groupId, apply) {
5563
6023
  const group = this.renderer.getGroup(groupId);
@@ -5567,7 +6027,7 @@ var InteractionManager = class {
5567
6027
  const after = createGroupSnapshot(group);
5568
6028
  if (shallowEqual(before, after)) return;
5569
6029
  this.renderer.markStyleDirty();
5570
- this.queuePropertyChange("group", groupId, before, after);
6030
+ this.propertyChangeBatcher.queue("group", groupId, before, after);
5571
6031
  }
5572
6032
  removeNodeFromGroups(nodeId, groupIds) {
5573
6033
  if (!this.renderer.getNode(nodeId)) return;
@@ -5601,7 +6061,7 @@ var InteractionManager = class {
5601
6061
  this.overlayCleanup?.();
5602
6062
  this.overlayCleanup = null;
5603
6063
  }
5604
- setupEvents(options) {
6064
+ setupEvents() {
5605
6065
  this.overlayCleanup = this.renderer.addOverlayRenderer((ctx) => {
5606
6066
  this.selectionManager.renderSelectionRect(ctx);
5607
6067
  if (!this.navigationOnly) {
@@ -5622,7 +6082,7 @@ var InteractionManager = class {
5622
6082
  this.inputHandler.on("wheel", (event) => this.handleWheel(event));
5623
6083
  this.inputHandler.on("pan", (event) => this.handlePan(event));
5624
6084
  this.inputHandler.on("pinch", (event) => this.handlePinch(event));
5625
- this.inputHandler.on("keydown", (event) => this.handleKeyDown(event, options));
6085
+ this.inputHandler.on("keydown", (event) => this.handleKeyDown(event));
5626
6086
  this.inputHandler.on("keyup", (event) => this.handleKeyUp(event));
5627
6087
  if (!this.navigationOnly) {
5628
6088
  this.dragManager.on("dragstart", (nodeIds) => {
@@ -5852,8 +6312,7 @@ var InteractionManager = class {
5852
6312
  const component = node.getComponentAtPoint(point);
5853
6313
  if (!component) return;
5854
6314
  if (component.id !== void 0) this.renderer.emit("componentClick", node.id, component, point);
5855
- const asAny = component;
5856
- if (typeof asAny["onClick"] === "function") asAny["onClick"](component);
6315
+ component.onClick?.(component);
5857
6316
  }
5858
6317
  handleDoubleClick(event) {
5859
6318
  if (this.renderer.blocksDiagramPointerAtScreen(event.screenX, event.screenY)) return;
@@ -5919,13 +6378,13 @@ var InteractionManager = class {
5919
6378
  if (this.renderer.blocksDiagramPointerAtScreen(event.screenX, event.screenY)) return;
5920
6379
  this.navigationManager.handlePinch(event);
5921
6380
  }
5922
- handleKeyDown(event, options) {
6381
+ handleKeyDown(event) {
5923
6382
  const isCtrlOrMeta = event.ctrlKey || event.metaKey;
5924
- const key = event.code.startsWith("Key") ? event.code.slice(3).toLowerCase() : event.key.toLowerCase();
6383
+ const key = getShortcutKey(event);
5925
6384
  this.navigationManager.handleKeyDown(event);
5926
6385
  if (this.handleViewportNavigationKey(event)) return;
5927
6386
  if (this.navigationOnly) return;
5928
- if (isCtrlOrMeta && (key === "z" || key === "y")) this.flushPendingPropertyChanges();
6387
+ if (getHistoryShortcut(event) !== null) this.propertyChangeBatcher.flush();
5929
6388
  if (this.historyManager.handleKeyDown(event)) return;
5930
6389
  if (this.keymap.deleteKeys.includes(event.key)) {
5931
6390
  event.preventDefault();
@@ -5934,12 +6393,12 @@ var InteractionManager = class {
5934
6393
  }
5935
6394
  if (isCtrlOrMeta && key === this.keymap.copyKey) {
5936
6395
  event.preventDefault();
5937
- this.copySelection();
6396
+ this.clipboardManager.copySelection();
5938
6397
  return;
5939
6398
  }
5940
6399
  if (isCtrlOrMeta && key === this.keymap.pasteKey) {
5941
6400
  event.preventDefault();
5942
- this.pasteSelection(options);
6401
+ this.clipboardManager.pasteSelection();
5943
6402
  return;
5944
6403
  }
5945
6404
  }
@@ -6044,49 +6503,6 @@ var InteractionManager = class {
6044
6503
  edge.label.text = value;
6045
6504
  });
6046
6505
  }
6047
- queuePropertyChange(kind, id, before, after) {
6048
- const key = `${kind}:${id}`;
6049
- const existing = this.pendingPropertyChanges.get(key);
6050
- if (existing) {
6051
- existing.after = after;
6052
- window.clearTimeout(existing.timerId);
6053
- existing.timerId = window.setTimeout(() => this.flushPendingPropertyChange(key), this.propertyChangeDebounceMs);
6054
- return;
6055
- }
6056
- const timerId = window.setTimeout(() => this.flushPendingPropertyChange(key), this.propertyChangeDebounceMs);
6057
- this.pendingPropertyChanges.set(key, {
6058
- kind,
6059
- id,
6060
- before,
6061
- after,
6062
- timerId
6063
- });
6064
- }
6065
- flushPendingPropertyChanges() {
6066
- const keys = Array.from(this.pendingPropertyChanges.keys());
6067
- for (const key of keys) this.flushPendingPropertyChange(key);
6068
- }
6069
- flushPendingPropertyChange(key) {
6070
- const pending = this.pendingPropertyChanges.get(key);
6071
- if (!pending) return;
6072
- window.clearTimeout(pending.timerId);
6073
- this.pendingPropertyChanges.delete(key);
6074
- if (shallowEqual(pending.before, pending.after)) return;
6075
- switch (pending.kind) {
6076
- case "node":
6077
- this.historyManager.execute(new ChangeNodePropertiesCommand((id) => this.renderer.getNode(id), pending.id, pending.before, pending.after));
6078
- this.renderer.markStyleDirty();
6079
- break;
6080
- case "edge":
6081
- this.historyManager.execute(new ChangeEdgePropertiesCommand((id) => this.renderer.getEdge(id), pending.id, pending.before, pending.after));
6082
- this.renderer.markStyleDirty();
6083
- break;
6084
- case "group":
6085
- this.historyManager.execute(new ChangeGroupPropertiesCommand((id) => this.renderer.getGroup(id), pending.id, pending.before, pending.after));
6086
- this.renderer.markStyleDirty();
6087
- break;
6088
- }
6089
- }
6090
6506
  deleteSelection() {
6091
6507
  const selectedIds = Array.from(this.selectionManager.selectedIds);
6092
6508
  this.deleteByIds(selectedIds);
@@ -6117,10 +6533,14 @@ var InteractionManager = class {
6117
6533
  nodeGroupIds.set(child.id, list);
6118
6534
  }
6119
6535
  }
6120
- for (const edge of this.renderer.edges.values()) if (nodeIds.has(edge.from.nodeId) || nodeIds.has(edge.to.nodeId)) {
6121
- if (!edgeIds.has(edge.id)) {
6122
- edgeIds.add(edge.id);
6123
- edges.push(edge);
6536
+ for (const edge of this.renderer.edges.values()) {
6537
+ const fromId = edge.from.nodeId;
6538
+ const toId = edge.to.nodeId;
6539
+ if (fromId && nodeIds.has(fromId) || toId && nodeIds.has(toId)) {
6540
+ if (!edgeIds.has(edge.id)) {
6541
+ edgeIds.add(edge.id);
6542
+ edges.push(edge);
6543
+ }
6124
6544
  }
6125
6545
  }
6126
6546
  return new CompositeCommand([{
@@ -6142,112 +6562,16 @@ var InteractionManager = class {
6142
6562
  }
6143
6563
  }]);
6144
6564
  }
6145
- copySelection() {
6146
- const selectedIds = new Set(this.selectionManager.selectedIds);
6147
- if (selectedIds.size === 0) return;
6148
- const nodes = [];
6149
- const edges = [];
6150
- const nodeIds = /* @__PURE__ */ new Set();
6151
- for (const id of selectedIds) {
6152
- const node = this.renderer.getNode(id);
6153
- if (node) {
6154
- nodes.push({
6155
- id: node.id,
6156
- type: node.typeName,
6157
- x: node.x,
6158
- y: node.y,
6159
- width: node.width,
6160
- height: node.height,
6161
- style: node.style,
6162
- styleClass: node.styleClass,
6163
- label: node.label?.text,
6164
- labelStyleClass: node.label?.styleClass,
6165
- ports: node.ports.map((port) => ({
6166
- id: port.id,
6167
- type: port.type,
6168
- position: port.position,
6169
- styleClass: port.styleClass
6170
- })),
6171
- data: Object.keys(node.data).length > 0 ? node.data : void 0
6172
- });
6173
- nodeIds.add(node.id);
6174
- }
6175
- }
6176
- for (const edge of this.renderer.edges.values()) if (nodeIds.has(edge.from.nodeId) && nodeIds.has(edge.to.nodeId)) edges.push({
6177
- id: edge.id,
6178
- from: edge.from,
6179
- to: edge.to,
6180
- type: edge.type,
6181
- controlPoints: edge.controlPoints,
6182
- arrowType: edge.arrowType,
6183
- style: edge.style,
6184
- styleClass: edge.styleClass,
6185
- label: edge.label?.text,
6186
- labelStyleClass: edge.label?.styleClass,
6187
- labelOffset: edge.labelOffset !== 0 ? edge.labelOffset : void 0,
6188
- labelBackground: edge.labelBackground,
6189
- labelLineGap: edge.labelLineGap ? true : void 0,
6190
- data: Object.keys(edge.data).length > 0 ? edge.data : void 0
6191
- });
6192
- this.clipboard = {
6193
- nodes,
6194
- edges
6195
- };
6196
- }
6197
- pasteSelection(options) {
6198
- if (!this.clipboard || !options.nodeFactory || !options.edgeFactory) return;
6199
- const offset = {
6200
- x: 20,
6201
- y: 20
6202
- };
6203
- const idMap = /* @__PURE__ */ new Map();
6204
- const newNodes = [];
6205
- const newEdges = [];
6206
- for (const nodeData of this.clipboard.nodes) {
6207
- const newId = `${nodeData.id}_copy_${Date.now()}`;
6208
- idMap.set(nodeData.id, newId);
6209
- const copyData = {
6210
- ...nodeData,
6211
- id: newId,
6212
- x: nodeData.x + offset.x,
6213
- y: nodeData.y + offset.y
6214
- };
6215
- newNodes.push(options.nodeFactory(copyData));
6216
- }
6217
- for (const edgeData of this.clipboard.edges) {
6218
- const fromNodeId = idMap.get(edgeData.from.nodeId);
6219
- const toNodeId = idMap.get(edgeData.to.nodeId);
6220
- if (!fromNodeId || !toNodeId) continue;
6221
- const newId = `${edgeData.id}_copy_${Date.now()}`;
6222
- const copyEdge = {
6223
- ...edgeData,
6224
- id: newId,
6225
- from: {
6226
- ...edgeData.from,
6227
- nodeId: fromNodeId
6228
- },
6229
- to: {
6230
- ...edgeData.to,
6231
- nodeId: toNodeId
6232
- }
6233
- };
6234
- newEdges.push(options.edgeFactory(copyEdge));
6235
- }
6236
- if (newNodes.length === 0 && newEdges.length === 0) return;
6237
- this.historyManager.execute({
6238
- execute: () => {
6239
- for (const node of newNodes) this.renderer.addNode(node);
6240
- for (const edge of newEdges) this.renderer.addEdge(edge);
6241
- },
6242
- undo: () => {
6243
- for (const edge of newEdges) this.renderer.removeEdge(edge.id);
6244
- for (const node of newNodes) this.renderer.removeNode(node.id);
6245
- }
6246
- });
6247
- }
6248
6565
  endpointsEqual(a, b) {
6249
- if (a.nodeId !== b.nodeId) return false;
6566
+ if ((a.edgeId ?? null) !== (b.edgeId ?? null)) return false;
6567
+ if ((a.nodeId ?? null) !== (b.nodeId ?? null)) return false;
6250
6568
  if ((a.portId ?? null) !== (b.portId ?? null)) return false;
6569
+ const aPath = a.pathParam;
6570
+ const bPath = b.pathParam;
6571
+ if (aPath !== void 0 || bPath !== void 0) {
6572
+ if (aPath === void 0 || bPath === void 0) return false;
6573
+ if (Math.abs(aPath - bPath) >= 1e-9) return false;
6574
+ }
6251
6575
  const aParam = a.outlineParam;
6252
6576
  const bParam = b.outlineParam;
6253
6577
  if (aParam === void 0 && bParam === void 0) return true;
@@ -6629,40 +6953,459 @@ var ContextMenuManager = class extends EventEmitter {
6629
6953
  iconEl.style.fontSize = "16px";
6630
6954
  iconEl.textContent = icon.value;
6631
6955
  }
6632
- return iconEl;
6956
+ return iconEl;
6957
+ }
6958
+ isSvgString(value) {
6959
+ const trimmed = value.trim();
6960
+ return trimmed.startsWith("<svg") || trimmed.includes("</svg>");
6961
+ }
6962
+ adjustPosition(menu) {
6963
+ const rect = menu.getBoundingClientRect();
6964
+ const padding = 8;
6965
+ let left = rect.left;
6966
+ let top = rect.top;
6967
+ if (rect.right > window.innerWidth - padding) left = Math.max(padding, window.innerWidth - rect.width - padding);
6968
+ if (rect.bottom > window.innerHeight - padding) top = Math.max(padding, window.innerHeight - rect.height - padding);
6969
+ menu.style.left = `${left}px`;
6970
+ menu.style.top = `${top}px`;
6971
+ }
6972
+ };
6973
+ //#endregion
6974
+ //#region src/types.ts
6975
+ function isNodeEdgeEndpoint(endpoint) {
6976
+ return typeof endpoint.nodeId === "string" && endpoint.nodeId.length > 0;
6977
+ }
6978
+ function isEdgeEdgeEndpoint(endpoint) {
6979
+ return typeof endpoint.edgeId === "string" && endpoint.edgeId.length > 0;
6980
+ }
6981
+ //#endregion
6982
+ //#region src/core/EdgeEndpointUpdater.ts
6983
+ var EDGE_ATTACH_MAX_PASSES = 3;
6984
+ var EdgeEndpointUpdater = class {
6985
+ constructor(host) {
6986
+ this.host = host;
6987
+ }
6988
+ updateForDrag() {
6989
+ this.update(false);
6990
+ }
6991
+ updateAll() {
6992
+ this.update(true);
6993
+ }
6994
+ update(includeRoutingObstacles) {
6995
+ const obstacles = includeRoutingObstacles ? this.host.getNodeObstacles() : void 0;
6996
+ const nodes = this.host.getNodes();
6997
+ const edges = this.host.getEdges();
6998
+ const nodeNode = [];
6999
+ const edgeAttached = [];
7000
+ for (const edge of edges.values()) {
7001
+ if (!edge.autoUpdateEndpoints) continue;
7002
+ if (isEdgeEdgeEndpoint(edge.from) || isEdgeEdgeEndpoint(edge.to)) edgeAttached.push(edge);
7003
+ else nodeNode.push(edge);
7004
+ }
7005
+ for (const edge of nodeNode) this.updateNodeNodeEdge(edge, nodes, includeRoutingObstacles, obstacles);
7006
+ for (let pass = 0; pass < EDGE_ATTACH_MAX_PASSES; pass++) {
7007
+ let changed = false;
7008
+ for (const edge of edgeAttached) if (this.updateEdgeAttachedEdge(edge, nodes, edges, obstacles)) changed = true;
7009
+ if (!changed) break;
7010
+ }
7011
+ }
7012
+ updateNodeNodeEdge(edge, nodes, includeRoutingObstacles, obstacles) {
7013
+ if (!isNodeEdgeEndpoint(edge.from) || !isNodeEdgeEndpoint(edge.to)) return;
7014
+ const fromNode = nodes.get(edge.from.nodeId);
7015
+ const toNode = nodes.get(edge.to.nodeId);
7016
+ if (!fromNode || !toNode) return;
7017
+ if (includeRoutingObstacles) this.lockAnchors(edge, fromNode, toNode);
7018
+ const from = this.resolveNodeEndpoint(fromNode, edge.from);
7019
+ const to = this.resolveNodeEndpoint(toNode, edge.to);
7020
+ edge.updateEndpoints(from.point, to.point, from.direction, to.direction, obstacles ? { obstacles } : void 0);
7021
+ }
7022
+ /**
7023
+ * @returns true when the resolved geometry changed enough to warrant another pass
7024
+ */
7025
+ updateEdgeAttachedEdge(edge, nodes, edges, obstacles) {
7026
+ const from = this.resolveAnyEndpoint(edge.from, nodes, edges);
7027
+ const to = this.resolveAnyEndpoint(edge.to, nodes, edges);
7028
+ if (!from || !to) return false;
7029
+ const before = edge.path;
7030
+ const beforeKey = before.length >= 2 ? `${before[0].x},${before[0].y}:${before[before.length - 1].x},${before[before.length - 1].y}` : "";
7031
+ edge.updateEndpoints(from.point, to.point, from.direction, to.direction, obstacles ? { obstacles } : void 0);
7032
+ const after = edge.path;
7033
+ return beforeKey !== (after.length >= 2 ? `${after[0].x},${after[0].y}:${after[after.length - 1].x},${after[after.length - 1].y}` : "");
7034
+ }
7035
+ resolveAnyEndpoint(endpoint, nodes, edges) {
7036
+ if (isEdgeEdgeEndpoint(endpoint)) return this.resolveEdgeEndpoint(endpoint, edges);
7037
+ if (isNodeEdgeEndpoint(endpoint)) {
7038
+ const node = nodes.get(endpoint.nodeId);
7039
+ if (!node) return null;
7040
+ return this.resolveNodeEndpoint(node, endpoint);
7041
+ }
7042
+ return null;
7043
+ }
7044
+ resolveEdgeEndpoint(endpoint, edges) {
7045
+ const host = edges.get(endpoint.edgeId);
7046
+ if (!host) return null;
7047
+ const pathParam = endpoint.pathParam ?? .5;
7048
+ const at = host.getPointAt(pathParam);
7049
+ if (!at) return null;
7050
+ return {
7051
+ point: at.point,
7052
+ direction: directionFromAngle(at.angle)
7053
+ };
7054
+ }
7055
+ lockAnchors(edge, fromNode, toNode) {
7056
+ if (isNodeEdgeEndpoint(edge.from) && edge.lockAnchors && edge.from.outlineParam === void 0 && !edge.from.portId) {
7057
+ const anchor = fromNode.getNearestAnchor(toNode.getCenter());
7058
+ if (anchor) edge.from = {
7059
+ ...edge.from,
7060
+ portId: `${ANCHOR_PORT_PREFIX}${anchor.id}`
7061
+ };
7062
+ }
7063
+ if (isNodeEdgeEndpoint(edge.to) && edge.lockAnchors && edge.to.outlineParam === void 0 && !edge.to.portId) {
7064
+ const anchor = toNode.getNearestAnchor(fromNode.getCenter());
7065
+ if (anchor) edge.to = {
7066
+ ...edge.to,
7067
+ portId: `${ANCHOR_PORT_PREFIX}${anchor.id}`
7068
+ };
7069
+ }
7070
+ }
7071
+ resolveNodeEndpoint(node, endpoint) {
7072
+ if (endpoint.portId) {
7073
+ const anchorId = endpoint.portId.slice(ANCHOR_PORT_PREFIX.length);
7074
+ const point = node.getAnchorPointById(anchorId);
7075
+ return point ? {
7076
+ point,
7077
+ direction: anchorId.split(":")[0]
7078
+ } : { point: node.getCenter() };
7079
+ }
7080
+ if (endpoint.outlineParam !== void 0) return {
7081
+ point: node.getConnectionPointAtOutlineParam(endpoint.outlineParam),
7082
+ direction: getDirectionFromOutlineParam(endpoint.outlineParam)
7083
+ };
7084
+ return { point: node.getCenter() };
7085
+ }
7086
+ };
7087
+ //#endregion
7088
+ //#region src/core/ScrollbarController.ts
7089
+ var DEFAULT_SCROLLBAR_OPTIONS = {
7090
+ enabled: true,
7091
+ autoHide: false,
7092
+ autoHideDelay: 1200,
7093
+ fadeDuration: 220,
7094
+ thickness: 6,
7095
+ hoverThickness: 8,
7096
+ minThumbLength: 24,
7097
+ hitAreaPadding: 4,
7098
+ pageScrollRatio: .8,
7099
+ trackColor: "",
7100
+ thumbColor: "",
7101
+ thumbHoverColor: "",
7102
+ thumbActiveColor: ""
7103
+ };
7104
+ var ScrollbarController = class ScrollbarController {
7105
+ constructor(host, diagramOptions) {
7106
+ this.host = host;
7107
+ this.hoveredAxis = null;
7108
+ this.activeAxis = null;
7109
+ this.lastInteractionAt = performance.now();
7110
+ this.lastAlpha = -1;
7111
+ this.options = ScrollbarController.resolveOptions(diagramOptions);
7112
+ }
7113
+ updateAnimation(now) {
7114
+ if (!this.options.enabled || !this.options.autoHide) return false;
7115
+ const alpha = this.getAlpha(now);
7116
+ if (Math.abs(alpha - this.lastAlpha) <= .001) return false;
7117
+ this.lastAlpha = alpha;
7118
+ return true;
7119
+ }
7120
+ render(ctx, frameTime) {
7121
+ const metrics = this.getMetrics();
7122
+ if (!metrics) return;
7123
+ const alpha = this.getAlpha(frameTime);
7124
+ this.lastAlpha = alpha;
7125
+ if (alpha <= .001) return;
7126
+ const colors = this.resolveColors();
7127
+ ctx.save();
7128
+ const pixelRatio = this.host.getPixelRatio();
7129
+ ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
7130
+ ctx.globalAlpha = alpha;
7131
+ if (metrics.horizontal) {
7132
+ const isHovered = this.hoveredAxis === "horizontal";
7133
+ const isActive = this.activeAxis === "horizontal";
7134
+ const thickness = isHovered || isActive ? this.options.hoverThickness : this.options.thickness;
7135
+ const trackY = metrics.horizontal.trackY + (metrics.horizontal.thickness - thickness) / 2;
7136
+ const radius = thickness / 2;
7137
+ ctx.fillStyle = colors.track;
7138
+ drawRoundedRectPath(ctx, metrics.horizontal.trackX, trackY, metrics.horizontal.trackLength, thickness, radius);
7139
+ ctx.fill();
7140
+ ctx.fillStyle = isActive ? colors.thumbActive : isHovered ? colors.thumbHover : colors.thumb;
7141
+ drawRoundedRectPath(ctx, metrics.horizontal.trackX + metrics.horizontal.thumbOffset, trackY, metrics.horizontal.thumbLength, thickness, radius);
7142
+ ctx.fill();
7143
+ }
7144
+ if (metrics.vertical) {
7145
+ const isHovered = this.hoveredAxis === "vertical";
7146
+ const isActive = this.activeAxis === "vertical";
7147
+ const thickness = isHovered || isActive ? this.options.hoverThickness : this.options.thickness;
7148
+ const trackX = metrics.vertical.trackX + (metrics.vertical.thickness - thickness) / 2;
7149
+ const radius = thickness / 2;
7150
+ ctx.fillStyle = colors.track;
7151
+ drawRoundedRectPath(ctx, trackX, metrics.vertical.trackY, thickness, metrics.vertical.trackLength, radius);
7152
+ ctx.fill();
7153
+ ctx.fillStyle = isActive ? colors.thumbActive : isHovered ? colors.thumbHover : colors.thumb;
7154
+ drawRoundedRectPath(ctx, trackX, metrics.vertical.trackY + metrics.vertical.thumbOffset, thickness, metrics.vertical.thumbLength, radius);
7155
+ ctx.fill();
7156
+ }
7157
+ ctx.restore();
7158
+ }
7159
+ notifyInteraction() {
7160
+ this.lastInteractionAt = performance.now();
7161
+ this.host.markDirty();
7162
+ }
7163
+ setActiveAxis(axis) {
7164
+ if (this.activeAxis === axis) return;
7165
+ this.activeAxis = axis;
7166
+ if (axis === null) this.host.markDirty();
7167
+ else this.notifyInteraction();
7168
+ }
7169
+ updateHover(screenX, screenY) {
7170
+ const axis = this.hitTestArea(screenX, screenY);
7171
+ if (this.hoveredAxis !== axis) {
7172
+ this.hoveredAxis = axis;
7173
+ this.host.markDirty();
7174
+ if (axis !== null) this.notifyInteraction();
7175
+ }
7176
+ return axis !== null;
7177
+ }
7178
+ clearHover() {
7179
+ if (this.hoveredAxis === null) return;
7180
+ this.hoveredAxis = null;
7181
+ this.host.markDirty();
7182
+ }
7183
+ hitTestThumb(screenX, screenY) {
7184
+ const metrics = this.getMetrics();
7185
+ const point = this.host.screenToCanvas(screenX, screenY);
7186
+ if (!metrics || !point) return null;
7187
+ const padding = this.options.hitAreaPadding;
7188
+ if (metrics.horizontal) {
7189
+ const start = metrics.horizontal.trackX + metrics.horizontal.thumbOffset;
7190
+ if (point.x >= start - padding && point.x <= start + metrics.horizontal.thumbLength + padding && point.y >= metrics.horizontal.trackY - padding && point.y <= metrics.horizontal.trackY + metrics.horizontal.thickness + padding) return {
7191
+ axis: "horizontal",
7192
+ pointerOffset: point.x - start
7193
+ };
7194
+ }
7195
+ if (metrics.vertical) {
7196
+ const start = metrics.vertical.trackY + metrics.vertical.thumbOffset;
7197
+ if (point.x >= metrics.vertical.trackX - padding && point.x <= metrics.vertical.trackX + metrics.vertical.thickness + padding && point.y >= start - padding && point.y <= start + metrics.vertical.thumbLength + padding) return {
7198
+ axis: "vertical",
7199
+ pointerOffset: point.y - start
7200
+ };
7201
+ }
7202
+ return null;
7203
+ }
7204
+ dragThumb(axis, screenX, screenY, pointerOffset) {
7205
+ const metrics = this.getMetrics();
7206
+ const point = this.host.screenToCanvas(screenX, screenY);
7207
+ if (!metrics || !point) return false;
7208
+ const track = axis === "horizontal" ? metrics.horizontal : metrics.vertical;
7209
+ if (!track) return false;
7210
+ const coordinate = axis === "horizontal" ? point.x : point.y;
7211
+ const trackStart = axis === "horizontal" ? track.trackX : track.trackY;
7212
+ const travel = Math.max(0, track.trackLength - track.thumbLength);
7213
+ const thumbOffset = Math.min(Math.max(coordinate - trackStart - pointerOffset, 0), travel);
7214
+ const scroll = travel > 0 && track.maxScroll > 0 ? thumbOffset / travel * track.maxScroll : 0;
7215
+ const viewportStart = (axis === "horizontal" ? metrics.contentBounds.x : metrics.contentBounds.y) + scroll;
7216
+ if (axis === "horizontal") this.host.setOffsetX(-viewportStart * this.host.getZoom());
7217
+ else this.host.setOffsetY(-viewportStart * this.host.getZoom());
7218
+ this.notifyInteraction();
7219
+ return true;
7220
+ }
7221
+ clickTrack(screenX, screenY) {
7222
+ const metrics = this.getMetrics();
7223
+ const point = this.host.screenToCanvas(screenX, screenY);
7224
+ if (!metrics || !point) return false;
7225
+ if (metrics.horizontal && this.clickTrackAxis("horizontal", metrics, point)) return true;
7226
+ return metrics.vertical ? this.clickTrackAxis("vertical", metrics, point) : false;
7227
+ }
7228
+ scrollBy(screenDx, screenDy) {
7229
+ const state = this.getScrollState();
7230
+ if (!state) return false;
7231
+ const zoom = this.host.getZoom();
7232
+ const maxX = Math.max(0, state.contentBounds.width - state.viewportBounds.width);
7233
+ const maxY = Math.max(0, state.contentBounds.height - state.viewportBounds.height);
7234
+ const x = Math.min(Math.max(state.viewportBounds.x + screenDx / zoom, state.contentBounds.x), state.contentBounds.x + maxX);
7235
+ const y = Math.min(Math.max(state.viewportBounds.y + screenDy / zoom, state.contentBounds.y), state.contentBounds.y + maxY);
7236
+ this.host.setOffsetX(-x * zoom);
7237
+ this.host.setOffsetY(-y * zoom);
7238
+ this.notifyInteraction();
7239
+ return true;
7240
+ }
7241
+ scrollToStart() {
7242
+ const state = this.getScrollState();
7243
+ if (!state) return false;
7244
+ const zoom = this.host.getZoom();
7245
+ this.host.setOffsetX(-state.contentBounds.x * zoom);
7246
+ this.host.setOffsetY(-state.contentBounds.y * zoom);
7247
+ this.notifyInteraction();
7248
+ return true;
7249
+ }
7250
+ scrollToEnd() {
7251
+ const state = this.getScrollState();
7252
+ if (!state) return false;
7253
+ const maxX = Math.max(0, state.contentBounds.width - state.viewportBounds.width);
7254
+ const maxY = Math.max(0, state.contentBounds.height - state.viewportBounds.height);
7255
+ const zoom = this.host.getZoom();
7256
+ this.host.setOffsetX(-(state.contentBounds.x + maxX) * zoom);
7257
+ this.host.setOffsetY(-(state.contentBounds.y + maxY) * zoom);
7258
+ this.notifyInteraction();
7259
+ return true;
7260
+ }
7261
+ clickTrackAxis(axis, metrics, point) {
7262
+ const track = axis === "horizontal" ? metrics.horizontal : metrics.vertical;
7263
+ if (!track) return false;
7264
+ const coordinate = axis === "horizontal" ? point.x : point.y;
7265
+ const cross = axis === "horizontal" ? point.y : point.x;
7266
+ const start = axis === "horizontal" ? track.trackX : track.trackY;
7267
+ const crossStart = axis === "horizontal" ? track.trackY : track.trackX;
7268
+ if (coordinate < start || coordinate > start + track.trackLength || cross < crossStart || cross > crossStart + track.thickness) return false;
7269
+ const thumbStart = start + track.thumbOffset;
7270
+ const thumbEnd = thumbStart + track.thumbLength;
7271
+ if (coordinate >= thumbStart && coordinate <= thumbEnd) return false;
7272
+ const direction = coordinate < thumbStart ? -1 : 1;
7273
+ const viewportSize = axis === "horizontal" ? metrics.viewportBounds.width : metrics.viewportBounds.height;
7274
+ const current = (axis === "horizontal" ? metrics.viewportBounds.x : metrics.viewportBounds.y) - (axis === "horizontal" ? metrics.contentBounds.x : metrics.contentBounds.y);
7275
+ const next = Math.min(Math.max(current + viewportSize * this.options.pageScrollRatio * direction, 0), track.maxScroll);
7276
+ const viewportStart = (axis === "horizontal" ? metrics.contentBounds.x : metrics.contentBounds.y) + next;
7277
+ if (axis === "horizontal") this.host.setOffsetX(-viewportStart * this.host.getZoom());
7278
+ else this.host.setOffsetY(-viewportStart * this.host.getZoom());
7279
+ this.notifyInteraction();
7280
+ return true;
7281
+ }
7282
+ getMetrics() {
7283
+ if (!this.options.enabled) return null;
7284
+ const state = this.getScrollState();
7285
+ if (!state) return null;
7286
+ const { contentBounds, viewportBounds } = state;
7287
+ const contentWidth = Math.max(contentBounds.width, 1);
7288
+ const contentHeight = Math.max(contentBounds.height, 1);
7289
+ const showHorizontal = contentWidth > viewportBounds.width + .01;
7290
+ const showVertical = contentHeight > viewportBounds.height + .01;
7291
+ if (!showHorizontal && !showVertical) return null;
7292
+ const padding = 6;
7293
+ const spacing = 4;
7294
+ const width = this.host.getWidth();
7295
+ const height = this.host.getHeight();
7296
+ const availableWidth = width - padding * 2;
7297
+ const availableHeight = height - padding * 2;
7298
+ if (availableWidth <= 0 || availableHeight <= 0) return null;
7299
+ const horizontalLength = Math.max(0, availableWidth - (showVertical ? this.options.thickness + spacing : 0));
7300
+ const verticalLength = Math.max(0, availableHeight - (showHorizontal ? this.options.thickness + spacing : 0));
7301
+ return {
7302
+ contentBounds,
7303
+ viewportBounds,
7304
+ horizontal: showHorizontal ? this.createTrack(horizontalLength, viewportBounds.width, contentWidth, viewportBounds.x - contentBounds.x, padding, height - padding - this.options.thickness) : null,
7305
+ vertical: showVertical ? this.createTrack(verticalLength, viewportBounds.height, contentHeight, viewportBounds.y - contentBounds.y, width - padding - this.options.thickness, padding) : null
7306
+ };
7307
+ }
7308
+ createTrack(length, viewportSize, contentSize, scroll, trackX, trackY) {
7309
+ if (length <= 0) return null;
7310
+ const maxScroll = Math.max(0, contentSize - viewportSize);
7311
+ const thumbLength = Math.min(length, Math.max(this.options.minThumbLength, length * (viewportSize / contentSize)));
7312
+ const travel = Math.max(0, length - thumbLength);
7313
+ return {
7314
+ trackX,
7315
+ trackY,
7316
+ trackLength: length,
7317
+ thickness: this.options.thickness,
7318
+ thumbLength,
7319
+ thumbOffset: maxScroll > 0 ? Math.min(Math.max(scroll, 0), maxScroll) / maxScroll * travel : 0,
7320
+ maxScroll
7321
+ };
7322
+ }
7323
+ getScrollState() {
7324
+ const raw = this.host.getContentBounds();
7325
+ if (!raw) return null;
7326
+ const viewportBounds = this.getViewportBounds();
7327
+ const minX = Math.min(raw.x, viewportBounds.x);
7328
+ const minY = Math.min(raw.y, viewportBounds.y);
7329
+ const maxX = Math.max(raw.x + raw.width, viewportBounds.x + viewportBounds.width);
7330
+ const maxY = Math.max(raw.y + raw.height, viewportBounds.y + viewportBounds.height);
7331
+ return {
7332
+ contentBounds: {
7333
+ x: minX,
7334
+ y: minY,
7335
+ width: maxX - minX,
7336
+ height: maxY - minY
7337
+ },
7338
+ viewportBounds
7339
+ };
7340
+ }
7341
+ getViewportBounds() {
7342
+ const zoom = this.host.getZoom();
7343
+ return {
7344
+ x: -this.host.getOffsetX() / zoom,
7345
+ y: -this.host.getOffsetY() / zoom,
7346
+ width: this.host.getWidth() / zoom,
7347
+ height: this.host.getHeight() / zoom
7348
+ };
7349
+ }
7350
+ hitTestArea(screenX, screenY) {
7351
+ const metrics = this.getMetrics();
7352
+ const point = this.host.screenToCanvas(screenX, screenY);
7353
+ if (!metrics || !point) return null;
7354
+ const padding = this.options.hitAreaPadding;
7355
+ for (const axis of ["horizontal", "vertical"]) {
7356
+ const track = metrics[axis];
7357
+ if (!track) continue;
7358
+ const coordinate = axis === "horizontal" ? point.x : point.y;
7359
+ const cross = axis === "horizontal" ? point.y : point.x;
7360
+ const start = axis === "horizontal" ? track.trackX : track.trackY;
7361
+ const crossStart = axis === "horizontal" ? track.trackY : track.trackX;
7362
+ if (coordinate >= start - padding && coordinate <= start + track.trackLength + padding && cross >= crossStart - padding && cross <= crossStart + track.thickness + padding) return axis;
7363
+ }
7364
+ return null;
6633
7365
  }
6634
- isSvgString(value) {
6635
- const trimmed = value.trim();
6636
- return trimmed.startsWith("<svg") || trimmed.includes("</svg>");
7366
+ getAlpha(now) {
7367
+ if (!this.options.autoHide || this.activeAxis !== null || this.hoveredAxis !== null) return 1;
7368
+ const elapsed = now - this.lastInteractionAt;
7369
+ if (elapsed <= this.options.autoHideDelay) return 1;
7370
+ if (this.options.fadeDuration <= 0) return 0;
7371
+ return Math.max(0, 1 - (elapsed - this.options.autoHideDelay) / this.options.fadeDuration);
6637
7372
  }
6638
- adjustPosition(menu) {
6639
- const rect = menu.getBoundingClientRect();
6640
- const padding = 8;
6641
- let left = rect.left;
6642
- let top = rect.top;
6643
- if (rect.right > window.innerWidth - padding) left = Math.max(padding, window.innerWidth - rect.width - padding);
6644
- if (rect.bottom > window.innerHeight - padding) top = Math.max(padding, window.innerHeight - rect.height - padding);
6645
- menu.style.left = `${left}px`;
6646
- menu.style.top = `${top}px`;
7373
+ resolveColors() {
7374
+ const fallback = this.host.isDarkTheme() ? {
7375
+ track: "rgba(148, 163, 184, 0.22)",
7376
+ thumb: "rgba(226, 232, 240, 0.68)",
7377
+ thumbHover: "rgba(226, 232, 240, 0.86)",
7378
+ thumbActive: "rgba(248, 250, 252, 0.96)"
7379
+ } : {
7380
+ track: "rgba(15, 23, 42, 0.16)",
7381
+ thumb: "rgba(51, 65, 85, 0.7)",
7382
+ thumbHover: "rgba(30, 41, 59, 0.82)",
7383
+ thumbActive: "rgba(15, 23, 42, 0.9)"
7384
+ };
7385
+ return {
7386
+ track: this.options.trackColor || fallback.track,
7387
+ thumb: this.options.thumbColor || fallback.thumb,
7388
+ thumbHover: this.options.thumbHoverColor || fallback.thumbHover,
7389
+ thumbActive: this.options.thumbActiveColor || fallback.thumbActive
7390
+ };
7391
+ }
7392
+ static resolveOptions(options) {
7393
+ const resolved = {
7394
+ ...DEFAULT_SCROLLBAR_OPTIONS,
7395
+ enabled: options.scrollbarOverlay ?? DEFAULT_SCROLLBAR_OPTIONS.enabled
7396
+ };
7397
+ if (typeof options.scrollbar === "boolean") return {
7398
+ ...resolved,
7399
+ enabled: options.scrollbar
7400
+ };
7401
+ return options.scrollbar ? {
7402
+ ...resolved,
7403
+ ...options.scrollbar
7404
+ } : resolved;
6647
7405
  }
6648
7406
  };
6649
7407
  //#endregion
6650
7408
  //#region src/core/DiagramRenderer.ts
6651
- var DEFAULT_SCROLLBAR_OPTIONS = {
6652
- enabled: true,
6653
- autoHide: false,
6654
- autoHideDelay: 1200,
6655
- fadeDuration: 220,
6656
- thickness: 6,
6657
- hoverThickness: 8,
6658
- minThumbLength: 24,
6659
- hitAreaPadding: 4,
6660
- pageScrollRatio: .8,
6661
- trackColor: "",
6662
- thumbColor: "",
6663
- thumbHoverColor: "",
6664
- thumbActiveColor: ""
6665
- };
6666
7409
  var DEFAULT_OPTIONS$1 = {
6667
7410
  width: 800,
6668
7411
  height: 600,
@@ -6699,15 +7442,12 @@ var DiagramRenderer = class extends EventEmitter {
6699
7442
  this.interactionManager = null;
6700
7443
  this.contextMenuManager = null;
6701
7444
  this.frameTime = 0;
6702
- this.scrollbarHoveredAxis = null;
6703
- this.scrollbarActiveAxis = null;
6704
- this.scrollbarLastInteractionAt = 0;
6705
- this.lastScrollbarAlpha = -1;
6706
7445
  this._zoom = 1;
6707
7446
  this._offsetX = 0;
6708
7447
  this._offsetY = 0;
6709
7448
  this._dirty = true;
6710
7449
  this._styleDirty = true;
7450
+ this._contentDirty = true;
6711
7451
  this._destroyed = false;
6712
7452
  this._canvasRect = null;
6713
7453
  this._nodeObstaclesCache = null;
@@ -6722,14 +7462,35 @@ var DiagramRenderer = class extends EventEmitter {
6722
7462
  ...DEFAULT_OPTIONS$1,
6723
7463
  ...options
6724
7464
  };
6725
- this.scrollbar = this.resolveScrollbarOptions(options);
6726
7465
  const ctx = this.canvas.getContext("2d");
6727
7466
  if (ctx === null) throw new Error("Failed to get 2D rendering context");
6728
7467
  this.ctx = ctx;
6729
7468
  this.devicePixelRatio = this.options.retina ? window.devicePixelRatio || 1 : 1;
6730
7469
  this._zoom = this.options.initialZoom;
6731
7470
  this.animationManager = new AnimationManager(this.options.animations);
6732
- this.scrollbarLastInteractionAt = performance.now();
7471
+ this.scrollbarController = new ScrollbarController({
7472
+ getWidth: () => this.width,
7473
+ getHeight: () => this.height,
7474
+ getZoom: () => this.zoom,
7475
+ getOffsetX: () => this.offsetX,
7476
+ setOffsetX: (value) => {
7477
+ this.offsetX = value;
7478
+ },
7479
+ getOffsetY: () => this.offsetY,
7480
+ setOffsetY: (value) => {
7481
+ this.offsetY = value;
7482
+ },
7483
+ getPixelRatio: () => this.pixelRatio,
7484
+ markDirty: () => this.markDirty(),
7485
+ screenToCanvas: (x, y) => this.screenToCanvas(x, y),
7486
+ getContentBounds: () => this.getContentBounds(),
7487
+ isDarkTheme: () => this.styleManager?.theme.name === "dark"
7488
+ }, options);
7489
+ this.edgeEndpointUpdater = new EdgeEndpointUpdater({
7490
+ getNodes: () => this.nodes,
7491
+ getEdges: () => this.edges,
7492
+ getNodeObstacles: () => this.getNodeObstacles()
7493
+ });
6733
7494
  this.setupCanvas();
6734
7495
  this.startRenderLoop();
6735
7496
  }
@@ -6743,7 +7504,7 @@ var DiagramRenderer = class extends EventEmitter {
6743
7504
  const clampedZoom = Math.max(this.options.minZoom, Math.min(this.options.maxZoom, value));
6744
7505
  if (this._zoom !== clampedZoom) {
6745
7506
  this._zoom = clampedZoom;
6746
- this.scrollbarLastInteractionAt = performance.now();
7507
+ this.scrollbarController.notifyInteraction();
6747
7508
  this.markDirty();
6748
7509
  this.emit("zoom", clampedZoom);
6749
7510
  }
@@ -6757,7 +7518,7 @@ var DiagramRenderer = class extends EventEmitter {
6757
7518
  set offsetX(value) {
6758
7519
  if (this._offsetX !== value) {
6759
7520
  this._offsetX = value;
6760
- this.scrollbarLastInteractionAt = performance.now();
7521
+ this.scrollbarController.notifyInteraction();
6761
7522
  this.markDirty();
6762
7523
  this.emit("pan", this._offsetX, this._offsetY);
6763
7524
  }
@@ -6771,7 +7532,7 @@ var DiagramRenderer = class extends EventEmitter {
6771
7532
  set offsetY(value) {
6772
7533
  if (this._offsetY !== value) {
6773
7534
  this._offsetY = value;
6774
- this.scrollbarLastInteractionAt = performance.now();
7535
+ this.scrollbarController.notifyInteraction();
6775
7536
  this.markDirty();
6776
7537
  this.emit("pan", this._offsetX, this._offsetY);
6777
7538
  }
@@ -6991,12 +7752,11 @@ var DiagramRenderer = class extends EventEmitter {
6991
7752
  * Add a node to the diagram
6992
7753
  */
6993
7754
  addNode(node) {
6994
- node.setDirtyListener(() => this.markDirty());
7755
+ node.setDirtyListener(() => this.markContentDirty());
6995
7756
  node.setAttachToOutlineGetter(() => this._attachToOutline);
6996
7757
  this._nodes.set(node.id, node);
6997
- this._nodeObstaclesCache = null;
6998
7758
  this.animationManager.registerEnter(node.id);
6999
- this.markDirty();
7759
+ this.markContentDirty();
7000
7760
  this.emit("nodeAdd", node);
7001
7761
  }
7002
7762
  /**
@@ -7027,11 +7787,12 @@ var DiagramRenderer = class extends EventEmitter {
7027
7787
  */
7028
7788
  addEdge(edge) {
7029
7789
  edge.setDirtyListener(() => this.markDirty());
7790
+ edge.setBindingListener(() => this.markContentDirty());
7030
7791
  this._edges.set(edge.id, edge);
7031
- this._updateEdgeIndex(edge.id, edge.from.nodeId, true);
7032
- this._updateEdgeIndex(edge.id, edge.to.nodeId, true);
7792
+ if (edge.from.nodeId) this._updateEdgeIndex(edge.id, edge.from.nodeId, true);
7793
+ if (edge.to.nodeId) this._updateEdgeIndex(edge.id, edge.to.nodeId, true);
7033
7794
  this.animationManager.registerEnter(edge.id);
7034
- this.markDirty();
7795
+ this.markContentDirty();
7035
7796
  this.emit("edgeAdd", edge);
7036
7797
  }
7037
7798
  /**
@@ -7070,21 +7831,27 @@ var DiagramRenderer = class extends EventEmitter {
7070
7831
  if (edgesToRemove) for (const edgeId of Array.from(edgesToRemove)) this.removeEdgeImmediate(edgeId);
7071
7832
  for (const group of this._groups.values()) group.removeChild(nodeId);
7072
7833
  this._nodes.delete(nodeId);
7073
- this._nodeObstaclesCache = null;
7074
7834
  node.setDirtyListener(void 0);
7075
7835
  node.setAttachToOutlineGetter(void 0);
7076
- this.markDirty();
7836
+ this.markContentDirty();
7077
7837
  this.emit("nodeRemove", node);
7078
7838
  return true;
7079
7839
  }
7080
7840
  removeEdgeImmediate(edgeId) {
7081
7841
  const edge = this._edges.get(edgeId);
7082
7842
  if (edge === void 0) return false;
7083
- this._updateEdgeIndex(edgeId, edge.from.nodeId, false);
7084
- this._updateEdgeIndex(edgeId, edge.to.nodeId, false);
7843
+ const dependents = [];
7844
+ for (const other of this._edges.values()) {
7845
+ if (other.id === edgeId) continue;
7846
+ if (other.from.edgeId === edgeId || other.to.edgeId === edgeId) dependents.push(other.id);
7847
+ }
7848
+ for (const dependentId of dependents) this.removeEdgeImmediate(dependentId);
7849
+ if (edge.from.nodeId) this._updateEdgeIndex(edgeId, edge.from.nodeId, false);
7850
+ if (edge.to.nodeId) this._updateEdgeIndex(edgeId, edge.to.nodeId, false);
7085
7851
  this._edges.delete(edgeId);
7086
7852
  edge.setDirtyListener(void 0);
7087
- this.markDirty();
7853
+ edge.setBindingListener(void 0);
7854
+ this.markContentDirty();
7088
7855
  this.emit("edgeRemove", edge);
7089
7856
  return true;
7090
7857
  }
@@ -7164,12 +7931,22 @@ var DiagramRenderer = class extends EventEmitter {
7164
7931
  for (const n of this._nodes.values()) n.setBadgeHover(n.id === hoveredNodeId ? hoveredIndex : -1);
7165
7932
  }
7166
7933
  /**
7167
- * Mark the diagram as needing re-render
7934
+ * Mark the diagram as needing re-render (viewport / cosmetics).
7935
+ * Does not resync edge endpoints — use {@link markContentDirty} after layout changes.
7168
7936
  */
7169
7937
  markDirty() {
7170
7938
  this._dirty = true;
7171
7939
  }
7172
7940
  /**
7941
+ * Mark that node/edge layout changed and edge endpoints must be recalculated.
7942
+ * Pan/zoom should call {@link markDirty} only so path routing is not redone every frame.
7943
+ */
7944
+ markContentDirty() {
7945
+ this._contentDirty = true;
7946
+ this._nodeObstaclesCache = null;
7947
+ this._dirty = true;
7948
+ }
7949
+ /**
7173
7950
  * Force an immediate render
7174
7951
  */
7175
7952
  render() {
@@ -7235,13 +8012,7 @@ var DiagramRenderer = class extends EventEmitter {
7235
8012
  if (this._destroyed) return;
7236
8013
  const now = performance.now();
7237
8014
  if (this.animationManager.update(now) || this.animationManager.hasActive()) this._dirty = true;
7238
- if (this.scrollbar.enabled && this.scrollbar.autoHide) {
7239
- const alpha = this.getScrollbarAlpha(now);
7240
- if (Math.abs(alpha - this.lastScrollbarAlpha) > .001) {
7241
- this._dirty = true;
7242
- this.lastScrollbarAlpha = alpha;
7243
- }
7244
- }
8015
+ if (this.scrollbarController.updateAnimation(now)) this._dirty = true;
7245
8016
  this._canvasRect = null;
7246
8017
  if (this._dirty) {
7247
8018
  this.renderFrame(now);
@@ -7253,7 +8024,6 @@ var DiagramRenderer = class extends EventEmitter {
7253
8024
  }
7254
8025
  renderFrame(now) {
7255
8026
  this.frameTime = now;
7256
- this._nodeObstaclesCache = null;
7257
8027
  const ctx = this.ctx;
7258
8028
  const { width, height, backgroundColor } = this.options;
7259
8029
  ctx.setTransform(this.devicePixelRatio, 0, 0, this.devicePixelRatio, 0, 0);
@@ -7271,7 +8041,10 @@ var DiagramRenderer = class extends EventEmitter {
7271
8041
  this.renderElementWithAnimation(ctx, group, () => group.render(ctx));
7272
8042
  group.clearDirty();
7273
8043
  }
7274
- this.updateEdgeEndpoints();
8044
+ if (this._contentDirty) {
8045
+ this.edgeEndpointUpdater.updateAll();
8046
+ this._contentDirty = false;
8047
+ }
7275
8048
  for (const node of this._nodes.values()) if (node.visible) {
7276
8049
  this.renderElementWithAnimation(ctx, node, () => node.render(ctx));
7277
8050
  node.clearDirty();
@@ -7283,7 +8056,7 @@ var DiagramRenderer = class extends EventEmitter {
7283
8056
  for (const edge of this._edges.values()) if (edge.visible) edge.renderHandles(ctx);
7284
8057
  for (const overlayRenderer of this.overlayRenderers) overlayRenderer(ctx);
7285
8058
  for (const topOverlay of this.topOverlayRenderers) topOverlay(ctx);
7286
- this.renderScrollbars(ctx);
8059
+ this.scrollbarController.render(ctx, this.frameTime);
7287
8060
  this.emit("render");
7288
8061
  }
7289
8062
  renderElementWithAnimation(ctx, element, renderFn) {
@@ -7315,93 +8088,7 @@ var DiagramRenderer = class extends EventEmitter {
7315
8088
  * Called during drag to keep control points in sync
7316
8089
  */
7317
8090
  updateEdgeEndpointsForDrag() {
7318
- for (const edge of this._edges.values()) {
7319
- if (!edge.autoUpdateEndpoints) continue;
7320
- const fromNode = this._nodes.get(edge.from.nodeId);
7321
- const toNode = this._nodes.get(edge.to.nodeId);
7322
- if (fromNode === void 0 || toNode === void 0) continue;
7323
- let fromPoint;
7324
- let toPoint;
7325
- let fromDir;
7326
- let toDir;
7327
- if (edge.from.portId) {
7328
- const anchorId = edge.from.portId.slice(ANCHOR_PORT_PREFIX.length);
7329
- const anchorPoint = fromNode.getAnchorPointById(anchorId);
7330
- if (anchorPoint) {
7331
- fromPoint = anchorPoint;
7332
- fromDir = anchorId.split(":")[0];
7333
- } else fromPoint = fromNode.getCenter();
7334
- } else if (edge.from.outlineParam !== void 0) {
7335
- fromPoint = fromNode.getConnectionPointAtOutlineParam(edge.from.outlineParam);
7336
- fromDir = this.getDirectionFromOutlineParam(edge.from.outlineParam);
7337
- } else fromPoint = fromNode.getCenter();
7338
- if (edge.to.portId) {
7339
- const anchorId = edge.to.portId.slice(ANCHOR_PORT_PREFIX.length);
7340
- const anchorPoint = toNode.getAnchorPointById(anchorId);
7341
- if (anchorPoint) {
7342
- toPoint = anchorPoint;
7343
- toDir = anchorId.split(":")[0];
7344
- } else toPoint = toNode.getCenter();
7345
- } else if (edge.to.outlineParam !== void 0) {
7346
- toPoint = toNode.getConnectionPointAtOutlineParam(edge.to.outlineParam);
7347
- toDir = this.getDirectionFromOutlineParam(edge.to.outlineParam);
7348
- } else toPoint = toNode.getCenter();
7349
- edge.updateEndpoints(fromPoint, toPoint, fromDir, toDir);
7350
- }
7351
- }
7352
- updateEdgeEndpoints() {
7353
- const obstacles = this.getNodeObstacles();
7354
- for (const edge of this._edges.values()) {
7355
- if (!edge.autoUpdateEndpoints) continue;
7356
- const fromNode = this._nodes.get(edge.from.nodeId);
7357
- const toNode = this._nodes.get(edge.to.nodeId);
7358
- if (fromNode === void 0 || toNode === void 0) continue;
7359
- if (edge.lockAnchors && edge.from.outlineParam === void 0) {
7360
- if (!edge.from.portId) {
7361
- const anchor = fromNode.getNearestAnchor(toNode.getCenter());
7362
- if (anchor) edge.from = {
7363
- ...edge.from,
7364
- portId: `${ANCHOR_PORT_PREFIX}${anchor.id}`
7365
- };
7366
- }
7367
- }
7368
- if (edge.lockAnchors && edge.to.outlineParam === void 0) {
7369
- if (!edge.to.portId) {
7370
- const anchor = toNode.getNearestAnchor(fromNode.getCenter());
7371
- if (anchor) edge.to = {
7372
- ...edge.to,
7373
- portId: `${ANCHOR_PORT_PREFIX}${anchor.id}`
7374
- };
7375
- }
7376
- }
7377
- let fromPoint;
7378
- let toPoint;
7379
- let fromDir;
7380
- let toDir;
7381
- if (edge.from.portId) {
7382
- const anchorId = edge.from.portId.slice(ANCHOR_PORT_PREFIX.length);
7383
- const anchorPoint = fromNode.getAnchorPointById(anchorId);
7384
- if (anchorPoint) {
7385
- fromPoint = anchorPoint;
7386
- fromDir = anchorId.split(":")[0];
7387
- } else fromPoint = fromNode.getCenter();
7388
- } else if (edge.from.outlineParam !== void 0) {
7389
- fromPoint = fromNode.getConnectionPointAtOutlineParam(edge.from.outlineParam);
7390
- fromDir = this.getDirectionFromOutlineParam(edge.from.outlineParam);
7391
- } else fromPoint = fromNode.getCenter();
7392
- if (edge.to.portId) {
7393
- const anchorId = edge.to.portId.slice(ANCHOR_PORT_PREFIX.length);
7394
- const anchorPoint = toNode.getAnchorPointById(anchorId);
7395
- if (anchorPoint) {
7396
- toPoint = anchorPoint;
7397
- toDir = anchorId.split(":")[0];
7398
- } else toPoint = toNode.getCenter();
7399
- } else if (edge.to.outlineParam !== void 0) {
7400
- toPoint = toNode.getConnectionPointAtOutlineParam(edge.to.outlineParam);
7401
- toDir = this.getDirectionFromOutlineParam(edge.to.outlineParam);
7402
- } else toPoint = toNode.getCenter();
7403
- edge.updateEndpoints(fromPoint, toPoint, fromDir, toDir, { obstacles });
7404
- }
8091
+ this.edgeEndpointUpdater.updateForDrag();
7405
8092
  }
7406
8093
  getNodeObstacles() {
7407
8094
  if (this._nodeObstaclesCache !== null) return this._nodeObstaclesCache;
@@ -7414,56 +8101,6 @@ var DiagramRenderer = class extends EventEmitter {
7414
8101
  }));
7415
8102
  return this._nodeObstaclesCache;
7416
8103
  }
7417
- getDirectionFromOutlineParam(param) {
7418
- const p = (param % 1 + 1) % 1;
7419
- if (p < .25) return "top";
7420
- if (p < .5) return "right";
7421
- if (p < .75) return "bottom";
7422
- return "left";
7423
- }
7424
- renderScrollbars(ctx) {
7425
- const metrics = this.getScrollbarMetrics();
7426
- if (!metrics) return;
7427
- const alpha = this.getScrollbarAlpha(this.frameTime);
7428
- this.lastScrollbarAlpha = alpha;
7429
- if (alpha <= .001) return;
7430
- const colors = this.resolveScrollbarColors();
7431
- const drawRoundedRect = (x, y, w, h, r) => {
7432
- drawRoundedRectPath(ctx, x, y, w, h, r);
7433
- };
7434
- ctx.save();
7435
- ctx.setTransform(this.devicePixelRatio, 0, 0, this.devicePixelRatio, 0, 0);
7436
- ctx.globalAlpha = alpha;
7437
- if (metrics.horizontal) {
7438
- const isHovered = this.scrollbarHoveredAxis === "horizontal";
7439
- const isActive = this.scrollbarActiveAxis === "horizontal";
7440
- const thickness = isHovered || isActive ? this.scrollbar.hoverThickness : this.scrollbar.thickness;
7441
- const trackY = metrics.horizontal.trackY + (metrics.horizontal.thickness - thickness) / 2;
7442
- const radius = thickness / 2;
7443
- const thumbColor = isActive ? colors.thumbActive : isHovered ? colors.thumbHover : colors.thumb;
7444
- ctx.fillStyle = colors.track;
7445
- drawRoundedRect(metrics.horizontal.trackX, trackY, metrics.horizontal.trackLength, thickness, radius);
7446
- ctx.fill();
7447
- ctx.fillStyle = thumbColor;
7448
- drawRoundedRect(metrics.horizontal.trackX + metrics.horizontal.thumbOffset, trackY, metrics.horizontal.thumbLength, thickness, radius);
7449
- ctx.fill();
7450
- }
7451
- if (metrics.vertical) {
7452
- const isHovered = this.scrollbarHoveredAxis === "vertical";
7453
- const isActive = this.scrollbarActiveAxis === "vertical";
7454
- const thickness = isHovered || isActive ? this.scrollbar.hoverThickness : this.scrollbar.thickness;
7455
- const trackX = metrics.vertical.trackX + (metrics.vertical.thickness - thickness) / 2;
7456
- const radius = thickness / 2;
7457
- const thumbColor = isActive ? colors.thumbActive : isHovered ? colors.thumbHover : colors.thumb;
7458
- ctx.fillStyle = colors.track;
7459
- drawRoundedRect(trackX, metrics.vertical.trackY, thickness, metrics.vertical.trackLength, radius);
7460
- ctx.fill();
7461
- ctx.fillStyle = thumbColor;
7462
- drawRoundedRect(trackX, metrics.vertical.trackY + metrics.vertical.thumbOffset, thickness, metrics.vertical.thumbLength, radius);
7463
- ctx.fill();
7464
- }
7465
- ctx.restore();
7466
- }
7467
8104
  getContentBounds() {
7468
8105
  return getContentBounds({
7469
8106
  nodes: this._nodes.values(),
@@ -7471,348 +8108,69 @@ var DiagramRenderer = class extends EventEmitter {
7471
8108
  groups: this._groups.values()
7472
8109
  });
7473
8110
  }
7474
- getViewportBounds() {
7475
- return {
7476
- x: -this._offsetX / this._zoom,
7477
- y: -this._offsetY / this._zoom,
7478
- width: this.options.width / this._zoom,
7479
- height: this.options.height / this._zoom
7480
- };
7481
- }
7482
8111
  notifyScrollbarInteraction() {
7483
- this.scrollbarLastInteractionAt = performance.now();
7484
- this.markDirty();
8112
+ this.scrollbarController.notifyInteraction();
7485
8113
  }
7486
8114
  setScrollbarActiveAxis(axis) {
7487
- if (this.scrollbarActiveAxis === axis) return;
7488
- this.scrollbarActiveAxis = axis;
7489
- if (axis !== null) this.notifyScrollbarInteraction();
7490
- else this.markDirty();
8115
+ this.scrollbarController.setActiveAxis(axis);
7491
8116
  }
7492
8117
  updateScrollbarHover(screenX, screenY) {
7493
- const axis = this.hitTestScrollbarArea(screenX, screenY);
7494
- if (this.scrollbarHoveredAxis !== axis) {
7495
- this.scrollbarHoveredAxis = axis;
7496
- this.markDirty();
7497
- if (axis !== null) this.notifyScrollbarInteraction();
7498
- }
7499
- return axis !== null;
8118
+ return this.scrollbarController.updateHover(screenX, screenY);
7500
8119
  }
7501
8120
  clearScrollbarHover() {
7502
- if (this.scrollbarHoveredAxis !== null) {
7503
- this.scrollbarHoveredAxis = null;
7504
- this.markDirty();
7505
- }
8121
+ this.scrollbarController.clearHover();
7506
8122
  }
7507
8123
  hitTestScrollbarThumb(screenX, screenY) {
7508
- const metrics = this.getScrollbarMetrics();
7509
- if (!metrics) return null;
7510
- const localPoint = this.screenToCanvas(screenX, screenY);
7511
- if (!localPoint) return null;
7512
- const { x, y } = localPoint;
7513
- const hitPadding = this.scrollbar.hitAreaPadding;
7514
- if (metrics.horizontal) {
7515
- const thumbStartX = metrics.horizontal.trackX + metrics.horizontal.thumbOffset;
7516
- if (x >= thumbStartX - hitPadding && x <= thumbStartX + metrics.horizontal.thumbLength + hitPadding && y >= metrics.horizontal.trackY - hitPadding && y <= metrics.horizontal.trackY + metrics.horizontal.thickness + hitPadding) return {
7517
- axis: "horizontal",
7518
- pointerOffset: x - thumbStartX
7519
- };
7520
- }
7521
- if (metrics.vertical) {
7522
- const thumbStartY = metrics.vertical.trackY + metrics.vertical.thumbOffset;
7523
- if (x >= metrics.vertical.trackX - hitPadding && x <= metrics.vertical.trackX + metrics.vertical.thickness + hitPadding && y >= thumbStartY - hitPadding && y <= thumbStartY + metrics.vertical.thumbLength + hitPadding) return {
7524
- axis: "vertical",
7525
- pointerOffset: y - thumbStartY
7526
- };
7527
- }
7528
- return null;
8124
+ return this.scrollbarController.hitTestThumb(screenX, screenY);
7529
8125
  }
7530
8126
  dragScrollbarThumb(axis, screenX, screenY, pointerOffset) {
7531
- const metrics = this.getScrollbarMetrics();
7532
- if (!metrics) return false;
7533
- const localPoint = this.screenToCanvas(screenX, screenY);
7534
- if (!localPoint) return false;
7535
- if (axis === "horizontal" && metrics.horizontal) {
7536
- const travel = Math.max(0, metrics.horizontal.trackLength - metrics.horizontal.thumbLength);
7537
- const desiredThumbOffset = localPoint.x - metrics.horizontal.trackX - pointerOffset;
7538
- const clampedThumbOffset = Math.min(Math.max(desiredThumbOffset, 0), travel);
7539
- const scrollX = travel > 0 && metrics.horizontal.maxScroll > 0 ? clampedThumbOffset / travel * metrics.horizontal.maxScroll : 0;
7540
- this.offsetX = -(metrics.contentBounds.x + scrollX) * this._zoom;
7541
- this.notifyScrollbarInteraction();
7542
- return true;
7543
- }
7544
- if (axis === "vertical" && metrics.vertical) {
7545
- const travel = Math.max(0, metrics.vertical.trackLength - metrics.vertical.thumbLength);
7546
- const desiredThumbOffset = localPoint.y - metrics.vertical.trackY - pointerOffset;
7547
- const clampedThumbOffset = Math.min(Math.max(desiredThumbOffset, 0), travel);
7548
- const scrollY = travel > 0 && metrics.vertical.maxScroll > 0 ? clampedThumbOffset / travel * metrics.vertical.maxScroll : 0;
7549
- this.offsetY = -(metrics.contentBounds.y + scrollY) * this._zoom;
7550
- this.notifyScrollbarInteraction();
7551
- return true;
7552
- }
7553
- return false;
8127
+ return this.scrollbarController.dragThumb(axis, screenX, screenY, pointerOffset);
7554
8128
  }
7555
8129
  clickScrollbarTrack(screenX, screenY) {
7556
- const metrics = this.getScrollbarMetrics();
7557
- if (!metrics) return false;
7558
- const localPoint = this.screenToCanvas(screenX, screenY);
7559
- if (!localPoint) return false;
7560
- if (metrics.horizontal) {
7561
- const { trackX, trackY, trackLength, thumbOffset, thumbLength, maxScroll } = metrics.horizontal;
7562
- if (localPoint.x >= trackX && localPoint.x <= trackX + trackLength && localPoint.y >= trackY && localPoint.y <= trackY + metrics.horizontal.thickness) {
7563
- const thumbStart = trackX + thumbOffset;
7564
- const thumbEnd = thumbStart + thumbLength;
7565
- if (localPoint.x < thumbStart || localPoint.x > thumbEnd) {
7566
- const direction = localPoint.x < thumbStart ? -1 : 1;
7567
- const step = metrics.viewportBounds.width * this.scrollbar.pageScrollRatio * direction;
7568
- const currentScrollX = Math.min(Math.max(metrics.viewportBounds.x - metrics.contentBounds.x, 0), maxScroll);
7569
- const nextScrollX = Math.min(Math.max(currentScrollX + step, 0), maxScroll);
7570
- this.offsetX = -(metrics.contentBounds.x + nextScrollX) * this.zoom;
7571
- this.notifyScrollbarInteraction();
7572
- return true;
7573
- }
7574
- }
7575
- }
7576
- if (metrics.vertical) {
7577
- const { trackX, trackY, trackLength, thumbOffset, thumbLength, maxScroll } = metrics.vertical;
7578
- if (localPoint.x >= trackX && localPoint.x <= trackX + metrics.vertical.thickness && localPoint.y >= trackY && localPoint.y <= trackY + trackLength) {
7579
- const thumbStart = trackY + thumbOffset;
7580
- const thumbEnd = thumbStart + thumbLength;
7581
- if (localPoint.y < thumbStart || localPoint.y > thumbEnd) {
7582
- const direction = localPoint.y < thumbStart ? -1 : 1;
7583
- const step = metrics.viewportBounds.height * this.scrollbar.pageScrollRatio * direction;
7584
- const currentScrollY = Math.min(Math.max(metrics.viewportBounds.y - metrics.contentBounds.y, 0), maxScroll);
7585
- const nextScrollY = Math.min(Math.max(currentScrollY + step, 0), maxScroll);
7586
- this.offsetY = -(metrics.contentBounds.y + nextScrollY) * this.zoom;
7587
- this.notifyScrollbarInteraction();
7588
- return true;
7589
- }
7590
- }
7591
- }
7592
- return false;
7593
- }
7594
- scrollViewportBy(screenDx, screenDy) {
7595
- const scrollState = this.getScrollState();
7596
- if (!scrollState) return false;
7597
- const worldDx = screenDx / this.zoom;
7598
- const worldDy = screenDy / this.zoom;
7599
- const currentViewportX = scrollState.viewportBounds.x;
7600
- const currentViewportY = scrollState.viewportBounds.y;
7601
- const maxScrollX = Math.max(0, scrollState.contentBounds.width - scrollState.viewportBounds.width);
7602
- const maxScrollY = Math.max(0, scrollState.contentBounds.height - scrollState.viewportBounds.height);
7603
- const nextViewportX = Math.min(Math.max(currentViewportX + worldDx, scrollState.contentBounds.x), scrollState.contentBounds.x + maxScrollX);
7604
- const nextViewportY = Math.min(Math.max(currentViewportY + worldDy, scrollState.contentBounds.y), scrollState.contentBounds.y + maxScrollY);
7605
- this.offsetX = -nextViewportX * this.zoom;
7606
- this.offsetY = -nextViewportY * this.zoom;
7607
- this.notifyScrollbarInteraction();
7608
- return true;
7609
- }
7610
- scrollViewportToStart() {
7611
- const scrollState = this.getScrollState();
7612
- if (!scrollState) return false;
7613
- this.offsetX = -scrollState.contentBounds.x * this.zoom;
7614
- this.offsetY = -scrollState.contentBounds.y * this.zoom;
7615
- this.notifyScrollbarInteraction();
7616
- return true;
7617
- }
7618
- scrollViewportToEnd() {
7619
- const scrollState = this.getScrollState();
7620
- if (!scrollState) return false;
7621
- const maxScrollX = Math.max(0, scrollState.contentBounds.width - scrollState.viewportBounds.width);
7622
- const maxScrollY = Math.max(0, scrollState.contentBounds.height - scrollState.viewportBounds.height);
7623
- const targetViewportX = scrollState.contentBounds.x + maxScrollX;
7624
- const targetViewportY = scrollState.contentBounds.y + maxScrollY;
7625
- this.offsetX = -targetViewportX * this.zoom;
7626
- this.offsetY = -targetViewportY * this.zoom;
7627
- this.notifyScrollbarInteraction();
7628
- return true;
7629
- }
7630
- beginOverlayDrag(screenX, screenY) {
7631
- for (const plugin of this.plugins) {
7632
- const interactivePlugin = plugin;
7633
- if (!interactivePlugin.beginOverlayDrag) continue;
7634
- const payload = interactivePlugin.beginOverlayDrag(this, screenX, screenY);
7635
- if (payload !== null && payload !== void 0) return {
7636
- plugin: interactivePlugin,
7637
- payload
7638
- };
7639
- }
7640
- return null;
7641
- }
7642
- /**
7643
- * Точка (client / screen) попадает в оверлей, который не должен «пробрасывать» hit-test в диаграмму.
7644
- */
7645
- blocksDiagramPointerAtScreen(screenX, screenY) {
7646
- for (const plugin of this.plugins) if (plugin.blocksDiagramPointerAtScreen?.(this, screenX, screenY)) return true;
7647
- return false;
7648
- }
7649
- updateOverlayDrag(session, screenX, screenY) {
7650
- return session.plugin.updateOverlayDrag?.(this, screenX, screenY, session.payload) ?? false;
7651
- }
7652
- endOverlayDrag(session) {
7653
- session.plugin.endOverlayDrag?.(this, session.payload);
7654
- }
7655
- screenToCanvas(screenX, screenY) {
7656
- const rect = this._canvasRect ?? this.canvas.getBoundingClientRect();
7657
- if (rect.width <= 0 || rect.height <= 0) return null;
7658
- const scaleX = rect.width / this.options.width;
7659
- const scaleY = rect.height / this.options.height;
7660
- return {
7661
- x: (screenX - rect.left) / scaleX,
7662
- y: (screenY - rect.top) / scaleY
7663
- };
7664
- }
7665
- getScrollbarMetrics() {
7666
- if (!this.scrollbar.enabled) return null;
7667
- const rawContentBounds = this.getContentBounds();
7668
- if (!rawContentBounds) return null;
7669
- const viewportBounds = this.getViewportBounds();
7670
- const unionMinX = Math.min(rawContentBounds.x, viewportBounds.x);
7671
- const unionMinY = Math.min(rawContentBounds.y, viewportBounds.y);
7672
- const unionMaxX = Math.max(rawContentBounds.x + rawContentBounds.width, viewportBounds.x + viewportBounds.width);
7673
- const unionMaxY = Math.max(rawContentBounds.y + rawContentBounds.height, viewportBounds.y + viewportBounds.height);
7674
- const contentBounds = {
7675
- x: unionMinX,
7676
- y: unionMinY,
7677
- width: unionMaxX - unionMinX,
7678
- height: unionMaxY - unionMinY
7679
- };
7680
- const contentWidth = Math.max(contentBounds.width, 1);
7681
- const contentHeight = Math.max(contentBounds.height, 1);
7682
- const showHorizontal = contentWidth > viewportBounds.width + .01;
7683
- const showVertical = contentHeight > viewportBounds.height + .01;
7684
- if (!showHorizontal && !showVertical) return null;
7685
- const { width, height } = this.options;
7686
- const padding = 6;
7687
- const thickness = this.scrollbar.thickness;
7688
- const spacing = 4;
7689
- const minThumbLength = this.scrollbar.minThumbLength;
7690
- const availableWidth = width - padding * 2;
7691
- const availableHeight = height - padding * 2;
7692
- if (availableWidth <= 0 || availableHeight <= 0) return null;
7693
- const horizontalTrackLength = Math.max(0, availableWidth - (showVertical ? thickness + spacing : 0));
7694
- const verticalTrackLength = Math.max(0, availableHeight - (showHorizontal ? thickness + spacing : 0));
7695
- const horizontalTrackX = padding;
7696
- const horizontalTrackY = height - padding - thickness;
7697
- const verticalTrackX = width - padding - thickness;
7698
- const verticalTrackY = padding;
7699
- let horizontal = null;
7700
- if (showHorizontal && horizontalTrackLength > 0) {
7701
- const maxScrollX = Math.max(0, contentWidth - viewportBounds.width);
7702
- const scrollX = Math.min(Math.max(viewportBounds.x - contentBounds.x, 0), maxScrollX);
7703
- const ratioX = viewportBounds.width / contentWidth;
7704
- const thumbLength = Math.min(horizontalTrackLength, Math.max(minThumbLength, horizontalTrackLength * ratioX));
7705
- const travel = Math.max(0, horizontalTrackLength - thumbLength);
7706
- horizontal = {
7707
- trackX: horizontalTrackX,
7708
- trackY: horizontalTrackY,
7709
- trackLength: horizontalTrackLength,
7710
- thickness,
7711
- thumbLength,
7712
- thumbOffset: maxScrollX > 0 ? scrollX / maxScrollX * travel : 0,
7713
- maxScroll: maxScrollX
7714
- };
7715
- }
7716
- let vertical = null;
7717
- if (showVertical && verticalTrackLength > 0) {
7718
- const maxScrollY = Math.max(0, contentHeight - viewportBounds.height);
7719
- const scrollY = Math.min(Math.max(viewportBounds.y - contentBounds.y, 0), maxScrollY);
7720
- const ratioY = viewportBounds.height / contentHeight;
7721
- const thumbLength = Math.min(verticalTrackLength, Math.max(minThumbLength, verticalTrackLength * ratioY));
7722
- const travel = Math.max(0, verticalTrackLength - thumbLength);
7723
- vertical = {
7724
- trackX: verticalTrackX,
7725
- trackY: verticalTrackY,
7726
- trackLength: verticalTrackLength,
7727
- thickness,
7728
- thumbLength,
7729
- thumbOffset: maxScrollY > 0 ? scrollY / maxScrollY * travel : 0,
7730
- maxScroll: maxScrollY
7731
- };
7732
- }
7733
- if (!horizontal && !vertical) return null;
7734
- return {
7735
- contentBounds,
7736
- viewportBounds,
7737
- thickness: this.scrollbar.thickness,
7738
- horizontal,
7739
- vertical
7740
- };
7741
- }
7742
- resolveScrollbarOptions(options) {
7743
- const fallbackEnabled = options.scrollbarOverlay ?? DEFAULT_SCROLLBAR_OPTIONS.enabled;
7744
- const resolved = {
7745
- ...DEFAULT_SCROLLBAR_OPTIONS,
7746
- enabled: fallbackEnabled
7747
- };
7748
- if (typeof options.scrollbar === "boolean") {
7749
- resolved.enabled = options.scrollbar;
7750
- return resolved;
7751
- }
7752
- if (options.scrollbar) return {
7753
- ...resolved,
7754
- ...options.scrollbar
7755
- };
7756
- return resolved;
7757
- }
7758
- resolveScrollbarColors() {
7759
- const fallback = this.styleManager?.theme.name === "dark" ? {
7760
- track: "rgba(148, 163, 184, 0.22)",
7761
- thumb: "rgba(226, 232, 240, 0.68)",
7762
- thumbHover: "rgba(226, 232, 240, 0.86)",
7763
- thumbActive: "rgba(248, 250, 252, 0.96)"
7764
- } : {
7765
- track: "rgba(15, 23, 42, 0.16)",
7766
- thumb: "rgba(51, 65, 85, 0.7)",
7767
- thumbHover: "rgba(30, 41, 59, 0.82)",
7768
- thumbActive: "rgba(15, 23, 42, 0.9)"
7769
- };
7770
- return {
7771
- track: this.scrollbar.trackColor || fallback.track,
7772
- thumb: this.scrollbar.thumbColor || fallback.thumb,
7773
- thumbHover: this.scrollbar.thumbHoverColor || fallback.thumbHover,
7774
- thumbActive: this.scrollbar.thumbActiveColor || fallback.thumbActive
7775
- };
7776
- }
7777
- getScrollbarAlpha(now) {
7778
- if (!this.scrollbar.autoHide) return 1;
7779
- if (this.scrollbarActiveAxis !== null || this.scrollbarHoveredAxis !== null) return 1;
7780
- const elapsed = now - this.scrollbarLastInteractionAt;
7781
- if (elapsed <= this.scrollbar.autoHideDelay) return 1;
7782
- if (this.scrollbar.fadeDuration <= 0) return 0;
7783
- const fadeProgress = (elapsed - this.scrollbar.autoHideDelay) / this.scrollbar.fadeDuration;
7784
- return Math.max(0, 1 - fadeProgress);
7785
- }
7786
- hitTestScrollbarArea(screenX, screenY) {
7787
- const metrics = this.getScrollbarMetrics();
7788
- if (!metrics) return null;
7789
- const localPoint = this.screenToCanvas(screenX, screenY);
7790
- if (!localPoint) return null;
7791
- const hitPadding = this.scrollbar.hitAreaPadding;
7792
- if (metrics.horizontal) {
7793
- if (localPoint.x >= metrics.horizontal.trackX - hitPadding && localPoint.x <= metrics.horizontal.trackX + metrics.horizontal.trackLength + hitPadding && localPoint.y >= metrics.horizontal.trackY - hitPadding && localPoint.y <= metrics.horizontal.trackY + metrics.horizontal.thickness + hitPadding) return "horizontal";
7794
- }
7795
- if (metrics.vertical) {
7796
- if (localPoint.x >= metrics.vertical.trackX - hitPadding && localPoint.x <= metrics.vertical.trackX + metrics.vertical.thickness + hitPadding && localPoint.y >= metrics.vertical.trackY - hitPadding && localPoint.y <= metrics.vertical.trackY + metrics.vertical.trackLength + hitPadding) return "vertical";
8130
+ return this.scrollbarController.clickTrack(screenX, screenY);
8131
+ }
8132
+ scrollViewportBy(screenDx, screenDy) {
8133
+ return this.scrollbarController.scrollBy(screenDx, screenDy);
8134
+ }
8135
+ scrollViewportToStart() {
8136
+ return this.scrollbarController.scrollToStart();
8137
+ }
8138
+ scrollViewportToEnd() {
8139
+ return this.scrollbarController.scrollToEnd();
8140
+ }
8141
+ beginOverlayDrag(screenX, screenY) {
8142
+ for (const plugin of this.plugins) {
8143
+ const interactivePlugin = plugin;
8144
+ if (!interactivePlugin.beginOverlayDrag) continue;
8145
+ const payload = interactivePlugin.beginOverlayDrag(this, screenX, screenY);
8146
+ if (payload !== null && payload !== void 0) return {
8147
+ plugin: interactivePlugin,
8148
+ payload
8149
+ };
7797
8150
  }
7798
8151
  return null;
7799
8152
  }
7800
- getScrollState() {
7801
- const rawContentBounds = this.getContentBounds();
7802
- if (!rawContentBounds) return null;
7803
- const viewportBounds = this.getViewportBounds();
7804
- const unionMinX = Math.min(rawContentBounds.x, viewportBounds.x);
7805
- const unionMinY = Math.min(rawContentBounds.y, viewportBounds.y);
7806
- const unionMaxX = Math.max(rawContentBounds.x + rawContentBounds.width, viewportBounds.x + viewportBounds.width);
7807
- const unionMaxY = Math.max(rawContentBounds.y + rawContentBounds.height, viewportBounds.y + viewportBounds.height);
8153
+ /**
8154
+ * Точка (client / screen) попадает в оверлей, который не должен «пробрасывать» hit-test в диаграмму.
8155
+ */
8156
+ blocksDiagramPointerAtScreen(screenX, screenY) {
8157
+ for (const plugin of this.plugins) if (plugin.blocksDiagramPointerAtScreen?.(this, screenX, screenY)) return true;
8158
+ return false;
8159
+ }
8160
+ updateOverlayDrag(session, screenX, screenY) {
8161
+ return session.plugin.updateOverlayDrag?.(this, screenX, screenY, session.payload) ?? false;
8162
+ }
8163
+ endOverlayDrag(session) {
8164
+ session.plugin.endOverlayDrag?.(this, session.payload);
8165
+ }
8166
+ screenToCanvas(screenX, screenY) {
8167
+ const rect = this._canvasRect ?? this.canvas.getBoundingClientRect();
8168
+ if (rect.width <= 0 || rect.height <= 0) return null;
8169
+ const scaleX = rect.width / this.options.width;
8170
+ const scaleY = rect.height / this.options.height;
7808
8171
  return {
7809
- contentBounds: {
7810
- x: unionMinX,
7811
- y: unionMinY,
7812
- width: unionMaxX - unionMinX,
7813
- height: unionMaxY - unionMinY
7814
- },
7815
- viewportBounds
8172
+ x: (screenX - rect.left) / scaleX,
8173
+ y: (screenY - rect.top) / scaleY
7816
8174
  };
7817
8175
  }
7818
8176
  };
@@ -7930,8 +8288,10 @@ var SearchManager = class {
7930
8288
  for (const edge of this.renderer.edges.values()) {
7931
8289
  if (scope === "nodes") continue;
7932
8290
  const matchesEdge = this.matchesEdge(edge, options);
7933
- const fromVisible = this.renderer.getNode(edge.from.nodeId)?.visible ?? true;
7934
- const toVisible = this.renderer.getNode(edge.to.nodeId)?.visible ?? true;
8291
+ const fromId = edge.from.nodeId;
8292
+ const toId = edge.to.nodeId;
8293
+ const fromVisible = fromId ? this.renderer.getNode(fromId)?.visible ?? true : true;
8294
+ const toVisible = toId ? this.renderer.getNode(toId)?.visible ?? true : true;
7935
8295
  edge.visible = matchesEdge && fromVisible && toVisible;
7936
8296
  }
7937
8297
  this.renderer.markDirty();
@@ -8032,6 +8392,7 @@ function generatePortId() {
8032
8392
  }
8033
8393
  /**
8034
8394
  * Reset port ID counter (for testing)
8395
+ * @internal
8035
8396
  */
8036
8397
  function resetPortIdCounter() {
8037
8398
  portIdCounter = 1;
@@ -8159,6 +8520,61 @@ var Port = class {
8159
8520
  }
8160
8521
  };
8161
8522
  //#endregion
8523
+ //#region src/utils/svgTint.ts
8524
+ /**
8525
+ * SVG tinting utilities — shared between NodeImage and CIcon.
8526
+ */
8527
+ function styleSetColor(style, key, color) {
8528
+ if (new RegExp(`${key}\\s*:`).test(style)) return style.replace(new RegExp(`${key}\\s*:[^;]+`), `${key}:${color}`);
8529
+ return `${style}${style.trim().endsWith(";") || style.trim() === "" ? "" : ";"}${key}:${color};`;
8530
+ }
8531
+ /**
8532
+ * Tint all stroke/fill attributes and inline styles in an SVG string.
8533
+ */
8534
+ function tintSvg(svgText, strokeColor, fillColor) {
8535
+ if (!strokeColor && !fillColor) return svgText;
8536
+ const root = new DOMParser().parseFromString(svgText, "image/svg+xml").documentElement;
8537
+ if (!root || root.nodeName.toLowerCase() === "parsererror") return svgText;
8538
+ const all = [root, ...Array.from(root.querySelectorAll("*"))];
8539
+ for (const el of all) {
8540
+ const stroke = el.getAttribute("stroke");
8541
+ const fill = el.getAttribute("fill");
8542
+ if (strokeColor && (stroke === null || stroke.toLowerCase() !== "none")) el.setAttribute("stroke", strokeColor);
8543
+ if (fillColor && (fill === null || fill.toLowerCase() !== "none")) el.setAttribute("fill", fillColor);
8544
+ const style = el.getAttribute("style");
8545
+ if (style) {
8546
+ let next = style;
8547
+ if (strokeColor && /stroke\s*:\s*(?!none)/.test(style)) next = styleSetColor(next, "stroke", strokeColor);
8548
+ if (fillColor && /fill\s*:\s*(?!none)/.test(style)) next = styleSetColor(next, "fill", fillColor);
8549
+ if (next !== style) el.setAttribute("style", next);
8550
+ }
8551
+ }
8552
+ return new XMLSerializer().serializeToString(root);
8553
+ }
8554
+ /**
8555
+ * Check if a string contains inline SVG markup.
8556
+ */
8557
+ function isSvgMarkup(value) {
8558
+ const trimmed = value.trim().toLowerCase();
8559
+ return trimmed.startsWith("<svg") || trimmed.includes("<svg");
8560
+ }
8561
+ /**
8562
+ * Convert SVG markup to a data URL for use in HTMLImageElement.src.
8563
+ */
8564
+ function svgToDataUrl(svg) {
8565
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg).replace(/%0A/g, "").replace(/%0D/g, "").replace(/%09/g, " ").replace(/%20/g, " ")}`;
8566
+ }
8567
+ /**
8568
+ * Check if a URL points to an SVG file.
8569
+ */
8570
+ function isSvgUrl(url) {
8571
+ try {
8572
+ return new URL(url, "http://localhost").pathname.endsWith(".svg");
8573
+ } catch {
8574
+ return url.endsWith(".svg");
8575
+ }
8576
+ }
8577
+ //#endregion
8162
8578
  //#region src/utils/LRUCache.ts
8163
8579
  /**
8164
8580
  * LRU (Least Recently Used) cache implementation
@@ -8225,66 +8641,174 @@ var LRUCache = class {
8225
8641
  }
8226
8642
  };
8227
8643
  //#endregion
8228
- //#region src/utils/svgTint.ts
8644
+ //#region src/utils/svgAssetLoader.ts
8645
+ /** Shared LRU for fetched SVG text (NodeImage, CIcon, etc.). */
8646
+ var svgTextCache = new LRUCache(100);
8229
8647
  /**
8230
- * SVG tinting utilities — shared between NodeImage and CIcon.
8648
+ * Fetch SVG text with a shared LRU cache.
8231
8649
  */
8232
- function styleSetColor(style, key, color) {
8233
- if (new RegExp(`${key}\\s*:`).test(style)) return style.replace(new RegExp(`${key}\\s*:[^;]+`), `${key}:${color}`);
8234
- return `${style}${style.trim().endsWith(";") || style.trim() === "" ? "" : ";"}${key}:${color};`;
8650
+ function fetchSvgText(url) {
8651
+ let promise = svgTextCache.get(url);
8652
+ if (!promise) {
8653
+ promise = fetch(url).then((r) => r.ok ? r.text() : "").catch(() => "");
8654
+ svgTextCache.set(url, promise);
8655
+ }
8656
+ return promise;
8235
8657
  }
8236
8658
  /**
8237
- * Tint all stroke/fill attributes and inline styles in an SVG string.
8659
+ * Synchronously read SVG from a URL (for SvgExporter). Uses cached promise result when already resolved.
8238
8660
  */
8239
- function tintSvg(svgText, strokeColor, fillColor) {
8240
- if (!strokeColor && !fillColor) return svgText;
8241
- const root = new DOMParser().parseFromString(svgText, "image/svg+xml").documentElement;
8242
- if (!root || root.nodeName.toLowerCase() === "parsererror") return svgText;
8243
- const all = [root, ...Array.from(root.querySelectorAll("*"))];
8244
- for (const el of all) {
8245
- const stroke = el.getAttribute("stroke");
8246
- const fill = el.getAttribute("fill");
8247
- if (strokeColor && (stroke === null || stroke.toLowerCase() !== "none")) el.setAttribute("stroke", strokeColor);
8248
- if (fillColor && (fill === null || fill.toLowerCase() !== "none")) el.setAttribute("fill", fillColor);
8249
- const style = el.getAttribute("style");
8250
- if (style) {
8251
- let next = style;
8252
- if (strokeColor && /stroke\s*:\s*(?!none)/.test(style)) next = styleSetColor(next, "stroke", strokeColor);
8253
- if (fillColor && /fill\s*:\s*(?!none)/.test(style)) next = styleSetColor(next, "fill", fillColor);
8254
- if (next !== style) el.setAttribute("style", next);
8255
- }
8661
+ function readSvgFromUrlSync(url) {
8662
+ if (typeof XMLHttpRequest === "undefined") return null;
8663
+ try {
8664
+ const xhr = new XMLHttpRequest();
8665
+ xhr.open("GET", url, false);
8666
+ xhr.send();
8667
+ if (xhr.status >= 200 && xhr.status < 300) return xhr.responseText || null;
8668
+ } catch {}
8669
+ return null;
8670
+ }
8671
+ /**
8672
+ * Resolve a source string to an href suitable for Image/SVG (async, with tint).
8673
+ */
8674
+ async function resolveTintedSvgDataUrl(source, options = {}) {
8675
+ const { strokeColor, fillColor } = options;
8676
+ if (isSvgMarkup(source)) return svgToDataUrl(tintSvg(source, strokeColor, fillColor));
8677
+ if (isSvgUrl(source) || source.toLowerCase().endsWith(".svg")) {
8678
+ const svgText = await fetchSvgText(source);
8679
+ if (!svgText) return null;
8680
+ return svgToDataUrl(tintSvg(svgText, strokeColor, fillColor));
8256
8681
  }
8257
- return new XMLSerializer().serializeToString(root);
8682
+ return null;
8258
8683
  }
8259
8684
  /**
8260
- * Check if a string contains inline SVG markup.
8685
+ * Resolve a source to a tinted data URL synchronously (SvgExporter path).
8261
8686
  */
8262
- function isSvgMarkup(value) {
8263
- const trimmed = value.trim().toLowerCase();
8264
- return trimmed.startsWith("<svg") || trimmed.includes("<svg");
8687
+ function resolveTintedSvgDataUrlSync(source, options = {}) {
8688
+ const { strokeColor, fillColor } = options;
8689
+ if (isSvgMarkup(source)) return svgToDataUrl(tintSvg(source, strokeColor, fillColor));
8690
+ if (source.toLowerCase().endsWith(".svg")) {
8691
+ const svgText = readSvgFromUrlSync(source);
8692
+ if (!svgText) return null;
8693
+ return svgToDataUrl(tintSvg(svgText, strokeColor, fillColor));
8694
+ }
8695
+ return null;
8265
8696
  }
8697
+ //#endregion
8698
+ //#region src/utils/iconLayout.ts
8266
8699
  /**
8267
- * Convert SVG markup to a data URL for use in HTMLImageElement.src.
8700
+ * Icon zone size including inset padding around the image.
8268
8701
  */
8269
- function svgToDataUrl(svg) {
8270
- return `data:image/svg+xml;utf8,${encodeURIComponent(svg).replace(/%0A/g, "").replace(/%0D/g, "").replace(/%09/g, " ").replace(/%20/g, " ")}`;
8702
+ function getIconBoxSize(imageSize, inset) {
8703
+ return {
8704
+ width: imageSize.width + inset * 2,
8705
+ height: imageSize.height + inset * 2
8706
+ };
8271
8707
  }
8272
8708
  /**
8273
- * Check if a URL points to an SVG file.
8709
+ * Bounds of the icon zone within a node, based on placement.
8274
8710
  */
8275
- function isSvgUrl(url) {
8276
- try {
8277
- return new URL(url, "http://localhost").pathname.endsWith(".svg");
8278
- } catch {
8279
- return url.endsWith(".svg");
8711
+ function getIconBounds(bounds, iconBoxSize, placement, inset) {
8712
+ switch (placement) {
8713
+ case "top": return {
8714
+ x: bounds.x,
8715
+ y: bounds.y,
8716
+ width: bounds.width,
8717
+ height: iconBoxSize.height
8718
+ };
8719
+ case "bottom": return {
8720
+ x: bounds.x,
8721
+ y: bounds.y + bounds.height - iconBoxSize.height,
8722
+ width: bounds.width,
8723
+ height: iconBoxSize.height
8724
+ };
8725
+ case "left": return {
8726
+ x: bounds.x,
8727
+ y: bounds.y,
8728
+ width: iconBoxSize.width,
8729
+ height: bounds.height
8730
+ };
8731
+ case "right": return {
8732
+ x: bounds.x + bounds.width - iconBoxSize.width,
8733
+ y: bounds.y,
8734
+ width: iconBoxSize.width,
8735
+ height: bounds.height
8736
+ };
8737
+ case "top-left": return {
8738
+ x: bounds.x + inset,
8739
+ y: bounds.y + inset,
8740
+ width: iconBoxSize.width,
8741
+ height: iconBoxSize.height
8742
+ };
8743
+ case "top-right": return {
8744
+ x: bounds.x + bounds.width - iconBoxSize.width - inset,
8745
+ y: bounds.y + inset,
8746
+ width: iconBoxSize.width,
8747
+ height: iconBoxSize.height
8748
+ };
8749
+ case "bottom-left": return {
8750
+ x: bounds.x + inset,
8751
+ y: bounds.y + bounds.height - iconBoxSize.height - inset,
8752
+ width: iconBoxSize.width,
8753
+ height: iconBoxSize.height
8754
+ };
8755
+ case "bottom-right": return {
8756
+ x: bounds.x + bounds.width - iconBoxSize.width - inset,
8757
+ y: bounds.y + bounds.height - iconBoxSize.height - inset,
8758
+ width: iconBoxSize.width,
8759
+ height: iconBoxSize.height
8760
+ };
8761
+ default: return bounds;
8280
8762
  }
8281
8763
  }
8764
+ /**
8765
+ * Compute the draw rectangle for an icon image inside its zone bounds.
8766
+ */
8767
+ function computeIconDrawRect(bounds, opts, imageSize, inset) {
8768
+ const fit = opts.fit ?? "none";
8769
+ const scaleWithBounds = opts.scaleWithBounds ?? false;
8770
+ const innerBounds = {
8771
+ x: bounds.x + inset,
8772
+ y: bounds.y + inset,
8773
+ width: Math.max(0, bounds.width - inset * 2),
8774
+ height: Math.max(0, bounds.height - inset * 2)
8775
+ };
8776
+ const availableWidth = Math.max(0, innerBounds.width);
8777
+ const availableHeight = Math.max(0, innerBounds.height);
8778
+ let drawWidth = opts.width ?? imageSize.width;
8779
+ let drawHeight = opts.height ?? imageSize.height;
8780
+ if (scaleWithBounds) {
8781
+ if ((fit === "contain" || fit === "cover") && imageSize.width > 0 && imageSize.height > 0) {
8782
+ const scaleX = availableWidth / imageSize.width;
8783
+ const scaleY = availableHeight / imageSize.height;
8784
+ const scale = fit === "contain" ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY);
8785
+ drawWidth = imageSize.width * scale;
8786
+ drawHeight = imageSize.height * scale;
8787
+ } else if (fit === "stretch") {
8788
+ drawWidth = availableWidth;
8789
+ drawHeight = availableHeight;
8790
+ }
8791
+ }
8792
+ drawWidth = Math.min(Math.max(0, drawWidth), Math.max(0, availableWidth));
8793
+ drawHeight = Math.min(Math.max(0, drawHeight), Math.max(0, availableHeight));
8794
+ return {
8795
+ x: innerBounds.x + (innerBounds.width - drawWidth) / 2,
8796
+ y: innerBounds.y + (innerBounds.height - drawHeight) / 2,
8797
+ width: drawWidth,
8798
+ height: drawHeight
8799
+ };
8800
+ }
8801
+ /**
8802
+ * Convenience: compute draw rect from NodeImageOptions.
8803
+ */
8804
+ function computeIconDrawRectFromOptions(bounds, opts, imageSize, inset) {
8805
+ return computeIconDrawRect(bounds, opts, imageSize, inset);
8806
+ }
8282
8807
  //#endregion
8283
8808
  //#region src/elements/NodeImage.ts
8284
8809
  function isCornerPlacement(placement) {
8285
8810
  return placement === "top-left" || placement === "top-right" || placement === "bottom-left" || placement === "bottom-right";
8286
8811
  }
8287
- var svgTextCache = new LRUCache(100);
8288
8812
  /**
8289
8813
  * Image helper for node rendering (supports images and inline SVG).
8290
8814
  */
@@ -8327,40 +8851,15 @@ var NodeImage = class {
8327
8851
  render(ctx, bounds) {
8328
8852
  if (!this._loaded) return;
8329
8853
  const ins = this.inset;
8330
- const fit = this._options.fit ?? "none";
8331
- const scaleWithBounds = this._options.scaleWithBounds ?? false;
8332
8854
  const opacity = this._options.opacity ?? 1;
8333
- const innerBounds = {
8334
- x: bounds.x + ins,
8335
- y: bounds.y + ins,
8336
- width: Math.max(0, bounds.width - ins * 2),
8337
- height: Math.max(0, bounds.height - ins * 2)
8338
- };
8339
- const availableWidth = Math.max(0, innerBounds.width);
8340
- const availableHeight = Math.max(0, innerBounds.height);
8341
- let drawWidth = this._options.width ?? this._naturalWidth;
8342
- let drawHeight = this._options.height ?? this._naturalHeight;
8343
- if (scaleWithBounds) {
8344
- if (fit === "contain" || fit === "cover") {
8345
- const scaleX = availableWidth / this._naturalWidth;
8346
- const scaleY = availableHeight / this._naturalHeight;
8347
- const scale = fit === "contain" ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY);
8348
- drawWidth = this._naturalWidth * scale;
8349
- drawHeight = this._naturalHeight * scale;
8350
- } else if (fit === "stretch") {
8351
- drawWidth = availableWidth;
8352
- drawHeight = availableHeight;
8353
- }
8354
- }
8355
- const maxWidth = Math.max(0, availableWidth);
8356
- const maxHeight = Math.max(0, availableHeight);
8357
- drawWidth = Math.min(drawWidth, maxWidth);
8358
- drawHeight = Math.min(drawHeight, maxHeight);
8359
- const x = innerBounds.x + (innerBounds.width - drawWidth) / 2;
8360
- const y = innerBounds.y + (innerBounds.height - drawHeight) / 2;
8855
+ const drawRect = computeIconDrawRect(bounds, this._options, {
8856
+ width: this._naturalWidth,
8857
+ height: this._naturalHeight
8858
+ }, ins);
8859
+ if (drawRect.width <= 0 || drawRect.height <= 0) return;
8361
8860
  ctx.save();
8362
8861
  ctx.globalAlpha = opacity;
8363
- ctx.drawImage(this._image, x, y, drawWidth, drawHeight);
8862
+ ctx.drawImage(this._image, drawRect.x, drawRect.y, drawRect.width, drawRect.height);
8364
8863
  ctx.restore();
8365
8864
  }
8366
8865
  getSize() {
@@ -8379,27 +8878,20 @@ var NodeImage = class {
8379
8878
  if (source instanceof HTMLImageElement) return;
8380
8879
  const version = ++this._sourceVersion;
8381
8880
  if (isSvgMarkup(source)) {
8382
- const tinted = tintSvg(source, this._options.strokeColor, this._options.fillColor);
8881
+ const resolvedSource = resolveTintedSvgDataUrlSync(source, {
8882
+ strokeColor: this._options.strokeColor,
8883
+ fillColor: this._options.fillColor
8884
+ });
8383
8885
  if (version !== this._sourceVersion) return;
8384
- this._image.src = svgToDataUrl(tinted);
8886
+ this._image.src = resolvedSource ?? source;
8385
8887
  return;
8386
8888
  }
8387
- if ((!!this._options.strokeColor || !!this._options.fillColor) && source.toLowerCase().endsWith(".svg")) {
8388
- let svgPromise = svgTextCache.get(source);
8389
- if (!svgPromise) {
8390
- svgPromise = fetch(source).then((r) => r.ok ? r.text() : "");
8391
- svgTextCache.set(source, svgPromise);
8392
- }
8393
- const svgText = await svgPromise;
8394
- if (version !== this._sourceVersion) return;
8395
- if (svgText) {
8396
- const tinted = tintSvg(svgText, this._options.strokeColor, this._options.fillColor);
8397
- this._image.src = svgToDataUrl(tinted);
8398
- return;
8399
- }
8400
- }
8889
+ const resolvedSource = await resolveTintedSvgDataUrl(source, {
8890
+ strokeColor: this._options.strokeColor,
8891
+ fillColor: this._options.fillColor
8892
+ });
8401
8893
  if (version !== this._sourceVersion) return;
8402
- this._image.src = source;
8894
+ this._image.src = resolvedSource ?? source;
8403
8895
  }
8404
8896
  attachHandlers() {
8405
8897
  if (this._image.complete && this._image.naturalWidth > 0) {
@@ -9163,65 +9655,13 @@ var Node = class extends Element {
9163
9655
  if (!this._icon) return;
9164
9656
  const { width, height } = this._icon.getSize();
9165
9657
  if (width <= 0 || height <= 0) return;
9166
- const ins = this._icon.inset;
9167
- return {
9168
- width: width + ins * 2,
9169
- height: height + ins * 2
9170
- };
9658
+ return getIconBoxSize({
9659
+ width,
9660
+ height
9661
+ }, this._icon.inset);
9171
9662
  }
9172
9663
  getIconBounds(bounds, iconBoxSize, placement) {
9173
- const ins = this._icon?.inset ?? 0;
9174
- switch (placement) {
9175
- case "top": return {
9176
- x: bounds.x,
9177
- y: bounds.y,
9178
- width: bounds.width,
9179
- height: iconBoxSize.height
9180
- };
9181
- case "bottom": return {
9182
- x: bounds.x,
9183
- y: bounds.y + bounds.height - iconBoxSize.height,
9184
- width: bounds.width,
9185
- height: iconBoxSize.height
9186
- };
9187
- case "left": return {
9188
- x: bounds.x,
9189
- y: bounds.y,
9190
- width: iconBoxSize.width,
9191
- height: bounds.height
9192
- };
9193
- case "right": return {
9194
- x: bounds.x + bounds.width - iconBoxSize.width,
9195
- y: bounds.y,
9196
- width: iconBoxSize.width,
9197
- height: bounds.height
9198
- };
9199
- case "top-left": return {
9200
- x: bounds.x + ins,
9201
- y: bounds.y + ins,
9202
- width: iconBoxSize.width,
9203
- height: iconBoxSize.height
9204
- };
9205
- case "top-right": return {
9206
- x: bounds.x + bounds.width - iconBoxSize.width - ins,
9207
- y: bounds.y + ins,
9208
- width: iconBoxSize.width,
9209
- height: iconBoxSize.height
9210
- };
9211
- case "bottom-left": return {
9212
- x: bounds.x + ins,
9213
- y: bounds.y + bounds.height - iconBoxSize.height - ins,
9214
- width: iconBoxSize.width,
9215
- height: iconBoxSize.height
9216
- };
9217
- case "bottom-right": return {
9218
- x: bounds.x + bounds.width - iconBoxSize.width - ins,
9219
- y: bounds.y + bounds.height - iconBoxSize.height - ins,
9220
- width: iconBoxSize.width,
9221
- height: iconBoxSize.height
9222
- };
9223
- default: return bounds;
9224
- }
9664
+ return getIconBounds(bounds, iconBoxSize, placement, this._icon?.inset ?? 0);
9225
9665
  }
9226
9666
  normalizeAnchorPoints(config) {
9227
9667
  const normalize = (value) => {
@@ -10122,6 +10562,23 @@ var ShapeFactories = {
10122
10562
  }
10123
10563
  };
10124
10564
  //#endregion
10565
+ //#region src/elements/composite/CompositeComponentBase.ts
10566
+ /**
10567
+ * Shared change notification and default bounds hit testing for composite components.
10568
+ */
10569
+ var CompositeComponentBase = class {
10570
+ setOnChange(cb) {
10571
+ this._onChange = cb;
10572
+ }
10573
+ hitTest(point, bounds) {
10574
+ if (this.style.visible === false) return null;
10575
+ return point.x >= bounds.x && point.x <= bounds.x + bounds.width && point.y >= bounds.y && point.y <= bounds.y + bounds.height ? this : null;
10576
+ }
10577
+ markChanged() {
10578
+ this._onChange?.();
10579
+ }
10580
+ };
10581
+ //#endregion
10125
10582
  //#region src/elements/composite/CText.ts
10126
10583
  var DEFAULT_FONT_FAMILY = "sans-serif";
10127
10584
  var DEFAULT_FONT_SIZE = 14;
@@ -10131,8 +10588,9 @@ var DEFAULT_COLOR$1 = "#000000";
10131
10588
  * Lightweight text component for CompositeNode.
10132
10589
  * Renders directly via canvas ctx without delegating to TextLabel.
10133
10590
  */
10134
- var CText = class {
10591
+ var CText = class extends CompositeComponentBase {
10135
10592
  constructor(options) {
10593
+ super();
10136
10594
  this.type = "text";
10137
10595
  this._cachedLines = null;
10138
10596
  this.id = options.id;
@@ -10158,7 +10616,7 @@ var CText = class {
10158
10616
  if (this._text !== value) {
10159
10617
  this._text = value;
10160
10618
  this._cachedLines = null;
10161
- this._onChange?.();
10619
+ this.markChanged();
10162
10620
  }
10163
10621
  }
10164
10622
  get fontFamily() {
@@ -10167,7 +10625,7 @@ var CText = class {
10167
10625
  set fontFamily(value) {
10168
10626
  if (this._fontFamily !== value) {
10169
10627
  this._fontFamily = value;
10170
- this._onChange?.();
10628
+ this.markChanged();
10171
10629
  }
10172
10630
  }
10173
10631
  get fontWeight() {
@@ -10176,7 +10634,7 @@ var CText = class {
10176
10634
  set fontWeight(value) {
10177
10635
  if (this._fontWeight !== value) {
10178
10636
  this._fontWeight = value;
10179
- this._onChange?.();
10637
+ this.markChanged();
10180
10638
  }
10181
10639
  }
10182
10640
  get fontStyle() {
@@ -10185,7 +10643,7 @@ var CText = class {
10185
10643
  set fontStyle(value) {
10186
10644
  if (this._fontStyle !== value) {
10187
10645
  this._fontStyle = value;
10188
- this._onChange?.();
10646
+ this.markChanged();
10189
10647
  }
10190
10648
  }
10191
10649
  get fontSize() {
@@ -10194,7 +10652,7 @@ var CText = class {
10194
10652
  set fontSize(value) {
10195
10653
  if (this._fontSize !== value) {
10196
10654
  this._fontSize = value;
10197
- this._onChange?.();
10655
+ this.markChanged();
10198
10656
  }
10199
10657
  }
10200
10658
  get color() {
@@ -10203,7 +10661,7 @@ var CText = class {
10203
10661
  set color(value) {
10204
10662
  if (this._color !== value) {
10205
10663
  this._color = value;
10206
- this._onChange?.();
10664
+ this.markChanged();
10207
10665
  }
10208
10666
  }
10209
10667
  get align() {
@@ -10212,7 +10670,7 @@ var CText = class {
10212
10670
  set align(value) {
10213
10671
  if (this._align !== value) {
10214
10672
  this._align = value;
10215
- this._onChange?.();
10673
+ this.markChanged();
10216
10674
  }
10217
10675
  }
10218
10676
  get verticalAlign() {
@@ -10221,7 +10679,7 @@ var CText = class {
10221
10679
  set verticalAlign(value) {
10222
10680
  if (this._verticalAlign !== value) {
10223
10681
  this._verticalAlign = value;
10224
- this._onChange?.();
10682
+ this.markChanged();
10225
10683
  }
10226
10684
  }
10227
10685
  get maxLines() {
@@ -10230,7 +10688,7 @@ var CText = class {
10230
10688
  set maxLines(value) {
10231
10689
  if (this._maxLines !== value) {
10232
10690
  this._maxLines = value;
10233
- this._onChange?.();
10691
+ this.markChanged();
10234
10692
  }
10235
10693
  }
10236
10694
  get lineHeight() {
@@ -10248,12 +10706,9 @@ var CText = class {
10248
10706
  set rotation(value) {
10249
10707
  if (this._rotation !== value) {
10250
10708
  this._rotation = value;
10251
- this._onChange?.();
10709
+ this.markChanged();
10252
10710
  }
10253
10711
  }
10254
- setOnChange(cb) {
10255
- this._onChange = cb;
10256
- }
10257
10712
  getFont() {
10258
10713
  return `${this._fontStyle} ${this._fontWeight} ${this._fontSize}px ${this._fontFamily}`;
10259
10714
  }
@@ -10265,34 +10720,16 @@ var CText = class {
10265
10720
  */
10266
10721
  wrapText(ctx, maxWidth) {
10267
10722
  ctx.font = this.getFont();
10268
- const words = this._text.split(" ");
10269
- const lines = [];
10270
- let currentLine = "";
10271
- for (const word of words) {
10272
- const testLine = currentLine ? `${currentLine} ${word}` : word;
10273
- if (ctx.measureText(testLine).width > maxWidth && currentLine) {
10274
- lines.push(currentLine);
10275
- currentLine = word;
10276
- } else currentLine = testLine;
10277
- }
10278
- if (currentLine) lines.push(currentLine);
10279
- if (this._maxLines !== void 0 && lines.length > this._maxLines) {
10280
- const truncated = lines.slice(0, this._maxLines);
10281
- const lastLine = truncated[truncated.length - 1];
10282
- if (lastLine !== void 0) truncated[truncated.length - 1] = lastLine + "…";
10283
- return truncated;
10284
- }
10285
- return lines;
10723
+ return wrapMeasuredText(ctx, this._text, maxWidth, {
10724
+ maxLines: this._maxLines,
10725
+ appendEllipsis: true
10726
+ });
10286
10727
  }
10287
10728
  measure(ctx) {
10288
10729
  ctx.font = this.getFont();
10289
10730
  const lineHeightPx = this.getLineHeightPx();
10290
10731
  const rawLines = this._text.split("\n");
10291
- let maxWidth = 0;
10292
- for (const line of rawLines) {
10293
- const metrics = ctx.measureText(line);
10294
- if (metrics.width > maxWidth) maxWidth = metrics.width;
10295
- }
10732
+ const maxWidth = measureTextLines(ctx, rawLines);
10296
10733
  let lineCount = rawLines.length;
10297
10734
  if (this._maxLines !== void 0 && lineCount > this._maxLines) lineCount = this._maxLines;
10298
10735
  const width = maxWidth;
@@ -10363,13 +10800,8 @@ var CText = class {
10363
10800
  const offsetX = xBase - (textBounds.x + textBounds.width / 2);
10364
10801
  const offsetY = yStart - (textBounds.y + textBounds.height / 2);
10365
10802
  for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], offsetX, offsetY + i * lineHeightPx);
10366
- } else for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], xBase, yStart + i * lineHeightPx);
10367
- ctx.restore();
10368
- }
10369
- hitTest(point, bounds) {
10370
- if (this.style.visible === false) return null;
10371
- if (point.x >= bounds.x && point.x <= bounds.x + bounds.width && point.y >= bounds.y && point.y <= bounds.y + bounds.height) return this;
10372
- return null;
10803
+ } else for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], xBase, yStart + i * lineHeightPx);
10804
+ ctx.restore();
10373
10805
  }
10374
10806
  serialize() {
10375
10807
  const data = {
@@ -10442,12 +10874,12 @@ function escapeXml(str) {
10442
10874
  //#endregion
10443
10875
  //#region src/elements/composite/CIcon.ts
10444
10876
  var DEFAULT_ICON_SIZE = 24;
10445
- var svgFetchCache = new LRUCache(100);
10446
10877
  /**
10447
10878
  * Lightweight SVG/image icon component for CompositeNode.
10448
10879
  */
10449
- var CIcon = class {
10880
+ var CIcon = class extends CompositeComponentBase {
10450
10881
  constructor(options) {
10882
+ super();
10451
10883
  this.type = "icon";
10452
10884
  this._loaded = false;
10453
10885
  this.id = options.id;
@@ -10463,10 +10895,10 @@ var CIcon = class {
10463
10895
  this._image = new Image();
10464
10896
  this._image.onload = () => {
10465
10897
  this._loaded = true;
10466
- this._onChange?.();
10898
+ this.markChanged();
10467
10899
  };
10468
10900
  this._image.onerror = () => {
10469
- this._onChange?.();
10901
+ this.markChanged();
10470
10902
  };
10471
10903
  this.loadSource(this._source);
10472
10904
  }
@@ -10478,7 +10910,7 @@ var CIcon = class {
10478
10910
  this._source = value;
10479
10911
  this._loaded = false;
10480
10912
  this.loadSource(value);
10481
- this._onChange?.();
10913
+ this.markChanged();
10482
10914
  }
10483
10915
  }
10484
10916
  get width() {
@@ -10487,7 +10919,7 @@ var CIcon = class {
10487
10919
  set width(value) {
10488
10920
  if (this._width !== value) {
10489
10921
  this._width = value;
10490
- this._onChange?.();
10922
+ this.markChanged();
10491
10923
  }
10492
10924
  }
10493
10925
  get height() {
@@ -10496,7 +10928,7 @@ var CIcon = class {
10496
10928
  set height(value) {
10497
10929
  if (this._height !== value) {
10498
10930
  this._height = value;
10499
- this._onChange?.();
10931
+ this.markChanged();
10500
10932
  }
10501
10933
  }
10502
10934
  get backgroundColor() {
@@ -10505,7 +10937,7 @@ var CIcon = class {
10505
10937
  set backgroundColor(value) {
10506
10938
  if (this._backgroundColor !== value) {
10507
10939
  this._backgroundColor = value;
10508
- this._onChange?.();
10940
+ this.markChanged();
10509
10941
  }
10510
10942
  }
10511
10943
  get fillColor() {
@@ -10515,7 +10947,7 @@ var CIcon = class {
10515
10947
  if (this._fillColor !== value) {
10516
10948
  this._fillColor = value;
10517
10949
  this.loadSource(this._source);
10518
- this._onChange?.();
10950
+ this.markChanged();
10519
10951
  }
10520
10952
  }
10521
10953
  get loaded() {
@@ -10527,31 +10959,12 @@ var CIcon = class {
10527
10959
  set bindsNotationIcon(value) {
10528
10960
  if (this._bindsNotationIcon !== value) {
10529
10961
  this._bindsNotationIcon = value;
10530
- this._onChange?.();
10962
+ this.markChanged();
10531
10963
  }
10532
10964
  }
10533
- setOnChange(cb) {
10534
- this._onChange = cb;
10535
- }
10536
10965
  async loadSource(source) {
10537
- if (isSvgMarkup(source)) {
10538
- const tinted = this._fillColor ? tintSvg(source, void 0, this._fillColor) : source;
10539
- this._image.src = svgToDataUrl(tinted);
10540
- } else if (isSvgUrl(source) && this._fillColor) try {
10541
- const tinted = tintSvg(await this.fetchSvg(source), void 0, this._fillColor);
10542
- this._image.src = svgToDataUrl(tinted);
10543
- } catch {
10544
- this._image.src = source;
10545
- }
10546
- else this._image.src = source;
10547
- }
10548
- async fetchSvg(url) {
10549
- let promise = svgFetchCache.get(url);
10550
- if (!promise) {
10551
- promise = fetch(url).then((r) => r.text());
10552
- svgFetchCache.set(url, promise);
10553
- }
10554
- return promise;
10966
+ const resolvedSource = await resolveTintedSvgDataUrl(source, { fillColor: this._fillColor });
10967
+ this._image.src = resolvedSource ?? source;
10555
10968
  }
10556
10969
  measure(_ctx) {
10557
10970
  return {
@@ -10578,11 +10991,6 @@ var CIcon = class {
10578
10991
  }
10579
10992
  ctx.restore();
10580
10993
  }
10581
- hitTest(point, bounds) {
10582
- if (this.style.visible === false) return null;
10583
- if (point.x >= bounds.x && point.x <= bounds.x + bounds.width && point.y >= bounds.y && point.y <= bounds.y + bounds.height) return this;
10584
- return null;
10585
- }
10586
10994
  serialize() {
10587
10995
  const data = {
10588
10996
  type: "icon",
@@ -10605,7 +11013,8 @@ var CIcon = class {
10605
11013
  const drawHeight = Math.min(this._height, bounds.height);
10606
11014
  const dx = bounds.x + (bounds.width - drawWidth) / 2;
10607
11015
  const dy = bounds.y + (bounds.height - drawHeight) / 2;
10608
- svg += `<image href="${escapeXmlAttr(this._source)}" x="${dx}" y="${dy}" width="${drawWidth}" height="${drawHeight}" />`;
11016
+ const source = resolveTintedSvgDataUrlSync(this._source, { fillColor: this._fillColor }) ?? this._source;
11017
+ svg += `<image href="${escapeXmlAttr(source)}" x="${dx}" y="${dy}" width="${drawWidth}" height="${drawHeight}" />`;
10609
11018
  return svg;
10610
11019
  }
10611
11020
  };
@@ -10622,8 +11031,9 @@ var DEFAULT_THICKNESS = 1;
10622
11031
  * - In a column container → horizontal line
10623
11032
  * - In a row container → vertical line
10624
11033
  */
10625
- var CDivider = class {
11034
+ var CDivider = class extends CompositeComponentBase {
10626
11035
  constructor(options = {}) {
11036
+ super();
10627
11037
  this.type = "divider";
10628
11038
  this.id = options.id;
10629
11039
  this._color = options.color ?? DEFAULT_COLOR;
@@ -10636,7 +11046,7 @@ var CDivider = class {
10636
11046
  set color(value) {
10637
11047
  if (this._color !== value) {
10638
11048
  this._color = value;
10639
- this._onChange?.();
11049
+ this.markChanged();
10640
11050
  }
10641
11051
  }
10642
11052
  get thickness() {
@@ -10645,12 +11055,9 @@ var CDivider = class {
10645
11055
  set thickness(value) {
10646
11056
  if (this._thickness !== value) {
10647
11057
  this._thickness = value;
10648
- this._onChange?.();
11058
+ this.markChanged();
10649
11059
  }
10650
11060
  }
10651
- setOnChange(cb) {
10652
- this._onChange = cb;
10653
- }
10654
11061
  measure(_ctx) {
10655
11062
  return {
10656
11063
  width: this._thickness,
@@ -10676,9 +11083,6 @@ var CDivider = class {
10676
11083
  ctx.stroke();
10677
11084
  ctx.restore();
10678
11085
  }
10679
- hitTest(_point, _bounds) {
10680
- return null;
10681
- }
10682
11086
  serialize() {
10683
11087
  const data = { type: "divider" };
10684
11088
  if (this.id !== void 0) data.id = this.id;
@@ -10893,12 +11297,19 @@ function flexLayout(container, config, children) {
10893
11297
  }
10894
11298
  //#endregion
10895
11299
  //#region src/elements/composite/CContainer.ts
11300
+ function isCContainer(component) {
11301
+ return component.type === "container";
11302
+ }
11303
+ function isCShape(component) {
11304
+ return component.type === "shape";
11305
+ }
10896
11306
  /**
10897
11307
  * Flex container component for CompositeNode.
10898
11308
  * Lays out children using a flexbox-like algorithm.
10899
11309
  */
10900
- var CContainer = class {
11310
+ var CContainer = class extends CompositeComponentBase {
10901
11311
  constructor(options = {}) {
11312
+ super();
10902
11313
  this.type = "container";
10903
11314
  this._cachedBounds = null;
10904
11315
  this._cachedContainerBounds = null;
@@ -10948,12 +11359,9 @@ var CContainer = class {
10948
11359
  this._children.splice(index, 0, child);
10949
11360
  this.handleChildChange();
10950
11361
  }
10951
- setOnChange(cb) {
10952
- this._onChange = cb;
10953
- }
10954
11362
  handleChildChange() {
10955
11363
  this._cachedBounds = null;
10956
- this._onChange?.();
11364
+ this.markChanged();
10957
11365
  }
10958
11366
  getFlexConfig() {
10959
11367
  return {
@@ -11047,11 +11455,11 @@ var CContainer = class {
11047
11455
  findById(id) {
11048
11456
  for (const child of this._children) {
11049
11457
  if (child.id === id) return child;
11050
- if (child.type === "container") {
11458
+ if (isCContainer(child)) {
11051
11459
  const found = child.findById(id);
11052
11460
  if (found) return found;
11053
11461
  }
11054
- if (child.type === "shape") {
11462
+ if (isCShape(child)) {
11055
11463
  const content = child.content;
11056
11464
  if (content) {
11057
11465
  const found = content.findById(id);
@@ -11107,8 +11515,9 @@ var CContainer = class {
11107
11515
  * A bordered box component for CompositeNode.
11108
11516
  * Can contain a CContainer for recursive composition (e.g. UML class sections).
11109
11517
  */
11110
- var CShape = class {
11518
+ var CShape = class extends CompositeComponentBase {
11111
11519
  constructor(options = {}) {
11520
+ super();
11112
11521
  this.type = "shape";
11113
11522
  this.id = options.id;
11114
11523
  this._borderColor = options.borderColor;
@@ -11119,7 +11528,7 @@ var CShape = class {
11119
11528
  this.onClick = options.onClick;
11120
11529
  this._content = options.content;
11121
11530
  this.style = options.style ?? {};
11122
- if (this._content) this._content.setOnChange(() => this._onChange?.());
11531
+ if (this._content) this._content.setOnChange(() => this.markChanged());
11123
11532
  }
11124
11533
  get borderColor() {
11125
11534
  return this._borderColor;
@@ -11127,7 +11536,7 @@ var CShape = class {
11127
11536
  set borderColor(value) {
11128
11537
  if (this._borderColor !== value) {
11129
11538
  this._borderColor = value;
11130
- this._onChange?.();
11539
+ this.markChanged();
11131
11540
  }
11132
11541
  }
11133
11542
  get borderWidth() {
@@ -11136,7 +11545,7 @@ var CShape = class {
11136
11545
  set borderWidth(value) {
11137
11546
  if (this._borderWidth !== value) {
11138
11547
  this._borderWidth = value;
11139
- this._onChange?.();
11548
+ this.markChanged();
11140
11549
  }
11141
11550
  }
11142
11551
  get backgroundColor() {
@@ -11145,7 +11554,7 @@ var CShape = class {
11145
11554
  set backgroundColor(value) {
11146
11555
  if (this._backgroundColor !== value) {
11147
11556
  this._backgroundColor = value;
11148
- this._onChange?.();
11557
+ this.markChanged();
11149
11558
  }
11150
11559
  }
11151
11560
  get cornerRadius() {
@@ -11157,9 +11566,6 @@ var CShape = class {
11157
11566
  get content() {
11158
11567
  return this._content;
11159
11568
  }
11160
- setOnChange(cb) {
11161
- this._onChange = cb;
11162
- }
11163
11569
  measure(ctx) {
11164
11570
  const pad = normalizeSides(this._padding);
11165
11571
  const bw = this._borderWidth;
@@ -11210,8 +11616,7 @@ var CShape = class {
11210
11616
  ctx.restore();
11211
11617
  }
11212
11618
  hitTest(point, bounds) {
11213
- if (this.style.visible === false) return null;
11214
- if (point.x < bounds.x || point.x > bounds.x + bounds.width || point.y < bounds.y || point.y > bounds.y + bounds.height) return null;
11619
+ if (!super.hitTest(point, bounds)) return null;
11215
11620
  if (this._content) {
11216
11621
  const pad = normalizeSides(this._padding);
11217
11622
  const bw = this._borderWidth;
@@ -12028,9 +12433,14 @@ var Serializer = class {
12028
12433
  if (!Array.isArray(data.nodes)) throw new SerializerValidationError("Invalid or missing nodes array");
12029
12434
  if (!Array.isArray(data.edges)) throw new SerializerValidationError("Invalid or missing edges array");
12030
12435
  const nodeIds = new Set(data.nodes.map((n) => n.id));
12436
+ const edgeIds = new Set(data.edges.map((e) => e.id));
12031
12437
  for (const edge of data.edges) {
12032
- if (!nodeIds.has(edge.from.nodeId)) throw new SerializerValidationError(`Edge "${edge.id}" references non-existent source node "${edge.from.nodeId}"`);
12033
- if (!nodeIds.has(edge.to.nodeId)) throw new SerializerValidationError(`Edge "${edge.id}" references non-existent target node "${edge.to.nodeId}"`);
12438
+ if (edge.from.edgeId) {
12439
+ if (!edgeIds.has(edge.from.edgeId)) throw new SerializerValidationError(`Edge "${edge.id}" references non-existent host edge "${edge.from.edgeId}"`);
12440
+ } else if (!edge.from.nodeId || !nodeIds.has(edge.from.nodeId)) throw new SerializerValidationError(`Edge "${edge.id}" references non-existent source node "${edge.from.nodeId}"`);
12441
+ if (edge.to.edgeId) {
12442
+ if (!edgeIds.has(edge.to.edgeId)) throw new SerializerValidationError(`Edge "${edge.id}" references non-existent host edge "${edge.to.edgeId}"`);
12443
+ } else if (!edge.to.nodeId || !nodeIds.has(edge.to.nodeId)) throw new SerializerValidationError(`Edge "${edge.id}" references non-existent target node "${edge.to.nodeId}"`);
12034
12444
  }
12035
12445
  }
12036
12446
  /**
@@ -12299,174 +12709,6 @@ var ImageExporter = class {
12299
12709
  }
12300
12710
  };
12301
12711
  //#endregion
12302
- //#region src/utils/markers.ts
12303
- /**
12304
- * Calculate marker points based on edge path
12305
- */
12306
- function calculateMarkerPoints(path, position, type) {
12307
- if (path.length < 2) return null;
12308
- if (type === "bezier" && path.length >= 4) {
12309
- const epsilon = .001;
12310
- const isSame = (a, b) => Math.abs(a.x - b.x) < epsilon && Math.abs(a.y - b.y) < epsilon;
12311
- if (position === "end") {
12312
- const endIndex = path.length - 1;
12313
- const endPoint = path[endIndex];
12314
- let from = path[endIndex - 1];
12315
- if (isSame(from, endPoint)) {
12316
- from = path[endIndex - 2];
12317
- if (isSame(from, endPoint)) from = path[0];
12318
- }
12319
- return {
12320
- from,
12321
- to: endPoint
12322
- };
12323
- }
12324
- const start = path[0];
12325
- let next = path[1];
12326
- if (isSame(next, start)) {
12327
- next = path[2];
12328
- if (isSame(next, start)) next = path[path.length - 1];
12329
- }
12330
- return {
12331
- from: next,
12332
- to: start
12333
- };
12334
- }
12335
- if (position === "end") return {
12336
- from: path[path.length - 2],
12337
- to: path[path.length - 1]
12338
- };
12339
- return {
12340
- from: path[1],
12341
- to: path[0]
12342
- };
12343
- }
12344
- /**
12345
- * Calculate arrow marker vertex points
12346
- */
12347
- function calculateArrowMarkerPoints(to, angle, size) {
12348
- return {
12349
- tip: to,
12350
- left: {
12351
- x: to.x - size * Math.cos(angle - ARROW_ANGLE),
12352
- y: to.y - size * Math.sin(angle - ARROW_ANGLE)
12353
- },
12354
- right: {
12355
- x: to.x - size * Math.cos(angle + ARROW_ANGLE),
12356
- y: to.y - size * Math.sin(angle + ARROW_ANGLE)
12357
- }
12358
- };
12359
- }
12360
- /**
12361
- * Calculate diamond marker vertex points
12362
- */
12363
- function calculateDiamondMarkerPoints(to, angle, size) {
12364
- const halfLength = size / 2;
12365
- const halfWidth = size * .3;
12366
- const cos = Math.cos(angle);
12367
- const sin = Math.sin(angle);
12368
- return [
12369
- {
12370
- x: to.x,
12371
- y: to.y
12372
- },
12373
- {
12374
- x: to.x - halfLength * cos + halfWidth * sin,
12375
- y: to.y - halfLength * sin - halfWidth * cos
12376
- },
12377
- {
12378
- x: to.x - size * cos,
12379
- y: to.y - size * sin
12380
- },
12381
- {
12382
- x: to.x - halfLength * cos - halfWidth * sin,
12383
- y: to.y - halfLength * sin + halfWidth * cos
12384
- }
12385
- ];
12386
- }
12387
- /**
12388
- * Calculate circle marker center
12389
- */
12390
- function calculateCircleMarkerCenter(to, angle, size) {
12391
- return {
12392
- x: to.x - size * Math.cos(angle),
12393
- y: to.y - size * Math.sin(angle)
12394
- };
12395
- }
12396
- /**
12397
- * Generate SVG path for arrow marker
12398
- */
12399
- function generateSvgArrowMarker(to, angle, size, fill, fillOpacity, stroke) {
12400
- const { left, right } = calculateArrowMarkerPoints(to, angle, size);
12401
- return `<path d="M ${to.x} ${to.y} L ${left.x} ${left.y} L ${right.x} ${right.y} Z" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="1"/>`;
12402
- }
12403
- /**
12404
- * Generate SVG path for open arrow marker
12405
- */
12406
- function generateSvgOpenArrowMarker(to, angle, size, stroke) {
12407
- const { left, right } = calculateArrowMarkerPoints(to, angle, size);
12408
- return `<path d="M ${to.x} ${to.y} L ${left.x} ${left.y} M ${to.x} ${to.y} L ${right.x} ${right.y}" fill="none" stroke="${stroke}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>`;
12409
- }
12410
- /**
12411
- * Generate SVG path for diamond marker
12412
- */
12413
- function generateSvgDiamondMarker(to, angle, size, fill, fillOpacity, stroke) {
12414
- const points = calculateDiamondMarkerPoints(to, angle, size);
12415
- return `<path d="M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y} L ${points[2].x} ${points[2].y} L ${points[3].x} ${points[3].y} Z" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="1"/>`;
12416
- }
12417
- /**
12418
- * Generate SVG circle marker
12419
- */
12420
- function generateSvgCircleMarker(to, angle, size, fill, fillOpacity, stroke) {
12421
- const { x: cx, y: cy } = calculateCircleMarkerCenter(to, angle, size);
12422
- return `<circle cx="${cx}" cy="${cy}" r="${size}" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="1"/>`;
12423
- }
12424
- /**
12425
- * Square marker: front edge centered at `to`, depth 2×size along the edge toward `from`.
12426
- */
12427
- function generateSvgSquareMarker(to, angle, size, fill, fillOpacity, stroke) {
12428
- const cos = Math.cos(angle);
12429
- const sin = Math.sin(angle);
12430
- const px = -sin;
12431
- const py = cos;
12432
- const p1 = {
12433
- x: to.x + size * px,
12434
- y: to.y + size * py
12435
- };
12436
- const p2 = {
12437
- x: to.x - size * px,
12438
- y: to.y - size * py
12439
- };
12440
- const bx = 2 * size * cos;
12441
- const by = 2 * size * sin;
12442
- const p3 = {
12443
- x: p2.x - bx,
12444
- y: p2.y - by
12445
- };
12446
- const p4 = {
12447
- x: p1.x - bx,
12448
- y: p1.y - by
12449
- };
12450
- return `<path d="M ${p1.x} ${p1.y} L ${p2.x} ${p2.y} L ${p3.x} ${p3.y} L ${p4.x} ${p4.y} Z" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="1"/>`;
12451
- }
12452
- /**
12453
- * Generate SVG path for any marker type
12454
- */
12455
- function generateSvgMarker(marker, from, to, edgeStroke) {
12456
- const angle = Math.atan2(to.y - from.y, to.x - from.x);
12457
- const size = marker.size ?? 12;
12458
- const stroke = marker.strokeColor ?? edgeStroke;
12459
- const fill = marker.fillColor ?? stroke;
12460
- const fillOpacity = marker.fillOpacity ?? 1;
12461
- switch (marker.type) {
12462
- case "open": return generateSvgOpenArrowMarker(to, angle, size, stroke);
12463
- case "diamond": return generateSvgDiamondMarker(to, angle, size, fill, fillOpacity, stroke);
12464
- case "circle": return generateSvgCircleMarker(to, angle, size, fill, fillOpacity, stroke);
12465
- case "square": return generateSvgSquareMarker(to, angle, size, fill, fillOpacity, stroke);
12466
- default: return generateSvgArrowMarker(to, angle, size, fill, fillOpacity, stroke);
12467
- }
12468
- }
12469
- //#endregion
12470
12712
  //#region src/utils/SvgExporter.ts
12471
12713
  /**
12472
12714
  * Exports diagram to SVG
@@ -12969,9 +13211,7 @@ var SvgExporter = class {
12969
13211
  const iconSize = icon.getSize();
12970
13212
  if (iconSize.width <= 0 || iconSize.height <= 0) return "";
12971
13213
  const iconInset = icon.inset;
12972
- const iconBoxSize = this.getIconBoxSize(iconSize, iconInset);
12973
- const iconBounds = this.getIconBounds(nodeBounds, iconBoxSize, opts.placement ?? "center", iconInset);
12974
- const drawRect = this.getIconDrawRect(iconBounds, opts, iconSize, iconInset);
13214
+ const drawRect = computeIconDrawRectFromOptions(getIconBounds(nodeBounds, getIconBoxSize(iconSize, iconInset), opts.placement ?? "center", iconInset), opts, iconSize, iconInset);
12975
13215
  if (drawRect.width <= 0 || drawRect.height <= 0) return "";
12976
13216
  const href = this.resolveIconHref(opts);
12977
13217
  if (!href) return "";
@@ -12979,150 +13219,14 @@ var SvgExporter = class {
12979
13219
  const escapedHref = this.escapeAttribute(href);
12980
13220
  return `<image href="${escapedHref}" xlink:href="${escapedHref}" x="${drawRect.x}" y="${drawRect.y}" width="${drawRect.width}" height="${drawRect.height}" opacity="${opacity}" preserveAspectRatio="none"/>`;
12981
13221
  }
12982
- getIconBoxSize(imageSize, inset) {
12983
- return {
12984
- width: imageSize.width + inset * 2,
12985
- height: imageSize.height + inset * 2
12986
- };
12987
- }
12988
- getIconBounds(bounds, iconBoxSize, placement, inset) {
12989
- switch (placement) {
12990
- case "top": return {
12991
- x: bounds.x,
12992
- y: bounds.y,
12993
- width: bounds.width,
12994
- height: iconBoxSize.height
12995
- };
12996
- case "bottom": return {
12997
- x: bounds.x,
12998
- y: bounds.y + bounds.height - iconBoxSize.height,
12999
- width: bounds.width,
13000
- height: iconBoxSize.height
13001
- };
13002
- case "left": return {
13003
- x: bounds.x,
13004
- y: bounds.y,
13005
- width: iconBoxSize.width,
13006
- height: bounds.height
13007
- };
13008
- case "right": return {
13009
- x: bounds.x + bounds.width - iconBoxSize.width,
13010
- y: bounds.y,
13011
- width: iconBoxSize.width,
13012
- height: bounds.height
13013
- };
13014
- case "top-left": return {
13015
- x: bounds.x + inset,
13016
- y: bounds.y + inset,
13017
- width: iconBoxSize.width,
13018
- height: iconBoxSize.height
13019
- };
13020
- case "top-right": return {
13021
- x: bounds.x + bounds.width - iconBoxSize.width - inset,
13022
- y: bounds.y + inset,
13023
- width: iconBoxSize.width,
13024
- height: iconBoxSize.height
13025
- };
13026
- case "bottom-left": return {
13027
- x: bounds.x + inset,
13028
- y: bounds.y + bounds.height - iconBoxSize.height - inset,
13029
- width: iconBoxSize.width,
13030
- height: iconBoxSize.height
13031
- };
13032
- case "bottom-right": return {
13033
- x: bounds.x + bounds.width - iconBoxSize.width - inset,
13034
- y: bounds.y + bounds.height - iconBoxSize.height - inset,
13035
- width: iconBoxSize.width,
13036
- height: iconBoxSize.height
13037
- };
13038
- default: return bounds;
13039
- }
13040
- }
13041
- getIconDrawRect(bounds, opts, imageSize, inset) {
13042
- const fit = opts.fit ?? "none";
13043
- const scaleWithBounds = opts.scaleWithBounds ?? false;
13044
- const innerBounds = {
13045
- x: bounds.x + inset,
13046
- y: bounds.y + inset,
13047
- width: Math.max(0, bounds.width - inset * 2),
13048
- height: Math.max(0, bounds.height - inset * 2)
13049
- };
13050
- const availableWidth = Math.max(0, innerBounds.width);
13051
- const availableHeight = Math.max(0, innerBounds.height);
13052
- let drawWidth = opts.width ?? imageSize.width;
13053
- let drawHeight = opts.height ?? imageSize.height;
13054
- if (scaleWithBounds) {
13055
- if ((fit === "contain" || fit === "cover") && imageSize.width > 0 && imageSize.height > 0) {
13056
- const scaleX = availableWidth / imageSize.width;
13057
- const scaleY = availableHeight / imageSize.height;
13058
- const scale = fit === "contain" ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY);
13059
- drawWidth = imageSize.width * scale;
13060
- drawHeight = imageSize.height * scale;
13061
- } else if (fit === "stretch") {
13062
- drawWidth = availableWidth;
13063
- drawHeight = availableHeight;
13064
- }
13065
- }
13066
- drawWidth = Math.min(Math.max(0, drawWidth), Math.max(0, availableWidth));
13067
- drawHeight = Math.min(Math.max(0, drawHeight), Math.max(0, availableHeight));
13068
- return {
13069
- x: innerBounds.x + (innerBounds.width - drawWidth) / 2,
13070
- y: innerBounds.y + (innerBounds.height - drawHeight) / 2,
13071
- width: drawWidth,
13072
- height: drawHeight
13073
- };
13074
- }
13075
13222
  resolveIconHref(opts) {
13076
13223
  const source = opts.source;
13077
13224
  if (source instanceof HTMLImageElement) return source.src;
13078
13225
  if (!source) return "";
13079
- if (this.isSvgMarkup(source)) return this.svgToDataUrl(this.tintSvg(source, opts.strokeColor, opts.fillColor));
13080
- if (source.toLowerCase().endsWith(".svg")) {
13081
- const svgText = this.readSvgFromUrlSync(source);
13082
- if (svgText) return this.svgToDataUrl(this.tintSvg(svgText, opts.strokeColor, opts.fillColor));
13083
- }
13084
- return source;
13085
- }
13086
- readSvgFromUrlSync(url) {
13087
- if (typeof XMLHttpRequest === "undefined") return null;
13088
- try {
13089
- const xhr = new XMLHttpRequest();
13090
- xhr.open("GET", url, false);
13091
- xhr.send();
13092
- if (xhr.status >= 200 && xhr.status < 300) return xhr.responseText || null;
13093
- } catch {}
13094
- return null;
13095
- }
13096
- isSvgMarkup(value) {
13097
- const trimmed = value.trim().toLowerCase();
13098
- return trimmed.startsWith("<svg") || trimmed.includes("<svg");
13099
- }
13100
- styleSetColor(style, key, color) {
13101
- if (new RegExp(`${key}\\s*:`).test(style)) return style.replace(new RegExp(`${key}\\s*:[^;]+`), `${key}:${color}`);
13102
- return `${style}${style.trim().endsWith(";") || style.trim() === "" ? "" : ";"}${key}:${color};`;
13103
- }
13104
- tintSvg(svgText, strokeColor, fillColor) {
13105
- if (!strokeColor && !fillColor) return svgText;
13106
- const root = new DOMParser().parseFromString(svgText, "image/svg+xml").documentElement;
13107
- if (!root || root.nodeName.toLowerCase() === "parsererror") return svgText;
13108
- const all = [root, ...Array.from(root.querySelectorAll("*"))];
13109
- for (const el of all) {
13110
- const stroke = el.getAttribute("stroke");
13111
- if (strokeColor && stroke !== null && stroke.toLowerCase() !== "none") el.setAttribute("stroke", strokeColor);
13112
- const fill = el.getAttribute("fill");
13113
- if (fillColor && fill !== null && fill.toLowerCase() !== "none") el.setAttribute("fill", fillColor);
13114
- const style = el.getAttribute("style");
13115
- if (style) {
13116
- let next = style;
13117
- if (strokeColor && /stroke\s*:\s*(?!none)/.test(style)) next = this.styleSetColor(next, "stroke", strokeColor);
13118
- if (fillColor && /fill\s*:\s*(?!none)/.test(style)) next = this.styleSetColor(next, "fill", fillColor);
13119
- if (next !== style) el.setAttribute("style", next);
13120
- }
13121
- }
13122
- return new XMLSerializer().serializeToString(root);
13123
- }
13124
- svgToDataUrl(svg) {
13125
- return `data:image/svg+xml;utf8,${encodeURIComponent(svg).replace(/%0A/g, "").replace(/%0D/g, "").replace(/%09/g, " ").replace(/%20/g, " ")}`;
13226
+ return resolveTintedSvgDataUrlSync(source, {
13227
+ strokeColor: opts.strokeColor,
13228
+ fillColor: opts.fillColor
13229
+ }) ?? source;
13126
13230
  }
13127
13231
  escapeAttribute(value) {
13128
13232
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -13179,8 +13283,11 @@ var AutoRouting = class {
13179
13283
  const type = options.type ?? "polyline";
13180
13284
  for (const edge of renderer.edges.values()) {
13181
13285
  edge.type = type;
13182
- const fromNode = renderer.getNode(edge.from.nodeId);
13183
- const toNode = renderer.getNode(edge.to.nodeId);
13286
+ const fromId = edge.from.nodeId;
13287
+ const toId = edge.to.nodeId;
13288
+ if (!fromId || !toId) continue;
13289
+ const fromNode = renderer.getNode(fromId);
13290
+ const toNode = renderer.getNode(toId);
13184
13291
  if (!fromNode || !toNode) continue;
13185
13292
  const fromPoint = edge.from.portId !== void 0 ? fromNode.getPortPosition(edge.from.portId) ?? fromNode.getCenter() : fromNode.getCenter();
13186
13293
  const toPoint = edge.to.portId !== void 0 ? toNode.getPortPosition(edge.to.portId) ?? toNode.getCenter() : toNode.getCenter();
@@ -13419,11 +13526,10 @@ var GuidesOverlay = class extends BaseOverlay {
13419
13526
  };
13420
13527
  //#endregion
13421
13528
  //#region src/core/overlays/MiniMap.ts
13422
- var MiniMap = class {
13529
+ var MiniMap = class extends BaseOverlay {
13423
13530
  constructor(options = {}) {
13424
- this.removeOverlay = null;
13531
+ super(options.enabled ?? true);
13425
13532
  this.options = {
13426
- enabled: options.enabled ?? true,
13427
13533
  width: options.width ?? 180,
13428
13534
  height: options.height ?? 120,
13429
13535
  padding: options.padding ?? 12,
@@ -13436,7 +13542,7 @@ var MiniMap = class {
13436
13542
  }
13437
13543
  install(renderer) {
13438
13544
  this.removeOverlay = renderer.addTopOverlayRenderer((ctx) => {
13439
- if (!this.options.enabled) return;
13545
+ if (!this.enabled) return;
13440
13546
  const layout = this.computeLayout(renderer);
13441
13547
  if (!layout) return;
13442
13548
  const { x, y, width, height, bounds, scale, mapOffsetX, mapOffsetY, viewRect } = layout;
@@ -13485,13 +13591,6 @@ var MiniMap = class {
13485
13591
  ctx.restore();
13486
13592
  });
13487
13593
  }
13488
- destroy() {
13489
- this.removeOverlay?.();
13490
- this.removeOverlay = null;
13491
- }
13492
- setEnabled(enabled) {
13493
- this.options.enabled = enabled;
13494
- }
13495
13594
  beginOverlayDrag(renderer, screenX, screenY) {
13496
13595
  const hit = this.hitTestMiniMap(renderer, screenX, screenY);
13497
13596
  if (!hit) return null;
@@ -13508,7 +13607,7 @@ var MiniMap = class {
13508
13607
  return this.hitTestMiniMap(renderer, screenX, screenY) !== null;
13509
13608
  }
13510
13609
  updateOverlayDrag(renderer, screenX, screenY, payload) {
13511
- if (!this.options.enabled) return false;
13610
+ if (!this.enabled) return false;
13512
13611
  if (typeof payload !== "object" || payload === null || !("kind" in payload)) return false;
13513
13612
  const dragPayload = payload;
13514
13613
  if (dragPayload.kind === "block") return true;
@@ -13530,7 +13629,7 @@ var MiniMap = class {
13530
13629
  return true;
13531
13630
  }
13532
13631
  hitTestMiniMap(renderer, screenX, screenY) {
13533
- if (!this.options.enabled) return null;
13632
+ if (!this.enabled) return null;
13534
13633
  const layout = this.computeLayout(renderer);
13535
13634
  if (!layout) return null;
13536
13635
  const canvasPoint = renderer.screenToCanvas(screenX, screenY);
@@ -13663,6 +13762,6 @@ function applyEdgeStyle(ctx, style) {
13663
13762
  return baseOpacity * strokeOpacity;
13664
13763
  }
13665
13764
  //#endregion
13666
- export { AddNodeCommand, AnimationManager, AutoLayout, AutoRouting, CContainer, CDivider, CIcon, CShape, CText, ChangeEditablePolylineControlPointsCommand, CircleNode, CompositeCommand, CompositeNode, ConnectionManager, ContextMenuManager, CustomShapeNode, DARK_THEME, DEFAULT_THEME, DiagramRenderer, DiamondNode, DragManager, Edge, Element, EventEmitter, GridOverlay, Group, GuidesOverlay, HistoryManager, ImageExporter, InputHandler, InteractionManager, MiniMap, MoveNodesCommand, NavigationManager, Node, NodeImage, Port, RectangleNode, RemoveNodeCommand, ResizeManager, RulersOverlay, SearchManager, SelectionManager, Serializer, ShapeFactories, StyleManager, SvgExporter, TextLabel, alignNodes, angle, applyEdgeStyle, applyNodeStyle, bezierPoint, boundsCenter, calculateBezierControlPoints, clamp, clonePoints, container, deserializeCComponent, distance, distanceToSegment, distributeNodes, divider, drawRoundedRectPath, expandBounds, flexLayout, generateId, icon, isCornerPlacement, lerp, mergeBounds, normalizeSides, pointInEllipse, pointInRect, rectIntersection, rectUnion, rectsIntersect, renderFillAndStroke, resetIdCounter, resetPortIdCounter, rotatePoint, segmentRectIntersections, shape, snapPointToGrid, snapToGrid, text };
13765
+ export { AddNodeCommand, AnimationManager, AutoLayout, AutoRouting, BezierPathStrategy, CContainer, CDivider, CIcon, CShape, CText, ChangeEdgePropertiesCommand, ChangeEditablePolylineControlPointsCommand, ChangeGroupPropertiesCommand, ChangeNodePropertiesCommand, CircleNode, ClipboardManager, CompositeCommand, CompositeNode, ConnectionManager, ContextMenuManager, CustomShapeNode, DARK_THEME, DEFAULT_THEME, DiagramRenderer, DiamondNode, DragManager, Edge, Element, EventEmitter, GridOverlay, Group, GuidesOverlay, HistoryManager, ImageExporter, InputHandler, InteractionManager, MiniMap, MoveNodesCommand, NavigationManager, Node, NodeImage, PolylinePathStrategy, Port, PropertyChangeBatcher, RectangleNode, RemoveNodeCommand, ResizeManager, RulersOverlay, SearchManager, SelectionManager, Serializer, ShapeFactories, StraightPathStrategy, StyleManager, SvgExporter, TextLabel, alignNodes, angle, applyEdgeStyle, applyNodeStyle, bezierPoint, boundsCenter, calculateBezierControlPoints, clamp, clonePoints, container, deserializeCComponent, directionFromAngle, distance, distanceToSegment, distributeNodes, divider, drawRoundedRectPath, expandBounds, flexLayout, generateId, getClosestPointOnPath, getPathPointAt, icon, isCornerPlacement, isEdgeEdgeEndpoint, isNodeEdgeEndpoint, lerp, mergeBounds, normalizeSides, pointInEllipse, pointInRect, rectIntersection, rectUnion, rectsIntersect, renderFillAndStroke, resetIdCounter, resetPortIdCounter, rotatePoint, segmentRectIntersections, shape, snapPointToGrid, snapToGrid, text };
13667
13766
 
13668
13767
  //# sourceMappingURL=papirus.js.map