@ngroznykh/papirus 0.3.13 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/papirus.js CHANGED
@@ -435,6 +435,7 @@ const ANCHOR_POINT_HITBOX_RADIUS = 10;
435
435
  const NODE_HITBOX_PADDING = 10;
436
436
  const ANCHOR_PORT_PREFIX = "anchor:";
437
437
  const BEZIER_MAX_OFFSET = 100;
438
+ const OUTLINE_SNAP_SCREEN_TOLERANCE = 40;
438
439
  const SELECTION_RECT_MIN_SIZE = 1;
439
440
  const DEFAULT_SELECTION_COLOR = "#3b82f6";
440
441
  const DEFAULT_HOVER_COLOR = "#6366f1";
@@ -1571,16 +1572,29 @@ class ConnectionManager extends EventEmitter {
1571
1572
  this.hoverDisabled = false;
1572
1573
  this.reconnectPoint = null;
1573
1574
  this.previewTargetAnchorId = null;
1575
+ this.sourceOutlineParam = null;
1576
+ this.previewTargetOutlineParam = null;
1577
+ this.previewTargetNodeId = null;
1574
1578
  this.isReconnecting = false;
1575
1579
  this.reconnectingEdge = null;
1576
1580
  this.reconnectingEndpoint = null;
1577
1581
  this.originalEdgeEndpoint = null;
1582
+ this.reconnectingOutlineParam = null;
1583
+ this.reconnectingTargetNodeId = null;
1578
1584
  this.activeControlPointDrag = null;
1579
1585
  this.renderer = options.renderer;
1580
1586
  this.createEdge = options.createEdge;
1581
1587
  this.addEdge = options.addEdge ?? ((edge) => this.renderer.addEdge(edge));
1582
1588
  this.snapToGrid = options.snapToGrid ?? false;
1583
1589
  this.gridSize = options.gridSize ?? 20;
1590
+ this.attachToOutline = options.attachToOutline ?? false;
1591
+ }
1592
+ /**
1593
+ * Enable/disable attach-to-outline mode (edges attach anywhere on shape contour)
1594
+ */
1595
+ setAttachToOutline(enabled) {
1596
+ this.attachToOutline = enabled;
1597
+ this.renderer.attachToOutline = enabled;
1584
1598
  }
1585
1599
  /**
1586
1600
  * Enable/disable snap to grid for editable polyline control points.
@@ -1646,9 +1660,17 @@ class ConnectionManager extends EventEmitter {
1646
1660
  this.sourceNode = node;
1647
1661
  this.isConnecting = true;
1648
1662
  const target = { x: event.worldX, y: event.worldY };
1649
- const anchor = node.getNearestAnchor(target);
1650
- this.sourceAnchorId = anchor?.id ?? null;
1651
- this.sourcePoint = anchor?.point ?? node.getConnectionPoint(target);
1663
+ if (this.attachToOutline) {
1664
+ const { point, param } = node.getClosestPointOnOutline(target);
1665
+ this.sourceOutlineParam = param;
1666
+ this.sourceAnchorId = null;
1667
+ this.sourcePoint = point;
1668
+ } else {
1669
+ const anchor = node.getNearestAnchor(target);
1670
+ this.sourceOutlineParam = null;
1671
+ this.sourceAnchorId = anchor?.id ?? null;
1672
+ this.sourcePoint = anchor?.point ?? node.getConnectionPoint(target);
1673
+ }
1652
1674
  this.previewEndpoint = target;
1653
1675
  this.emit("connectionStart", node);
1654
1676
  return true;
@@ -1668,7 +1690,9 @@ class ConnectionManager extends EventEmitter {
1668
1690
  return false;
1669
1691
  }
1670
1692
  /**
1671
- * Try to start connection from a hovered anchor point
1693
+ * Try to start connection from a hovered anchor point.
1694
+ * When attachToOutline + Shift: start from anywhere on node outline.
1695
+ * Otherwise: requires clicking on an anchor so that node drag still works.
1672
1696
  */
1673
1697
  tryStartConnectionAtPoint(event) {
1674
1698
  const point = { x: event.worldX, y: event.worldY };
@@ -1676,6 +1700,18 @@ class ConnectionManager extends EventEmitter {
1676
1700
  if (!node) {
1677
1701
  return false;
1678
1702
  }
1703
+ if (this.attachToOutline && event.shiftKey) {
1704
+ const { point: outlinePoint, param } = node.getClosestPointOnOutline(point);
1705
+ this.sourceNode = node;
1706
+ this.isConnecting = true;
1707
+ this.sourceAnchorId = null;
1708
+ this.sourcePoint = outlinePoint;
1709
+ this.sourceOutlineParam = param;
1710
+ this.previewEndpoint = point;
1711
+ this.emit("connectionStart", node);
1712
+ this.renderer.markDirty();
1713
+ return true;
1714
+ }
1679
1715
  const hover = this.getAnchorAtPoint(node, point);
1680
1716
  if (!hover) {
1681
1717
  return false;
@@ -1685,6 +1721,12 @@ class ConnectionManager extends EventEmitter {
1685
1721
  this.sourceAnchorId = hover.id;
1686
1722
  this.sourcePoint = hover.point;
1687
1723
  this.previewEndpoint = point;
1724
+ if (this.attachToOutline) {
1725
+ const { param } = node.getClosestPointOnOutline(point);
1726
+ this.sourceOutlineParam = param;
1727
+ } else {
1728
+ this.sourceOutlineParam = null;
1729
+ }
1688
1730
  this.emit("connectionStart", node);
1689
1731
  this.renderer.markDirty();
1690
1732
  return true;
@@ -1735,32 +1777,122 @@ class ConnectionManager extends EventEmitter {
1735
1777
  const toNode = this.renderer.getNode(edge.to.nodeId);
1736
1778
  let snappedPoint = point;
1737
1779
  let snappedDir;
1738
- const targetNode = this.getNodeAtPoint(point, true);
1739
- if (targetNode) {
1780
+ let targetNode = this.getNodeAtPoint(point, true);
1781
+ if (this.attachToOutline) {
1782
+ const tolerance = OUTLINE_SNAP_SCREEN_TOLERANCE / Math.max(this.renderer.zoom, 1e-4);
1783
+ const toleranceSq = tolerance * tolerance;
1784
+ let bestDistSq = Infinity;
1785
+ let bestResult = null;
1786
+ for (const node of this.renderer.nodes.values()) {
1787
+ if (!node.visible) continue;
1788
+ const { point: outlinePoint, param } = node.getClosestPointOnOutline(point);
1789
+ const dx = point.x - outlinePoint.x;
1790
+ const dy = point.y - outlinePoint.y;
1791
+ const distSq = dx * dx + dy * dy;
1792
+ if (distSq <= toleranceSq && distSq < bestDistSq) {
1793
+ bestDistSq = distSq;
1794
+ bestResult = { node, point: outlinePoint, param };
1795
+ }
1796
+ }
1797
+ if (bestResult) {
1798
+ targetNode = bestResult.node;
1799
+ snappedPoint = bestResult.point;
1800
+ this.reconnectingOutlineParam = bestResult.param;
1801
+ this.reconnectingTargetNodeId = bestResult.node.id;
1802
+ snappedDir = this.getDirectionFromOutlineParam(bestResult.param, bestResult.node);
1803
+ const otherNode = this.reconnectingEndpoint === "start" ? toNode : fromNode;
1804
+ if (otherNode) {
1805
+ const alignRef = edge.isEditablePolyline() ? this.getNearestBendPointForAxisAlignment(edge, this.reconnectingEndpoint) : this.reconnectingEndpoint === "start" ? this.getTargetPointForReconnect(edge, toNode, "to", snappedPoint) : this.getTargetPointForReconnect(edge, fromNode, "from", snappedPoint);
1806
+ snappedPoint = this.applyAxisAlignmentToPoint(snappedPoint, alignRef);
1807
+ const reprojected = targetNode.getClosestPointOnOutline(snappedPoint);
1808
+ snappedPoint = reprojected.point;
1809
+ this.reconnectingOutlineParam = reprojected.param;
1810
+ snappedDir = this.getDirectionFromOutlineParam(reprojected.param, targetNode);
1811
+ }
1812
+ } else {
1813
+ this.reconnectingOutlineParam = null;
1814
+ this.reconnectingTargetNodeId = null;
1815
+ }
1816
+ } else if (targetNode) {
1740
1817
  const nearestAnchor = targetNode.getNearestAnchor(point);
1741
1818
  if (nearestAnchor) {
1742
1819
  snappedPoint = nearestAnchor.point;
1743
1820
  snappedDir = nearestAnchor.id.split(":")[0];
1744
1821
  }
1822
+ this.reconnectingOutlineParam = null;
1823
+ this.reconnectingTargetNodeId = null;
1824
+ } else {
1825
+ this.reconnectingOutlineParam = null;
1826
+ this.reconnectingTargetNodeId = null;
1745
1827
  }
1746
1828
  this.reconnectPoint = snappedPoint;
1747
1829
  if (this.reconnectingEndpoint === "start") {
1748
1830
  if (toNode) {
1749
- const toAnchorId = edge.to.portId?.replace(ANCHOR_PORT_PREFIX, "");
1750
- const target = toAnchorId ? toNode.getAnchorPointById(toAnchorId) ?? toNode.getConnectionPoint(snappedPoint) : toNode.getConnectionPoint(snappedPoint);
1751
- const toDir = toAnchorId?.split(":")[0];
1831
+ const target = this.getTargetPointForReconnect(edge, toNode, "to", snappedPoint);
1832
+ const toDir = this.getTargetDirForReconnect(edge, toNode, "to");
1752
1833
  edge.updateEndpoints(snappedPoint, target, snappedDir, toDir);
1753
1834
  }
1754
1835
  } else {
1755
1836
  if (fromNode) {
1756
- const fromAnchorId = edge.from.portId?.replace(ANCHOR_PORT_PREFIX, "");
1757
- const start = fromAnchorId ? fromNode.getAnchorPointById(fromAnchorId) ?? fromNode.getConnectionPoint(snappedPoint) : fromNode.getConnectionPoint(snappedPoint);
1758
- const fromDir = fromAnchorId?.split(":")[0];
1837
+ const start = this.getTargetPointForReconnect(edge, fromNode, "from", snappedPoint);
1838
+ const fromDir = this.getTargetDirForReconnect(edge, fromNode, "from");
1759
1839
  edge.updateEndpoints(start, snappedPoint, fromDir, snappedDir);
1760
1840
  }
1761
1841
  }
1762
1842
  this.renderer.markDirty();
1763
1843
  }
1844
+ getTargetPointForReconnect(edge, node, endpoint, fallbackPoint) {
1845
+ const ep = endpoint === "from" ? edge.from : edge.to;
1846
+ if (this.attachToOutline && ep.outlineParam !== void 0) {
1847
+ return node.getConnectionPointAtOutlineParam(ep.outlineParam);
1848
+ }
1849
+ const anchorId = ep.portId?.replace(ANCHOR_PORT_PREFIX, "");
1850
+ if (anchorId) {
1851
+ return node.getAnchorPointById(anchorId) ?? node.getConnectionPoint(fallbackPoint);
1852
+ }
1853
+ return node.getConnectionPoint(fallbackPoint);
1854
+ }
1855
+ getTargetDirForReconnect(edge, node, endpoint) {
1856
+ const ep = endpoint === "from" ? edge.from : edge.to;
1857
+ if (this.attachToOutline && ep.outlineParam !== void 0) {
1858
+ return this.getDirectionFromOutlineParam(ep.outlineParam, node);
1859
+ }
1860
+ const anchorId = ep.portId?.replace(ANCHOR_PORT_PREFIX, "");
1861
+ return anchorId?.split(":")[0];
1862
+ }
1863
+ getDirectionFromOutlineParam(param, _node) {
1864
+ const p = (param % 1 + 1) % 1;
1865
+ if (p < 0.25) return "top";
1866
+ if (p < 0.5) return "right";
1867
+ if (p < 0.75) return "bottom";
1868
+ return "left";
1869
+ }
1870
+ /**
1871
+ * For editable polyline: return the nearest bend point for axis alignment.
1872
+ * When reconnecting 'end', use last control point (or start). When reconnecting 'start', use first control point (or end).
1873
+ */
1874
+ getNearestBendPointForAxisAlignment(edge, endpoint) {
1875
+ const cps = edge.isEditablePolyline() ? edge.controlPoints ?? edge.getEditableControlPoints() : [];
1876
+ if (endpoint === "end") {
1877
+ return cps.length > 0 ? cps[cps.length - 1] : edge.startPoint;
1878
+ }
1879
+ return cps.length > 0 ? cps[0] : edge.endPoint;
1880
+ }
1881
+ /**
1882
+ * When attachToOutline: snap moving point to horizontal/vertical alignment with fixed point.
1883
+ */
1884
+ applyAxisAlignmentToPoint(moving, fixed) {
1885
+ const tolerance = EDGE_AXIS_MAGNET_SCREEN_TOLERANCE / Math.max(this.renderer.zoom, 1e-4);
1886
+ let x = moving.x;
1887
+ let y = moving.y;
1888
+ if (Math.abs(moving.x - fixed.x) <= tolerance) {
1889
+ x = fixed.x;
1890
+ }
1891
+ if (Math.abs(moving.y - fixed.y) <= tolerance) {
1892
+ y = fixed.y;
1893
+ }
1894
+ return { x, y };
1895
+ }
1764
1896
  /**
1765
1897
  * Handle mouse move during connection
1766
1898
  */
@@ -1792,25 +1924,60 @@ class ConnectionManager extends EventEmitter {
1792
1924
  const cursorPoint = { x: event.worldX, y: event.worldY };
1793
1925
  let snappedPoint = cursorPoint;
1794
1926
  this.previewTargetAnchorId = null;
1795
- const targetNode = this.getNodeAtPoint(cursorPoint, false);
1796
- if (targetNode && this.isCompatibleTarget(targetNode)) {
1927
+ this.previewTargetOutlineParam = null;
1928
+ this.previewTargetNodeId = null;
1929
+ let targetNode = this.getNodeAtPoint(cursorPoint, false);
1930
+ if (this.attachToOutline) {
1931
+ const tolerance = OUTLINE_SNAP_SCREEN_TOLERANCE / Math.max(this.renderer.zoom, 1e-4);
1932
+ const toleranceSq = tolerance * tolerance;
1933
+ let bestDistSq = Infinity;
1934
+ let bestResult = null;
1935
+ for (const node of this.renderer.nodes.values()) {
1936
+ if (!node.visible || !this.isCompatibleTarget(node)) continue;
1937
+ const forbidden = this._connectionValidator && this.sourceNode && !this._connectionValidator(this.sourceNode.id, node.id);
1938
+ if (forbidden) continue;
1939
+ const { point: outlinePoint, param } = node.getClosestPointOnOutline(cursorPoint);
1940
+ const dx = cursorPoint.x - outlinePoint.x;
1941
+ const dy = cursorPoint.y - outlinePoint.y;
1942
+ const distSq = dx * dx + dy * dy;
1943
+ if (distSq <= toleranceSq && distSq < bestDistSq) {
1944
+ bestDistSq = distSq;
1945
+ bestResult = { node, point: outlinePoint, param };
1946
+ }
1947
+ }
1948
+ if (bestResult) {
1949
+ targetNode = bestResult.node;
1950
+ snappedPoint = bestResult.point;
1951
+ this.previewTargetOutlineParam = bestResult.param;
1952
+ this.previewTargetNodeId = bestResult.node.id;
1953
+ if (this.sourcePoint) {
1954
+ snappedPoint = this.applyAxisAlignmentToPoint(snappedPoint, this.sourcePoint);
1955
+ const reprojected = bestResult.node.getClosestPointOnOutline(snappedPoint);
1956
+ snappedPoint = reprojected.point;
1957
+ this.previewTargetOutlineParam = reprojected.param;
1958
+ }
1959
+ }
1960
+ } else if (targetNode && this.isCompatibleTarget(targetNode)) {
1797
1961
  const forbidden = this._connectionValidator && this.sourceNode && !this._connectionValidator(this.sourceNode.id, targetNode.id);
1798
- if (forbidden) {
1799
- this.setCursor("not-allowed");
1800
- } else {
1801
- this.setCursor("crosshair");
1962
+ if (!forbidden) {
1802
1963
  const nearestAnchor = targetNode.getNearestAnchor(cursorPoint);
1803
1964
  if (nearestAnchor) {
1804
1965
  snappedPoint = nearestAnchor.point;
1805
1966
  this.previewTargetAnchorId = nearestAnchor.id;
1806
1967
  }
1807
1968
  }
1969
+ }
1970
+ if (targetNode) {
1971
+ const forbidden = this._connectionValidator && this.sourceNode && !this._connectionValidator(this.sourceNode.id, targetNode.id);
1972
+ this.setCursor(forbidden ? "not-allowed" : "crosshair");
1808
1973
  } else {
1809
1974
  this.setCursor("crosshair");
1810
1975
  }
1811
1976
  this.previewEndpoint = snappedPoint;
1812
- if (this.sourceNode && !this.sourceAnchorId) {
1977
+ if (this.sourceNode && !this.sourceAnchorId && !this.sourceOutlineParam) {
1813
1978
  this.sourcePoint = this.sourceNode.getConnectionPoint(this.previewEndpoint);
1979
+ } else if (this.sourceNode && this.sourceOutlineParam !== null) {
1980
+ this.sourcePoint = this.sourceNode.getConnectionPointAtOutlineParam(this.sourceOutlineParam);
1814
1981
  }
1815
1982
  this.emit(
1816
1983
  "connectionMove",
@@ -1866,19 +2033,29 @@ class ConnectionManager extends EventEmitter {
1866
2033
  if (this.isReconnecting && this.reconnectingEdge) {
1867
2034
  const point2 = { x: event.worldX, y: event.worldY };
1868
2035
  let reconnected = false;
1869
- const node = this.getNodeAtPoint(point2, true);
2036
+ let node = this.getNodeAtPoint(point2, true);
2037
+ if (!node && this.attachToOutline && this.reconnectingTargetNodeId) {
2038
+ node = this.renderer.getNode(this.reconnectingTargetNodeId) ?? null;
2039
+ }
1870
2040
  if (node) {
1871
- const anchor = node.getNearestAnchor(point2);
1872
- if (this.reconnectingEndpoint === "start") {
1873
- this.reconnectingEdge.from = {
2041
+ if (this.attachToOutline && this.reconnectingOutlineParam !== null) {
2042
+ const endpoint = {
1874
2043
  nodeId: node.id,
1875
- portId: this.reconnectingEdge.lockAnchors && anchor ? `${ANCHOR_PORT_PREFIX}${anchor.id}` : void 0
2044
+ outlineParam: this.reconnectingOutlineParam
1876
2045
  };
2046
+ if (this.reconnectingEndpoint === "start") {
2047
+ this.reconnectingEdge.from = endpoint;
2048
+ } else {
2049
+ this.reconnectingEdge.to = endpoint;
2050
+ }
1877
2051
  } else {
1878
- this.reconnectingEdge.to = {
1879
- nodeId: node.id,
1880
- portId: this.reconnectingEdge.lockAnchors && anchor ? `${ANCHOR_PORT_PREFIX}${anchor.id}` : void 0
1881
- };
2052
+ const anchor = node.getNearestAnchor(point2);
2053
+ const portId = this.reconnectingEdge.lockAnchors && anchor ? `${ANCHOR_PORT_PREFIX}${anchor.id}` : void 0;
2054
+ if (this.reconnectingEndpoint === "start") {
2055
+ this.reconnectingEdge.from = { nodeId: node.id, portId };
2056
+ } else {
2057
+ this.reconnectingEdge.to = { nodeId: node.id, portId };
2058
+ }
1882
2059
  }
1883
2060
  reconnected = true;
1884
2061
  this.emit("edgeReconnect", this.reconnectingEdge, this.reconnectingEndpoint);
@@ -1900,20 +2077,36 @@ class ConnectionManager extends EventEmitter {
1900
2077
  }
1901
2078
  const point = { x: event.worldX, y: event.worldY };
1902
2079
  let createdEdge = null;
1903
- const targetNode = this.getNodeAtPoint(point, false);
2080
+ let targetNode = this.getNodeAtPoint(point, false);
2081
+ if (!targetNode && this.attachToOutline && this.previewTargetNodeId) {
2082
+ targetNode = this.renderer.getNode(this.previewTargetNodeId) ?? null;
2083
+ }
1904
2084
  if (targetNode) {
1905
2085
  const allowed = !this._connectionValidator || this._connectionValidator(this.sourceNode.id, targetNode.id);
1906
2086
  if (allowed) {
1907
- const nearestTargetAnchor = targetNode.getNearestAnchor(point);
1908
- const sourceAnchorId = this.sourceAnchorId;
1909
- const from = {
1910
- nodeId: this.sourceNode.id,
1911
- portId: sourceAnchorId ? `${ANCHOR_PORT_PREFIX}${sourceAnchorId}` : void 0
1912
- };
1913
- const to = {
1914
- nodeId: targetNode.id,
1915
- portId: nearestTargetAnchor ? `${ANCHOR_PORT_PREFIX}${nearestTargetAnchor.id}` : void 0
1916
- };
2087
+ let from;
2088
+ let to;
2089
+ if (this.attachToOutline) {
2090
+ const targetOutline = targetNode.getClosestPointOnOutline(point);
2091
+ from = {
2092
+ nodeId: this.sourceNode.id,
2093
+ outlineParam: this.sourceOutlineParam ?? void 0
2094
+ };
2095
+ to = {
2096
+ nodeId: targetNode.id,
2097
+ outlineParam: targetOutline.param
2098
+ };
2099
+ } else {
2100
+ const nearestTargetAnchor = targetNode.getNearestAnchor(point);
2101
+ from = {
2102
+ nodeId: this.sourceNode.id,
2103
+ portId: this.sourceAnchorId ? `${ANCHOR_PORT_PREFIX}${this.sourceAnchorId}` : void 0
2104
+ };
2105
+ to = {
2106
+ nodeId: targetNode.id,
2107
+ portId: nearestTargetAnchor ? `${ANCHOR_PORT_PREFIX}${nearestTargetAnchor.id}` : void 0
2108
+ };
2109
+ }
1917
2110
  createdEdge = this.createEdge(from, to);
1918
2111
  this.addEdge(createdEdge);
1919
2112
  this.emit("connect", createdEdge);
@@ -1940,7 +2133,9 @@ class ConnectionManager extends EventEmitter {
1940
2133
  */
1941
2134
  renderPreview(ctx) {
1942
2135
  if (this.isReconnecting && this.reconnectPoint) {
1943
- this.renderAnchorHighlights(ctx, this.reconnectPoint, true);
2136
+ if (!this.attachToOutline) {
2137
+ this.renderAnchorHighlights(ctx, this.reconnectPoint, true);
2138
+ }
1944
2139
  return;
1945
2140
  }
1946
2141
  if (!this.isConnecting || this.previewEndpoint === null) {
@@ -1948,14 +2143,16 @@ class ConnectionManager extends EventEmitter {
1948
2143
  }
1949
2144
  const start = this.sourcePoint;
1950
2145
  const end = this.previewEndpoint;
1951
- const fromDir = this.sourceAnchorId?.split(":")[0];
1952
- const toDir = this.previewTargetAnchorId?.split(":")[0];
2146
+ const fromDir = this.sourceOutlineParam !== null ? this.getDirectionFromOutlineParam(this.sourceOutlineParam, this.sourceNode) : this.sourceAnchorId?.split(":")[0];
2147
+ const toDir = this.previewTargetOutlineParam !== null ? this.getDirectionFromOutlineParam(this.previewTargetOutlineParam, this.sourceNode) : this.previewTargetAnchorId?.split(":")[0];
1953
2148
  ctx.strokeStyle = "#3b82f6";
1954
2149
  ctx.lineWidth = 2;
1955
2150
  ctx.setLineDash([6, 4]);
1956
2151
  this.drawBezierPreview(ctx, start, end, fromDir, toDir);
1957
2152
  ctx.setLineDash([]);
1958
- this.renderAnchorHighlights(ctx, end, false);
2153
+ if (!this.attachToOutline) {
2154
+ this.renderAnchorHighlights(ctx, end, false);
2155
+ }
1959
2156
  }
1960
2157
  /**
1961
2158
  * Get control point offset based on direction
@@ -2035,6 +2232,9 @@ class ConnectionManager extends EventEmitter {
2035
2232
  this.sourcePoint = null;
2036
2233
  this.previewEndpoint = null;
2037
2234
  this.sourceAnchorId = null;
2235
+ this.sourceOutlineParam = null;
2236
+ this.previewTargetOutlineParam = null;
2237
+ this.previewTargetNodeId = null;
2038
2238
  this.setCursor("");
2039
2239
  }
2040
2240
  resetReconnection() {
@@ -2045,6 +2245,8 @@ class ConnectionManager extends EventEmitter {
2045
2245
  this.reconnectingEdge = null;
2046
2246
  this.reconnectingEndpoint = null;
2047
2247
  this.originalEdgeEndpoint = null;
2248
+ this.reconnectingOutlineParam = null;
2249
+ this.reconnectingTargetNodeId = null;
2048
2250
  this.reconnectPoint = null;
2049
2251
  this.setCursor("");
2050
2252
  }
@@ -2056,6 +2258,9 @@ class ConnectionManager extends EventEmitter {
2056
2258
  return;
2057
2259
  }
2058
2260
  this.renderEditablePolylineControls(ctx);
2261
+ if (this.attachToOutline) {
2262
+ return;
2263
+ }
2059
2264
  if (!this.hoverNodeId) {
2060
2265
  return;
2061
2266
  }
@@ -4644,11 +4849,14 @@ class InteractionManager {
4644
4849
  this.navigationManager = new NavigationManager({ renderer: this.renderer });
4645
4850
  this.historyManager = new HistoryManager();
4646
4851
  this.keymap = { ...DEFAULT_KEYMAP, ...options.keymap };
4852
+ const attachToOutline = options.attachToOutline ?? false;
4853
+ this.renderer.attachToOutline = attachToOutline;
4647
4854
  this.connectionManager = new ConnectionManager({
4648
4855
  renderer: this.renderer,
4649
4856
  createEdge: options.createEdge ?? ((from, to) => new Edge({ from, to, type: "bezier", arrowType: "single" })),
4650
4857
  snapToGrid: options.snapToGrid ?? this.renderer.snapToGrid,
4651
4858
  gridSize: options.gridSize ?? 20,
4859
+ attachToOutline,
4652
4860
  addEdge: (edge) => {
4653
4861
  this.historyManager.execute({
4654
4862
  execute: () => this.renderer.addEdge(edge),
@@ -5474,7 +5682,13 @@ class InteractionManager {
5474
5682
  });
5475
5683
  }
5476
5684
  endpointsEqual(a, b) {
5477
- return a.nodeId === b.nodeId && (a.portId ?? null) === (b.portId ?? null);
5685
+ if (a.nodeId !== b.nodeId) return false;
5686
+ if ((a.portId ?? null) !== (b.portId ?? null)) return false;
5687
+ const aParam = a.outlineParam;
5688
+ const bParam = b.outlineParam;
5689
+ if (aParam === void 0 && bParam === void 0) return true;
5690
+ if (aParam === void 0 || bParam === void 0) return false;
5691
+ return Math.abs(aParam % 1 - bParam % 1) < 1e-9;
5478
5692
  }
5479
5693
  getSelectionBounds() {
5480
5694
  const selected = Array.from(this.selectionManager.selectedIds);
@@ -5957,6 +6171,7 @@ class DiagramRenderer extends EventEmitter {
5957
6171
  this._nodes = /* @__PURE__ */ new Map();
5958
6172
  this._edges = /* @__PURE__ */ new Map();
5959
6173
  this._groups = /* @__PURE__ */ new Map();
6174
+ this._attachToOutline = false;
5960
6175
  this.canvas = this.resolveCanvas(canvas);
5961
6176
  this.options = { ...DEFAULT_OPTIONS$1, ...options };
5962
6177
  this.scrollbar = this.resolveScrollbarOptions(options);
@@ -5972,6 +6187,16 @@ class DiagramRenderer extends EventEmitter {
5972
6187
  this.setupCanvas();
5973
6188
  this.startRenderLoop();
5974
6189
  }
6190
+ /** When true, ports and anchor points are hidden (attach anywhere on outline) */
6191
+ get attachToOutline() {
6192
+ return this._attachToOutline;
6193
+ }
6194
+ set attachToOutline(value) {
6195
+ if (this._attachToOutline !== value) {
6196
+ this._attachToOutline = value;
6197
+ this.markDirty();
6198
+ }
6199
+ }
5975
6200
  /**
5976
6201
  * Current zoom level
5977
6202
  */
@@ -6208,6 +6433,7 @@ class DiagramRenderer extends EventEmitter {
6208
6433
  */
6209
6434
  addNode(node) {
6210
6435
  node.setDirtyListener(() => this.markDirty());
6436
+ node.setAttachToOutlineGetter(() => this._attachToOutline);
6211
6437
  this._nodes.set(node.id, node);
6212
6438
  this.animationManager.registerEnter(node.id);
6213
6439
  this.markDirty();
@@ -6291,6 +6517,7 @@ class DiagramRenderer extends EventEmitter {
6291
6517
  }
6292
6518
  this._nodes.delete(nodeId);
6293
6519
  node.setDirtyListener(void 0);
6520
+ node.setAttachToOutlineGetter(void 0);
6294
6521
  this.markDirty();
6295
6522
  this.emit("nodeRemove", node);
6296
6523
  return true;
@@ -6542,13 +6769,15 @@ class DiagramRenderer extends EventEmitter {
6542
6769
  if (fromNode === void 0 || toNode === void 0) {
6543
6770
  continue;
6544
6771
  }
6545
- if (edge.lockAnchors) {
6772
+ if (edge.lockAnchors && edge.from.outlineParam === void 0) {
6546
6773
  if (!edge.from.portId) {
6547
6774
  const anchor = fromNode.getNearestAnchor(toNode.getCenter());
6548
6775
  if (anchor) {
6549
6776
  edge.from = { ...edge.from, portId: `${ANCHOR_PORT_PREFIX}${anchor.id}` };
6550
6777
  }
6551
6778
  }
6779
+ }
6780
+ if (edge.lockAnchors && edge.to.outlineParam === void 0) {
6552
6781
  if (!edge.to.portId) {
6553
6782
  const anchor = toNode.getNearestAnchor(fromNode.getCenter());
6554
6783
  if (anchor) {
@@ -6560,7 +6789,10 @@ class DiagramRenderer extends EventEmitter {
6560
6789
  let toPoint = toNode.getConnectionPoint(fromNode.getCenter());
6561
6790
  let fromDir;
6562
6791
  let toDir;
6563
- if (edge.lockAnchors && edge.from.portId?.startsWith(ANCHOR_PORT_PREFIX)) {
6792
+ if (edge.from.outlineParam !== void 0) {
6793
+ fromPoint = fromNode.getConnectionPointAtOutlineParam(edge.from.outlineParam);
6794
+ fromDir = this.getDirectionFromOutlineParam(edge.from.outlineParam);
6795
+ } else if (edge.lockAnchors && edge.from.portId?.startsWith(ANCHOR_PORT_PREFIX)) {
6564
6796
  const anchorId = edge.from.portId.slice(ANCHOR_PORT_PREFIX.length);
6565
6797
  const anchorPoint = fromNode.getAnchorPointById(anchorId);
6566
6798
  if (anchorPoint) {
@@ -6568,7 +6800,10 @@ class DiagramRenderer extends EventEmitter {
6568
6800
  fromDir = anchorId.split(":")[0];
6569
6801
  }
6570
6802
  }
6571
- if (edge.lockAnchors && edge.to.portId?.startsWith(ANCHOR_PORT_PREFIX)) {
6803
+ if (edge.to.outlineParam !== void 0) {
6804
+ toPoint = toNode.getConnectionPointAtOutlineParam(edge.to.outlineParam);
6805
+ toDir = this.getDirectionFromOutlineParam(edge.to.outlineParam);
6806
+ } else if (edge.lockAnchors && edge.to.portId?.startsWith(ANCHOR_PORT_PREFIX)) {
6572
6807
  const anchorId = edge.to.portId.slice(ANCHOR_PORT_PREFIX.length);
6573
6808
  const anchorPoint = toNode.getAnchorPointById(anchorId);
6574
6809
  if (anchorPoint) {
@@ -6586,6 +6821,13 @@ class DiagramRenderer extends EventEmitter {
6586
6821
  edge.updateEndpoints(fromPoint, toPoint, fromDir, toDir, { obstacles });
6587
6822
  }
6588
6823
  }
6824
+ getDirectionFromOutlineParam(param) {
6825
+ const p = (param % 1 + 1) % 1;
6826
+ if (p < 0.25) return "top";
6827
+ if (p < 0.5) return "right";
6828
+ if (p < 0.75) return "bottom";
6829
+ return "left";
6830
+ }
6589
6831
  renderScrollbars(ctx) {
6590
6832
  const metrics = this.getScrollbarMetrics();
6591
6833
  if (!metrics) {
@@ -7891,10 +8133,19 @@ class Node extends Element {
7891
8133
  get defaultSize() {
7892
8134
  return { ...this._defaultSize };
7893
8135
  }
8136
+ /**
8137
+ * Set getter for attachToOutline (called by DiagramRenderer when node is added)
8138
+ */
8139
+ setAttachToOutlineGetter(getter) {
8140
+ this._attachToOutlineGetter = getter;
8141
+ }
7894
8142
  /**
7895
8143
  * Render ports
7896
8144
  */
7897
8145
  renderPorts(ctx) {
8146
+ if (this._attachToOutlineGetter?.()) {
8147
+ return;
8148
+ }
7898
8149
  const shouldShow = this._showPortsAlways || this._ports.some((port) => port.hovered) || this._state === "hover" || this._state === "selected" || this._state === "dragging";
7899
8150
  if (!shouldShow) {
7900
8151
  return;
@@ -8177,6 +8428,52 @@ class Node extends Element {
8177
8428
  height: this._height
8178
8429
  };
8179
8430
  }
8431
+ /**
8432
+ * Get point on outline at normalized param (0-1 along perimeter).
8433
+ * Base implementation uses rectangular outline. Override for non-rectangular shapes.
8434
+ */
8435
+ getConnectionPointAtOutlineParam(param) {
8436
+ const bounds = this.getBounds();
8437
+ const { x, y, width: w, height: h } = bounds;
8438
+ const perimeter = 2 * (w + h);
8439
+ let s = (param % 1 + 1) % 1 * perimeter;
8440
+ if (s < w) return { x: x + s, y };
8441
+ s -= w;
8442
+ if (s < h) return { x: x + w, y: y + s };
8443
+ s -= h;
8444
+ if (s < w) return { x: x + w - s, y: y + h };
8445
+ s -= w;
8446
+ return { x, y: y + h - s };
8447
+ }
8448
+ /**
8449
+ * Get closest point on outline and its param (0-1).
8450
+ * Base implementation uses rectangular outline. Override for non-rectangular shapes.
8451
+ */
8452
+ getClosestPointOnOutline(target) {
8453
+ const bounds = this.getBounds();
8454
+ const { x, y, width: w, height: h } = bounds;
8455
+ const P = 2 * (w + h);
8456
+ const projectSegment = (ax, ay, bx, by, segLen, segStartParam) => {
8457
+ const dx = bx - ax;
8458
+ const dy = by - ay;
8459
+ const lenSq = dx * dx + dy * dy;
8460
+ let t = lenSq > 0 ? ((target.x - ax) * dx + (target.y - ay) * dy) / lenSq : 0;
8461
+ t = Math.max(0, Math.min(1, t));
8462
+ const px = ax + t * dx;
8463
+ const py = ay + t * dy;
8464
+ const distSq = (target.x - px) ** 2 + (target.y - py) ** 2;
8465
+ const param = segStartParam + t * segLen / P;
8466
+ return { point: { x: px, y: py }, param, distSq };
8467
+ };
8468
+ let best = projectSegment(x, y, x + w, y, w, 0);
8469
+ let r = projectSegment(x + w, y, x + w, y + h, h, w / P);
8470
+ if (r.distSq < best.distSq) best = r;
8471
+ r = projectSegment(x + w, y + h, x, y + h, w, (w + h) / P);
8472
+ if (r.distSq < best.distSq) best = r;
8473
+ r = projectSegment(x, y + h, x, y, h, (2 * w + h) / P);
8474
+ if (r.distSq < best.distSq) best = r;
8475
+ return { point: best.point, param: best.param };
8476
+ }
8180
8477
  /**
8181
8478
  * Get intersection point on the shape outline toward the target.
8182
8479
  * Override for non-rectangular shapes.
@@ -8724,6 +9021,30 @@ class CircleNode extends Node {
8724
9021
  const dy = point.y - center.y;
8725
9022
  return dx * dx / (rx * rx) + dy * dy / (ry * ry) <= 1;
8726
9023
  }
9024
+ getConnectionPointAtOutlineParam(param) {
9025
+ const center = this.getCenter();
9026
+ const rx = this._width / 2;
9027
+ const ry = this._height / 2;
9028
+ const t = (param % 1 + 1) % 1 * Math.PI * 2;
9029
+ const angle2 = Math.PI * 1.5 - t;
9030
+ return {
9031
+ x: center.x + rx * Math.cos(angle2),
9032
+ y: center.y + ry * Math.sin(angle2)
9033
+ };
9034
+ }
9035
+ getClosestPointOnOutline(target) {
9036
+ const point = this.getOutlinePointToward(target);
9037
+ const center = this.getCenter();
9038
+ const rx = this._width / 2;
9039
+ const ry = this._height / 2;
9040
+ const t = Math.atan2(
9041
+ (point.y - center.y) / ry,
9042
+ (point.x - center.x) / rx
9043
+ );
9044
+ const param = (Math.PI * 1.5 - t) / (Math.PI * 2);
9045
+ const normalizedParam = (param % 1 + 1) % 1;
9046
+ return { point, param: normalizedParam };
9047
+ }
8727
9048
  getOutlinePointToward(target) {
8728
9049
  const center = this.getCenter();
8729
9050
  const rx = this._width / 2;
@@ -8785,6 +9106,66 @@ class DiamondNode extends Node {
8785
9106
  const dy = Math.abs(point.y - center.y);
8786
9107
  return dx / hw + dy / hh <= 1;
8787
9108
  }
9109
+ getConnectionPointAtOutlineParam(param) {
9110
+ const center = this.getCenter();
9111
+ const hw = this._width / 2;
9112
+ const hh = this._height / 2;
9113
+ const vertices = [
9114
+ { x: center.x, y: center.y - hh },
9115
+ { x: center.x + hw, y: center.y },
9116
+ { x: center.x, y: center.y + hh },
9117
+ { x: center.x - hw, y: center.y }
9118
+ ];
9119
+ const segLen = Math.sqrt(hw * hw + hh * hh);
9120
+ const perimeter = 4 * segLen;
9121
+ let s = (param % 1 + 1) % 1 * perimeter;
9122
+ for (let i = 0; i < 4; i++) {
9123
+ if (s < segLen) {
9124
+ const a = vertices[i];
9125
+ const b = vertices[(i + 1) % 4];
9126
+ const t = s / segLen;
9127
+ return {
9128
+ x: a.x + t * (b.x - a.x),
9129
+ y: a.y + t * (b.y - a.y)
9130
+ };
9131
+ }
9132
+ s -= segLen;
9133
+ }
9134
+ return vertices[0];
9135
+ }
9136
+ getClosestPointOnOutline(target) {
9137
+ const center = this.getCenter();
9138
+ const hw = this._width / 2;
9139
+ const hh = this._height / 2;
9140
+ const vertices = [
9141
+ { x: center.x, y: center.y - hh },
9142
+ { x: center.x + hw, y: center.y },
9143
+ { x: center.x, y: center.y + hh },
9144
+ { x: center.x - hw, y: center.y }
9145
+ ];
9146
+ const segLen = Math.sqrt(hw * hw + hh * hh);
9147
+ const P = 4 * segLen;
9148
+ const projectSegment = (ax, ay, bx, by, segStartParam) => {
9149
+ const dx = bx - ax;
9150
+ const dy = by - ay;
9151
+ const lenSq = dx * dx + dy * dy;
9152
+ let t = lenSq > 0 ? ((target.x - ax) * dx + (target.y - ay) * dy) / lenSq : 0;
9153
+ t = Math.max(0, Math.min(1, t));
9154
+ const px = ax + t * dx;
9155
+ const py = ay + t * dy;
9156
+ const distSq = (target.x - px) ** 2 + (target.y - py) ** 2;
9157
+ const param = segStartParam + t * segLen / P;
9158
+ return { point: { x: px, y: py }, param, distSq };
9159
+ };
9160
+ let best = projectSegment(vertices[0].x, vertices[0].y, vertices[1].x, vertices[1].y, 0);
9161
+ for (let i = 1; i < 4; i++) {
9162
+ const a = vertices[i];
9163
+ const b = vertices[(i + 1) % 4];
9164
+ const r = projectSegment(a.x, a.y, b.x, b.y, i * segLen / P);
9165
+ if (r.distSq < best.distSq) best = r;
9166
+ }
9167
+ return { point: best.point, param: best.param };
9168
+ }
8788
9169
  getOutlinePointToward(target) {
8789
9170
  const center = this.getCenter();
8790
9171
  const dx = target.x - center.x;
@@ -10556,6 +10937,7 @@ class MiniMap {
10556
10937
  width: options.width ?? 180,
10557
10938
  height: options.height ?? 120,
10558
10939
  padding: options.padding ?? 12,
10940
+ contentMargin: options.contentMargin ?? 0,
10559
10941
  backgroundColor: options.backgroundColor ?? "rgba(24, 24, 27, 0.5)",
10560
10942
  borderColor: options.borderColor ?? "#52525b",
10561
10943
  viewportColor: options.viewportColor ?? "#22c55e",
@@ -10694,14 +11076,27 @@ class MiniMap {
10694
11076
  if (!rawBounds) {
10695
11077
  return null;
10696
11078
  }
11079
+ const margin = this.options.contentMargin;
11080
+ const expandedContentBounds = {
11081
+ x: rawBounds.x - margin,
11082
+ y: rawBounds.y - margin,
11083
+ width: rawBounds.width + 2 * margin,
11084
+ height: rawBounds.height + 2 * margin
11085
+ };
10697
11086
  const { width, height, padding, anchor } = this.options;
10698
11087
  const x = anchor === "bottom-left" || anchor === "top-left" ? padding : renderer.width - width - padding;
10699
11088
  const y = anchor === "top-left" || anchor === "top-right" ? padding : renderer.height - height - padding;
10700
11089
  const viewportBounds = this.getViewportBounds(renderer);
10701
- const unionMinX = Math.min(rawBounds.x, viewportBounds.x);
10702
- const unionMinY = Math.min(rawBounds.y, viewportBounds.y);
10703
- const unionMaxX = Math.max(rawBounds.x + rawBounds.width, viewportBounds.x + viewportBounds.width);
10704
- const unionMaxY = Math.max(rawBounds.y + rawBounds.height, viewportBounds.y + viewportBounds.height);
11090
+ const unionMinX = Math.min(expandedContentBounds.x, viewportBounds.x);
11091
+ const unionMinY = Math.min(expandedContentBounds.y, viewportBounds.y);
11092
+ const unionMaxX = Math.max(
11093
+ expandedContentBounds.x + expandedContentBounds.width,
11094
+ viewportBounds.x + viewportBounds.width
11095
+ );
11096
+ const unionMaxY = Math.max(
11097
+ expandedContentBounds.y + expandedContentBounds.height,
11098
+ viewportBounds.y + viewportBounds.height
11099
+ );
10705
11100
  const bounds = {
10706
11101
  x: unionMinX,
10707
11102
  y: unionMinY,