@ngroznykh/papirus 0.6.3 → 0.6.5

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 (77) 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 +2 -3
  6. package/dist/core/ConnectionManager.d.ts.map +1 -1
  7. package/dist/core/DiagramRenderer.d.ts +11 -28
  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 +18 -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/SelectionManager.d.ts +2 -2
  28. package/dist/core/SelectionManager.d.ts.map +1 -1
  29. package/dist/core/overlays/MiniMap.d.ts +4 -7
  30. package/dist/core/overlays/MiniMap.d.ts.map +1 -1
  31. package/dist/elements/Edge.d.ts +1 -22
  32. package/dist/elements/Edge.d.ts.map +1 -1
  33. package/dist/elements/Element.d.ts +1 -0
  34. package/dist/elements/Element.d.ts.map +1 -1
  35. package/dist/elements/Node.d.ts.map +1 -1
  36. package/dist/elements/NodeImage.d.ts.map +1 -1
  37. package/dist/elements/Port.d.ts +1 -0
  38. package/dist/elements/Port.d.ts.map +1 -1
  39. package/dist/elements/TextLabel.d.ts +0 -1
  40. package/dist/elements/TextLabel.d.ts.map +1 -1
  41. package/dist/elements/composite/CComponent.d.ts +3 -2
  42. package/dist/elements/composite/CComponent.d.ts.map +1 -1
  43. package/dist/elements/composite/CContainer.d.ts +2 -3
  44. package/dist/elements/composite/CContainer.d.ts.map +1 -1
  45. package/dist/elements/composite/CDivider.d.ts +3 -8
  46. package/dist/elements/composite/CDivider.d.ts.map +1 -1
  47. package/dist/elements/composite/CIcon.d.ts +4 -10
  48. package/dist/elements/composite/CIcon.d.ts.map +1 -1
  49. package/dist/elements/composite/CShape.d.ts +4 -5
  50. package/dist/elements/composite/CShape.d.ts.map +1 -1
  51. package/dist/elements/composite/CText.d.ts +3 -8
  52. package/dist/elements/composite/CText.d.ts.map +1 -1
  53. package/dist/elements/composite/CompositeComponentBase.d.ts +25 -0
  54. package/dist/elements/composite/CompositeComponentBase.d.ts.map +1 -0
  55. package/dist/elements/composite/CompositeNode.d.ts +11 -0
  56. package/dist/elements/composite/CompositeNode.d.ts.map +1 -1
  57. package/dist/elements/edge/EdgeLabelRenderer.d.ts +17 -0
  58. package/dist/elements/edge/EdgeLabelRenderer.d.ts.map +1 -0
  59. package/dist/elements/edge/EdgeMarkerRenderer.d.ts +11 -0
  60. package/dist/elements/edge/EdgeMarkerRenderer.d.ts.map +1 -0
  61. package/dist/index.d.ts +13 -6
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/papirus.js +1634 -1773
  64. package/dist/papirus.js.map +1 -1
  65. package/dist/utils/SvgExporter.d.ts +0 -8
  66. package/dist/utils/SvgExporter.d.ts.map +1 -1
  67. package/dist/utils/direction.d.ts +4 -0
  68. package/dist/utils/direction.d.ts.map +1 -1
  69. package/dist/utils/iconLayout.d.ts +34 -0
  70. package/dist/utils/iconLayout.d.ts.map +1 -0
  71. package/dist/utils/keymap.d.ts +11 -0
  72. package/dist/utils/keymap.d.ts.map +1 -0
  73. package/dist/utils/svgAssetLoader.d.ts +23 -0
  74. package/dist/utils/svgAssetLoader.d.ts.map +1 -0
  75. package/dist/utils/textMeasurer.d.ts +13 -0
  76. package/dist/utils/textMeasurer.d.ts.map +1 -0
  77. package/package.json +1 -1
package/dist/papirus.js CHANGED
@@ -1668,6 +1668,51 @@ var NavigationManager = class extends EventEmitter {
1668
1668
  }
1669
1669
  };
1670
1670
  //#endregion
1671
+ //#region src/utils/direction.ts
1672
+ /**
1673
+ * Map outline param [0,1) to cardinal direction (top → right → bottom → left).
1674
+ */
1675
+ function getDirectionFromOutlineParam(param) {
1676
+ const p = (param % 1 + 1) % 1;
1677
+ if (p < .25) return "top";
1678
+ if (p < .5) return "right";
1679
+ if (p < .75) return "bottom";
1680
+ return "left";
1681
+ }
1682
+ function isHorizontal(dir) {
1683
+ return dir === "left" || dir === "right";
1684
+ }
1685
+ function isVertical(dir) {
1686
+ return dir === "top" || dir === "bottom";
1687
+ }
1688
+ function isOppositeDirections(fromDir, toDir) {
1689
+ return fromDir === "left" && toDir === "right" || fromDir === "right" && toDir === "left" || fromDir === "top" && toDir === "bottom" || fromDir === "bottom" && toDir === "top";
1690
+ }
1691
+ function directionToVector(dir) {
1692
+ switch (dir) {
1693
+ case "top": return {
1694
+ x: 0,
1695
+ y: -1
1696
+ };
1697
+ case "bottom": return {
1698
+ x: 0,
1699
+ y: 1
1700
+ };
1701
+ case "left": return {
1702
+ x: -1,
1703
+ y: 0
1704
+ };
1705
+ case "right": return {
1706
+ x: 1,
1707
+ y: 0
1708
+ };
1709
+ default: return {
1710
+ x: 0,
1711
+ y: 0
1712
+ };
1713
+ }
1714
+ }
1715
+ //#endregion
1671
1716
  //#region src/core/ConnectionManager.ts
1672
1717
  /**
1673
1718
  * Manages port-to-port connection creation
@@ -1897,14 +1942,14 @@ var ConnectionManager = class extends EventEmitter {
1897
1942
  snappedPoint = bestResult.point;
1898
1943
  this.reconnectingOutlineParam = bestResult.param;
1899
1944
  this.reconnectingTargetNodeId = bestResult.node.id;
1900
- snappedDir = this.getDirectionFromOutlineParam(bestResult.param, bestResult.node);
1945
+ snappedDir = getDirectionFromOutlineParam(bestResult.param);
1901
1946
  if (this.reconnectingEndpoint === "start" ? toNode : fromNode) {
1902
1947
  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
1948
  snappedPoint = this.applyAxisAlignmentToPoint(snappedPoint, alignRef);
1904
1949
  const reprojected = targetNode.getClosestPointOnOutline(snappedPoint);
1905
1950
  snappedPoint = reprojected.point;
1906
1951
  this.reconnectingOutlineParam = reprojected.param;
1907
- snappedDir = this.getDirectionFromOutlineParam(reprojected.param, targetNode);
1952
+ snappedDir = getDirectionFromOutlineParam(reprojected.param);
1908
1953
  }
1909
1954
  } else {
1910
1955
  this.reconnectingOutlineParam = null;
@@ -1926,12 +1971,12 @@ var ConnectionManager = class extends EventEmitter {
1926
1971
  if (this.reconnectingEndpoint === "start") {
1927
1972
  if (toNode) {
1928
1973
  const target = this.getTargetPointForReconnect(edge, toNode, "to", snappedPoint);
1929
- const toDir = this.getTargetDirForReconnect(edge, toNode, "to");
1974
+ const toDir = this.getTargetDirForReconnect(edge, "to");
1930
1975
  edge.updateEndpoints(snappedPoint, target, snappedDir, toDir);
1931
1976
  }
1932
1977
  } else if (fromNode) {
1933
1978
  const start = this.getTargetPointForReconnect(edge, fromNode, "from", snappedPoint);
1934
- const fromDir = this.getTargetDirForReconnect(edge, fromNode, "from");
1979
+ const fromDir = this.getTargetDirForReconnect(edge, "from");
1935
1980
  edge.updateEndpoints(start, snappedPoint, fromDir, snappedDir);
1936
1981
  }
1937
1982
  this.renderer.markDirty();
@@ -1943,18 +1988,11 @@ var ConnectionManager = class extends EventEmitter {
1943
1988
  if (anchorId) return node.getAnchorPointById(anchorId) ?? node.getConnectionPoint(fallbackPoint);
1944
1989
  return node.getConnectionPoint(fallbackPoint);
1945
1990
  }
1946
- getTargetDirForReconnect(edge, node, endpoint) {
1991
+ getTargetDirForReconnect(edge, endpoint) {
1947
1992
  const ep = endpoint === "from" ? edge.from : edge.to;
1948
- if (this.attachToOutline && ep.outlineParam !== void 0) return this.getDirectionFromOutlineParam(ep.outlineParam, node);
1993
+ if (this.attachToOutline && ep.outlineParam !== void 0) return getDirectionFromOutlineParam(ep.outlineParam);
1949
1994
  return (ep.portId?.replace(ANCHOR_PORT_PREFIX, ""))?.split(":")[0];
1950
1995
  }
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
1996
  /**
1959
1997
  * For editable polyline: return the nearest bend point for axis alignment.
1960
1998
  * When reconnecting 'end', use last control point (or start). When reconnecting 'start', use first control point (or end).
@@ -2227,8 +2265,8 @@ var ConnectionManager = class extends EventEmitter {
2227
2265
  ctx.lineTo(end.x, end.y);
2228
2266
  ctx.stroke();
2229
2267
  } 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];
2268
+ const fromDir = this.sourceOutlineParam !== null ? getDirectionFromOutlineParam(this.sourceOutlineParam) : this.sourceAnchorId?.split(":")[0];
2269
+ const toDir = this.previewTargetOutlineParam !== null ? getDirectionFromOutlineParam(this.previewTargetOutlineParam) : this.previewTargetAnchorId?.split(":")[0];
2232
2270
  this.drawBezierPreview(ctx, start, end, fromDir, toDir);
2233
2271
  }
2234
2272
  ctx.setLineDash([]);
@@ -2577,6 +2615,25 @@ var ConnectionManager = class extends EventEmitter {
2577
2615
  }
2578
2616
  };
2579
2617
  //#endregion
2618
+ //#region src/utils/keymap.ts
2619
+ var DEFAULT_HISTORY_SHORTCUT_KEYS = {
2620
+ undoKey: "z",
2621
+ redoKey: "y"
2622
+ };
2623
+ /**
2624
+ * Resolves a letter key independently of the active keyboard layout.
2625
+ */
2626
+ function getShortcutKey(event) {
2627
+ return event.code.startsWith("Key") ? event.code.slice(3).toLowerCase() : event.key.toLowerCase();
2628
+ }
2629
+ function getHistoryShortcut(event, keys = DEFAULT_HISTORY_SHORTCUT_KEYS) {
2630
+ if (!event.ctrlKey && !event.metaKey) return null;
2631
+ const key = getShortcutKey(event);
2632
+ if (key === keys.undoKey.toLowerCase() && !event.shiftKey) return "undo";
2633
+ if (key === keys.redoKey.toLowerCase() || key === keys.undoKey.toLowerCase() && event.shiftKey) return "redo";
2634
+ return null;
2635
+ }
2636
+ //#endregion
2580
2637
  //#region src/utils/style.ts
2581
2638
  /**
2582
2639
  * Shallow compare two objects
@@ -2608,6 +2665,44 @@ function applyStyleManagerToElements(styleManager, groups, edges, nodes) {
2608
2665
  for (const node of nodes) node.applyStyleManager(styleManager);
2609
2666
  }
2610
2667
  //#endregion
2668
+ //#region src/utils/textMeasurer.ts
2669
+ /**
2670
+ * Wrap text by words while preserving explicit newline boundaries.
2671
+ */
2672
+ function wrapMeasuredText(ctx, text, maxWidth, options = {}) {
2673
+ const lines = [];
2674
+ for (const paragraph of text.split("\n")) {
2675
+ const words = paragraph.split(" ");
2676
+ let currentLine = "";
2677
+ for (const word of words) {
2678
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
2679
+ if (ctx.measureText(testLine).width > maxWidth && currentLine) {
2680
+ lines.push(currentLine);
2681
+ currentLine = word;
2682
+ } else currentLine = testLine;
2683
+ }
2684
+ lines.push(currentLine);
2685
+ }
2686
+ const maxLines = options.maxLines;
2687
+ if (maxLines !== void 0 && lines.length > maxLines) {
2688
+ const truncated = lines.slice(0, Math.max(0, maxLines));
2689
+ if (options.appendEllipsis && truncated.length > 0) {
2690
+ const lastIndex = truncated.length - 1;
2691
+ truncated[lastIndex] = `${truncated[lastIndex]}…`;
2692
+ }
2693
+ return truncated;
2694
+ }
2695
+ return lines;
2696
+ }
2697
+ /**
2698
+ * Return the widest measured line.
2699
+ */
2700
+ function measureTextLines(ctx, lines) {
2701
+ let maxWidth = 0;
2702
+ for (const line of lines) maxWidth = Math.max(maxWidth, ctx.measureText(line).width);
2703
+ return maxWidth;
2704
+ }
2705
+ //#endregion
2611
2706
  //#region src/elements/TextLabel.ts
2612
2707
  var DEFAULT_INSET = 8;
2613
2708
  function normalizeTextInset(value, fallback) {
@@ -2815,16 +2910,9 @@ var TextLabel = class {
2815
2910
  const lineHeight = (this._style.fontSize ?? 14) * 1.2;
2816
2911
  const effectiveMaxWidth = this._maxWidth ?? this._autoMaxWidth;
2817
2912
  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;
2913
+ if (effectiveMaxWidth !== void 0) this._lines = wrapMeasuredText(ctx, text, Math.max(0, effectiveMaxWidth - this._inset.left - this._inset.right));
2914
+ else this._lines = text.split("\n");
2915
+ this._measuredWidth = measureTextLines(ctx, this._lines) + this._inset.left + this._inset.right;
2828
2916
  this._measuredHeight = this._lines.length * lineHeight + this._inset.top + this._inset.bottom;
2829
2917
  this._measureDirty = false;
2830
2918
  return {
@@ -2896,23 +2984,6 @@ var TextLabel = class {
2896
2984
  ctx.textAlign = this._style.align ?? "center";
2897
2985
  ctx.textBaseline = this._style.baseline ?? "middle";
2898
2986
  }
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
2987
  };
2917
2988
  //#endregion
2918
2989
  //#region src/core/history/commands.ts
@@ -3282,16 +3353,14 @@ var HistoryManager = class extends EventEmitter {
3282
3353
  }
3283
3354
  /**
3284
3355
  * Handle keyboard shortcuts.
3285
- * Uses event.code for letter keys to work with any keyboard layout (e.g. Russian).
3286
3356
  */
3287
3357
  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) {
3358
+ const shortcut = getHistoryShortcut(event);
3359
+ if (shortcut === "undo") {
3291
3360
  event.preventDefault();
3292
3361
  return this.undo();
3293
3362
  }
3294
- if (isCtrlOrMeta && (key === "y" || key === "z" && event.shiftKey)) {
3363
+ if (shortcut === "redo") {
3295
3364
  event.preventDefault();
3296
3365
  return this.redo();
3297
3366
  }
@@ -3312,6 +3381,7 @@ function generateId(prefix = "el") {
3312
3381
  }
3313
3382
  /**
3314
3383
  * Reset ID counter (for testing)
3384
+ * @internal
3315
3385
  */
3316
3386
  function resetIdCounter() {
3317
3387
  nextId = 1;
@@ -3532,41 +3602,6 @@ var StraightPathStrategy = class {
3532
3602
  }
3533
3603
  };
3534
3604
  //#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
3605
  //#region src/elements/paths/PolylinePathStrategy.ts
3571
3606
  var MIN_SEGMENT_LENGTH = 20;
3572
3607
  var SELF_LOOP_MIN_DISTANCE$1 = 1;
@@ -4298,134 +4333,561 @@ var BezierPathStrategy = class {
4298
4333
  }
4299
4334
  };
4300
4335
  //#endregion
4301
- //#region src/elements/Edge.ts
4302
- var DEFAULT_EDGE_STYLE = {
4303
- strokeColor: "#666666",
4304
- strokeWidth: 2,
4305
- opacity: 1
4306
- };
4336
+ //#region src/utils/markers.ts
4307
4337
  /**
4308
- * Connection edge between nodes
4338
+ * Calculate marker points based on edge path
4309
4339
  */
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
4341
- };
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
- }
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
4380
- */
4381
- get type() {
4382
- return this._type;
4383
- }
4384
- set type(value) {
4385
- if (this._type !== value) {
4386
- this._type = value;
4387
- this._pathStrategy = this.getPathStrategy(value);
4388
- if (value !== "editable-polyline" && this._controlPoints?.length) this.controlPoints = void 0;
4389
- this.markDirty();
4340
+ function calculateMarkerPoints(path, position, type) {
4341
+ if (path.length < 2) return null;
4342
+ if (type === "bezier" && path.length >= 4) {
4343
+ const epsilon = .001;
4344
+ const isSame = (a, b) => Math.abs(a.x - b.x) < epsilon && Math.abs(a.y - b.y) < epsilon;
4345
+ if (position === "end") {
4346
+ const endIndex = path.length - 1;
4347
+ const endPoint = path[endIndex];
4348
+ let from = path[endIndex - 1];
4349
+ if (isSame(from, endPoint)) {
4350
+ from = path[endIndex - 2];
4351
+ if (isSame(from, endPoint)) from = path[0];
4352
+ }
4353
+ return {
4354
+ from,
4355
+ to: endPoint
4356
+ };
4390
4357
  }
4391
- }
4392
- /**
4393
- * Arrow type (legacy)
4394
- */
4395
- get arrowType() {
4396
- return this._arrowType;
4397
- }
4398
- set arrowType(value) {
4399
- if (this._arrowType !== value) {
4400
- this._arrowType = value;
4401
- this.markDirty();
4358
+ const start = path[0];
4359
+ let next = path[1];
4360
+ if (isSame(next, start)) {
4361
+ next = path[2];
4362
+ if (isSame(next, start)) next = path[path.length - 1];
4402
4363
  }
4364
+ return {
4365
+ from: next,
4366
+ to: start
4367
+ };
4403
4368
  }
4404
- /**
4405
- * Start marker configuration
4406
- */
4407
- get startMarker() {
4408
- return this._startMarker;
4409
- }
4410
- set startMarker(value) {
4411
- this._startMarker = value;
4412
- this.markDirty();
4413
- }
4414
- /**
4415
- * End marker configuration
4416
- */
4417
- get endMarker() {
4418
- return this._endMarker;
4419
- }
4420
- set endMarker(value) {
4421
- this._endMarker = value;
4422
- this.markDirty();
4423
- }
4424
- /**
4425
- * Label offset from center of path
4426
- */
4427
- get labelOffset() {
4428
- return this._labelOffset;
4369
+ if (position === "end") return {
4370
+ from: path[path.length - 2],
4371
+ to: path[path.length - 1]
4372
+ };
4373
+ return {
4374
+ from: path[1],
4375
+ to: path[0]
4376
+ };
4377
+ }
4378
+ /**
4379
+ * Calculate arrow marker vertex points
4380
+ */
4381
+ function calculateArrowMarkerPoints(to, angle, size) {
4382
+ return {
4383
+ tip: to,
4384
+ left: {
4385
+ x: to.x - size * Math.cos(angle - ARROW_ANGLE),
4386
+ y: to.y - size * Math.sin(angle - ARROW_ANGLE)
4387
+ },
4388
+ right: {
4389
+ x: to.x - size * Math.cos(angle + ARROW_ANGLE),
4390
+ y: to.y - size * Math.sin(angle + ARROW_ANGLE)
4391
+ }
4392
+ };
4393
+ }
4394
+ /**
4395
+ * Calculate diamond marker vertex points
4396
+ */
4397
+ function calculateDiamondMarkerPoints(to, angle, size) {
4398
+ const halfLength = size / 2;
4399
+ const halfWidth = size * .3;
4400
+ const cos = Math.cos(angle);
4401
+ const sin = Math.sin(angle);
4402
+ return [
4403
+ {
4404
+ x: to.x,
4405
+ y: to.y
4406
+ },
4407
+ {
4408
+ x: to.x - halfLength * cos + halfWidth * sin,
4409
+ y: to.y - halfLength * sin - halfWidth * cos
4410
+ },
4411
+ {
4412
+ x: to.x - size * cos,
4413
+ y: to.y - size * sin
4414
+ },
4415
+ {
4416
+ x: to.x - halfLength * cos - halfWidth * sin,
4417
+ y: to.y - halfLength * sin + halfWidth * cos
4418
+ }
4419
+ ];
4420
+ }
4421
+ /**
4422
+ * Calculate circle marker center
4423
+ */
4424
+ function calculateCircleMarkerCenter(to, angle, size) {
4425
+ return {
4426
+ x: to.x - size * Math.cos(angle),
4427
+ y: to.y - size * Math.sin(angle)
4428
+ };
4429
+ }
4430
+ /**
4431
+ * Generate SVG path for arrow marker
4432
+ */
4433
+ function generateSvgArrowMarker(to, angle, size, fill, fillOpacity, stroke) {
4434
+ const { left, right } = calculateArrowMarkerPoints(to, angle, size);
4435
+ 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"/>`;
4436
+ }
4437
+ /**
4438
+ * Generate SVG path for open arrow marker
4439
+ */
4440
+ function generateSvgOpenArrowMarker(to, angle, size, stroke) {
4441
+ const { left, right } = calculateArrowMarkerPoints(to, angle, size);
4442
+ 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"/>`;
4443
+ }
4444
+ /**
4445
+ * Generate SVG path for diamond marker
4446
+ */
4447
+ function generateSvgDiamondMarker(to, angle, size, fill, fillOpacity, stroke) {
4448
+ const points = calculateDiamondMarkerPoints(to, angle, size);
4449
+ 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"/>`;
4450
+ }
4451
+ /**
4452
+ * Generate SVG circle marker
4453
+ */
4454
+ function generateSvgCircleMarker(to, angle, size, fill, fillOpacity, stroke) {
4455
+ const { x: cx, y: cy } = calculateCircleMarkerCenter(to, angle, size);
4456
+ return `<circle cx="${cx}" cy="${cy}" r="${size}" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="1"/>`;
4457
+ }
4458
+ /**
4459
+ * Square marker: front edge centered at `to`, depth 2×size along the edge toward `from`.
4460
+ */
4461
+ function generateSvgSquareMarker(to, angle, size, fill, fillOpacity, stroke) {
4462
+ const cos = Math.cos(angle);
4463
+ const sin = Math.sin(angle);
4464
+ const px = -sin;
4465
+ const py = cos;
4466
+ const p1 = {
4467
+ x: to.x + size * px,
4468
+ y: to.y + size * py
4469
+ };
4470
+ const p2 = {
4471
+ x: to.x - size * px,
4472
+ y: to.y - size * py
4473
+ };
4474
+ const bx = 2 * size * cos;
4475
+ const by = 2 * size * sin;
4476
+ const p3 = {
4477
+ x: p2.x - bx,
4478
+ y: p2.y - by
4479
+ };
4480
+ const p4 = {
4481
+ x: p1.x - bx,
4482
+ y: p1.y - by
4483
+ };
4484
+ 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"/>`;
4485
+ }
4486
+ /**
4487
+ * Generate SVG path for any marker type
4488
+ */
4489
+ function generateSvgMarker(marker, from, to, edgeStroke) {
4490
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
4491
+ const size = marker.size ?? 12;
4492
+ const stroke = marker.strokeColor ?? edgeStroke;
4493
+ const fill = marker.fillColor ?? stroke;
4494
+ const fillOpacity = marker.fillOpacity ?? 1;
4495
+ switch (marker.type) {
4496
+ case "open": return generateSvgOpenArrowMarker(to, angle, size, stroke);
4497
+ case "diamond": return generateSvgDiamondMarker(to, angle, size, fill, fillOpacity, stroke);
4498
+ case "circle": return generateSvgCircleMarker(to, angle, size, fill, fillOpacity, stroke);
4499
+ case "square": return generateSvgSquareMarker(to, angle, size, fill, fillOpacity, stroke);
4500
+ default: return generateSvgArrowMarker(to, angle, size, fill, fillOpacity, stroke);
4501
+ }
4502
+ }
4503
+ //#endregion
4504
+ //#region src/elements/edge/EdgeMarkerRenderer.ts
4505
+ function getCanvasMarkerLength(config) {
4506
+ const size = config.size ?? MARKER_SIZES[config.type] ?? 10;
4507
+ switch (config.type) {
4508
+ case "arrow": return size * Math.cos(ARROW_ANGLE);
4509
+ case "open": return 0;
4510
+ case "diamond": return size;
4511
+ case "circle":
4512
+ case "square": return size * 2;
4513
+ default: return 0;
4514
+ }
4515
+ }
4516
+ function renderEdgeMarkers(ctx, options) {
4517
+ const { path, type, arrowType, startMarker, endMarker } = options;
4518
+ if (path.length < 2) return;
4519
+ if (startMarker !== void 0 || endMarker !== void 0) {
4520
+ renderConfiguredMarker(ctx, path, type, "end", endMarker);
4521
+ renderConfiguredMarker(ctx, path, type, "start", startMarker);
4522
+ return;
4523
+ }
4524
+ if (arrowType === "none") return;
4525
+ const endPoints = calculateMarkerPoints(path, "end", type);
4526
+ if (endPoints) drawLegacyArrowHead(ctx, endPoints.from, endPoints.to);
4527
+ if (arrowType === "double") {
4528
+ const startPoints = calculateMarkerPoints(path, "start", type);
4529
+ if (startPoints) drawLegacyArrowHead(ctx, startPoints.from, startPoints.to);
4530
+ }
4531
+ }
4532
+ function renderConfiguredMarker(ctx, path, type, position, config) {
4533
+ if (!config || config.type === "none") return;
4534
+ const points = calculateMarkerPoints(path, position, type);
4535
+ if (points) drawMarker(ctx, points.from, points.to, config);
4536
+ }
4537
+ function drawMarker(ctx, from, to, config) {
4538
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
4539
+ const size = config.size ?? MARKER_SIZES[config.type] ?? 10;
4540
+ const strokeColor = config.strokeColor ?? ctx.strokeStyle;
4541
+ const fillColor = config.fillColor ?? strokeColor;
4542
+ const fillOpacity = config.fillOpacity ?? 1;
4543
+ ctx.save();
4544
+ ctx.setLineDash([]);
4545
+ ctx.lineDashOffset = 0;
4546
+ ctx.strokeStyle = strokeColor;
4547
+ switch (config.type) {
4548
+ case "arrow":
4549
+ drawArrowMarker(ctx, to, angle, size, fillColor, fillOpacity);
4550
+ break;
4551
+ case "open":
4552
+ drawOpenArrowMarker(ctx, to, angle, size);
4553
+ break;
4554
+ case "diamond":
4555
+ drawDiamondMarker(ctx, to, angle, size, fillColor, fillOpacity);
4556
+ break;
4557
+ case "circle":
4558
+ drawCircleMarker(ctx, to, angle, size, fillColor, fillOpacity);
4559
+ break;
4560
+ case "square":
4561
+ drawSquareMarker(ctx, to, angle, size, fillColor, fillOpacity);
4562
+ break;
4563
+ }
4564
+ ctx.restore();
4565
+ }
4566
+ function drawArrowMarker(ctx, to, angle, size, fillColor, fillOpacity) {
4567
+ const { left, right } = calculateArrowMarkerPoints(to, angle, size);
4568
+ ctx.beginPath();
4569
+ ctx.moveTo(to.x, to.y);
4570
+ ctx.lineTo(left.x, left.y);
4571
+ ctx.lineTo(right.x, right.y);
4572
+ ctx.closePath();
4573
+ fillAndStroke(ctx, fillColor, fillOpacity);
4574
+ }
4575
+ function drawOpenArrowMarker(ctx, to, angle, size) {
4576
+ const { left, right } = calculateArrowMarkerPoints(to, angle, size);
4577
+ ctx.beginPath();
4578
+ ctx.moveTo(to.x, to.y);
4579
+ ctx.lineTo(left.x, left.y);
4580
+ ctx.moveTo(to.x, to.y);
4581
+ ctx.lineTo(right.x, right.y);
4582
+ ctx.stroke();
4583
+ }
4584
+ function drawDiamondMarker(ctx, to, angle, size, fillColor, fillOpacity) {
4585
+ const points = calculateDiamondMarkerPoints(to, angle, size);
4586
+ ctx.beginPath();
4587
+ ctx.moveTo(points[0].x, points[0].y);
4588
+ for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
4589
+ ctx.closePath();
4590
+ fillAndStroke(ctx, fillColor, fillOpacity);
4591
+ }
4592
+ function drawCircleMarker(ctx, to, angle, size, fillColor, fillOpacity) {
4593
+ const center = calculateCircleMarkerCenter(to, angle, size);
4594
+ ctx.beginPath();
4595
+ ctx.arc(center.x, center.y, size, 0, Math.PI * 2);
4596
+ fillAndStroke(ctx, fillColor, fillOpacity);
4597
+ }
4598
+ function drawSquareMarker(ctx, to, angle, size, fillColor, fillOpacity) {
4599
+ const cos = Math.cos(angle);
4600
+ const sin = Math.sin(angle);
4601
+ const perpendicularX = -sin;
4602
+ const perpendicularY = cos;
4603
+ const frontLeft = {
4604
+ x: to.x + size * perpendicularX,
4605
+ y: to.y + size * perpendicularY
4606
+ };
4607
+ const frontRight = {
4608
+ x: to.x - size * perpendicularX,
4609
+ y: to.y - size * perpendicularY
4610
+ };
4611
+ const depthX = 2 * size * cos;
4612
+ const depthY = 2 * size * sin;
4613
+ const backRight = {
4614
+ x: frontRight.x - depthX,
4615
+ y: frontRight.y - depthY
4616
+ };
4617
+ const backLeft = {
4618
+ x: frontLeft.x - depthX,
4619
+ y: frontLeft.y - depthY
4620
+ };
4621
+ ctx.beginPath();
4622
+ ctx.moveTo(frontLeft.x, frontLeft.y);
4623
+ ctx.lineTo(frontRight.x, frontRight.y);
4624
+ ctx.lineTo(backRight.x, backRight.y);
4625
+ ctx.lineTo(backLeft.x, backLeft.y);
4626
+ ctx.closePath();
4627
+ fillAndStroke(ctx, fillColor, fillOpacity);
4628
+ }
4629
+ function fillAndStroke(ctx, fillColor, fillOpacity) {
4630
+ ctx.globalAlpha = fillOpacity;
4631
+ ctx.fillStyle = fillColor;
4632
+ ctx.fill();
4633
+ ctx.globalAlpha = 1;
4634
+ ctx.stroke();
4635
+ }
4636
+ function drawLegacyArrowHead(ctx, from, to) {
4637
+ const { left, right } = calculateArrowMarkerPoints(to, Math.atan2(to.y - from.y, to.x - from.x), 10);
4638
+ ctx.save();
4639
+ ctx.setLineDash([]);
4640
+ ctx.lineDashOffset = 0;
4641
+ ctx.beginPath();
4642
+ ctx.moveTo(to.x, to.y);
4643
+ ctx.lineTo(left.x, left.y);
4644
+ ctx.moveTo(to.x, to.y);
4645
+ ctx.lineTo(right.x, right.y);
4646
+ ctx.stroke();
4647
+ ctx.restore();
4648
+ }
4649
+ //#endregion
4650
+ //#region src/elements/edge/EdgeLabelRenderer.ts
4651
+ function renderEdgeLabel(ctx, options) {
4652
+ const { label, path, background } = options;
4653
+ if (!label || path.length < 2) return;
4654
+ const labelCenter = getEdgeLabelPosition(options);
4655
+ if (!labelCenter) return;
4656
+ const rotation = getEdgeLabelRotation(options);
4657
+ const labelOpacity = label.style.opacity ?? 1;
4658
+ label.measure(ctx);
4659
+ const labelWidth = label.measuredWidth;
4660
+ const labelHeight = label.measuredHeight;
4661
+ const backgroundColor = background?.color ?? "#ffffff";
4662
+ const backgroundOpacity = background?.opacity ?? 1;
4663
+ const backgroundRadius = background?.borderRadius ?? 2;
4664
+ ctx.save();
4665
+ if (rotation !== 0) {
4666
+ ctx.translate(labelCenter.x, labelCenter.y);
4667
+ ctx.rotate(rotation);
4668
+ ctx.translate(-labelCenter.x, -labelCenter.y);
4669
+ }
4670
+ const backgroundX = labelCenter.x - labelWidth / 2;
4671
+ const backgroundY = labelCenter.y - labelHeight / 2;
4672
+ ctx.fillStyle = backgroundColor;
4673
+ ctx.globalAlpha = backgroundOpacity;
4674
+ if (backgroundRadius > 0) {
4675
+ drawRoundedRectPath(ctx, backgroundX, backgroundY, labelWidth, labelHeight, backgroundRadius);
4676
+ ctx.fill();
4677
+ } else ctx.fillRect(backgroundX, backgroundY, labelWidth, labelHeight);
4678
+ ctx.globalAlpha = labelOpacity;
4679
+ label.renderAt(ctx, labelCenter);
4680
+ ctx.globalAlpha = 1;
4681
+ ctx.restore();
4682
+ }
4683
+ function getEdgeLabelPosition(options) {
4684
+ if (options.path.length < 2) return null;
4685
+ const { point, angle } = getPathPointAt(options.path, options.type, options.position);
4686
+ const perpendicularAngle = options.followPath ? angle + Math.PI / 2 : Math.PI / 2;
4687
+ return {
4688
+ x: point.x + options.offset * Math.cos(perpendicularAngle),
4689
+ y: point.y + options.offset * Math.sin(perpendicularAngle)
4690
+ };
4691
+ }
4692
+ function getEdgeLabelRotation(options) {
4693
+ if (!options.followPath || options.path.length < 2) return 0;
4694
+ let angle = getPathPointAt(options.path, options.type, options.position).angle;
4695
+ if (angle > Math.PI / 2) angle -= Math.PI;
4696
+ if (angle < -Math.PI / 2) angle += Math.PI;
4697
+ return angle;
4698
+ }
4699
+ function getPathPointAt(path, type, position) {
4700
+ if (type !== "bezier" || path.length < 4) return getPointAlongPolyline(path, position);
4701
+ const samples = [];
4702
+ const steps = 20;
4703
+ for (let i = 1; i + 2 < path.length; i += 3) {
4704
+ const start = path[i - 1];
4705
+ const control1 = path[i];
4706
+ const control2 = path[i + 1];
4707
+ const end = path[i + 2];
4708
+ for (let sample = 0; sample <= steps; sample++) {
4709
+ const t = sample / steps;
4710
+ if (samples.length > 0 && t === 0) continue;
4711
+ samples.push(bezierPoint(start, control1, control2, end, t));
4712
+ }
4713
+ }
4714
+ return getPointAlongPolyline(samples, position);
4715
+ }
4716
+ function getPointAlongPolyline(path, position) {
4717
+ if (path.length === 0) return {
4718
+ point: {
4719
+ x: 0,
4720
+ y: 0
4721
+ },
4722
+ angle: 0
4723
+ };
4724
+ if (path.length === 1) return {
4725
+ point: path[0],
4726
+ angle: 0
4727
+ };
4728
+ let totalLength = 0;
4729
+ const segments = [];
4730
+ for (let i = 1; i < path.length; i++) {
4731
+ const start = path[i - 1];
4732
+ const end = path[i];
4733
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
4734
+ segments.push({
4735
+ start,
4736
+ end,
4737
+ length
4738
+ });
4739
+ totalLength += length;
4740
+ }
4741
+ const targetLength = totalLength * Math.max(0, Math.min(1, position));
4742
+ let accumulated = 0;
4743
+ for (const segment of segments) {
4744
+ if (accumulated + segment.length >= targetLength) {
4745
+ const segmentPosition = segment.length > 0 ? (targetLength - accumulated) / segment.length : 0;
4746
+ return {
4747
+ point: {
4748
+ x: segment.start.x + segmentPosition * (segment.end.x - segment.start.x),
4749
+ y: segment.start.y + segmentPosition * (segment.end.y - segment.start.y)
4750
+ },
4751
+ angle: Math.atan2(segment.end.y - segment.start.y, segment.end.x - segment.start.x)
4752
+ };
4753
+ }
4754
+ accumulated += segment.length;
4755
+ }
4756
+ const lastSegment = segments[segments.length - 1];
4757
+ return {
4758
+ point: lastSegment.end,
4759
+ angle: Math.atan2(lastSegment.end.y - lastSegment.start.y, lastSegment.end.x - lastSegment.start.x)
4760
+ };
4761
+ }
4762
+ //#endregion
4763
+ //#region src/elements/Edge.ts
4764
+ var DEFAULT_EDGE_STYLE = {
4765
+ strokeColor: "#666666",
4766
+ strokeWidth: 2,
4767
+ opacity: 1
4768
+ };
4769
+ /**
4770
+ * Connection edge between nodes
4771
+ */
4772
+ var Edge = class extends Element {
4773
+ constructor(options) {
4774
+ super({
4775
+ id: options.id ?? generateId("edge"),
4776
+ x: 0,
4777
+ y: 0,
4778
+ width: 0,
4779
+ height: 0,
4780
+ style: options.style,
4781
+ styleClass: options.styleClass
4782
+ });
4783
+ this._path = [];
4784
+ this._autoUpdateEndpoints = true;
4785
+ this._fromPoint = {
4786
+ x: 0,
4787
+ y: 0
4788
+ };
4789
+ this._toPoint = {
4790
+ x: 0,
4791
+ y: 0
4792
+ };
4793
+ this._from = options.from;
4794
+ this._to = options.to;
4795
+ this._type = options.type ?? "straight";
4796
+ this._arrowType = options.arrowType ?? "single";
4797
+ this._controlPoints = options.controlPoints;
4798
+ this._startMarker = options.startMarker;
4799
+ this._endMarker = options.endMarker;
4800
+ this._edgeStyle = {
4801
+ ...DEFAULT_EDGE_STYLE,
4802
+ ...options.style
4803
+ };
4804
+ this._pathStrategy = this.getPathStrategy(this._type);
4805
+ this._lockAnchors = options.lockAnchors ?? true;
4806
+ this._labelOffset = options.labelOffset ?? 0;
4807
+ this._labelPosition = options.labelPosition ?? .5;
4808
+ this._labelFollowPath = options.labelFollowPath ?? false;
4809
+ this._labelBackground = options.labelBackground;
4810
+ this._labelLineGap = options.labelLineGap ?? false;
4811
+ if (options.label !== void 0) if (typeof options.label === "string") this._label = new TextLabel({
4812
+ text: options.label,
4813
+ onChange: () => this.markDirty()
4814
+ });
4815
+ else this._label = new TextLabel({
4816
+ ...options.label,
4817
+ onChange: () => this.markDirty()
4818
+ });
4819
+ }
4820
+ /**
4821
+ * Source endpoint
4822
+ */
4823
+ get from() {
4824
+ return this._from;
4825
+ }
4826
+ set from(value) {
4827
+ this._from = value;
4828
+ this.markDirty();
4829
+ }
4830
+ /**
4831
+ * Target endpoint
4832
+ */
4833
+ get to() {
4834
+ return this._to;
4835
+ }
4836
+ set to(value) {
4837
+ this._to = value;
4838
+ this.markDirty();
4839
+ }
4840
+ /**
4841
+ * Edge type
4842
+ */
4843
+ get type() {
4844
+ return this._type;
4845
+ }
4846
+ set type(value) {
4847
+ if (this._type !== value) {
4848
+ this._type = value;
4849
+ this._pathStrategy = this.getPathStrategy(value);
4850
+ if (value !== "editable-polyline" && this._controlPoints?.length) this.controlPoints = void 0;
4851
+ this.markDirty();
4852
+ }
4853
+ }
4854
+ /**
4855
+ * Arrow type (legacy)
4856
+ */
4857
+ get arrowType() {
4858
+ return this._arrowType;
4859
+ }
4860
+ set arrowType(value) {
4861
+ if (this._arrowType !== value) {
4862
+ this._arrowType = value;
4863
+ this.markDirty();
4864
+ }
4865
+ }
4866
+ /**
4867
+ * Start marker configuration
4868
+ */
4869
+ get startMarker() {
4870
+ return this._startMarker;
4871
+ }
4872
+ set startMarker(value) {
4873
+ this._startMarker = value;
4874
+ this.markDirty();
4875
+ }
4876
+ /**
4877
+ * End marker configuration
4878
+ */
4879
+ get endMarker() {
4880
+ return this._endMarker;
4881
+ }
4882
+ set endMarker(value) {
4883
+ this._endMarker = value;
4884
+ this.markDirty();
4885
+ }
4886
+ /**
4887
+ * Label offset from center of path
4888
+ */
4889
+ get labelOffset() {
4890
+ return this._labelOffset;
4429
4891
  }
4430
4892
  set labelOffset(value) {
4431
4893
  if (this._labelOffset !== value) {
@@ -4737,8 +5199,8 @@ var Edge = class extends Element {
4737
5199
  renderPath(ctx) {
4738
5200
  const path = this._path;
4739
5201
  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;
5202
+ const startOffset = this._startMarker && this._startMarker.type !== "none" ? getCanvasMarkerLength(this._startMarker) : 0;
5203
+ const endOffset = this._endMarker && this._endMarker.type !== "none" ? getCanvasMarkerLength(this._endMarker) : 0;
4742
5204
  const buildPolyline = () => {
4743
5205
  if (this._type === "bezier" && path.length >= 4) {
4744
5206
  const samples = [];
@@ -4920,363 +5382,40 @@ var Edge = class extends Element {
4920
5382
  ctx.stroke();
4921
5383
  }
4922
5384
  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
- }
4942
- }
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
- };
4984
- }
4985
- /**
4986
- * Get the length of a marker (how much to shorten the line)
4987
- */
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();
5385
+ renderEdgeMarkers(ctx, {
5386
+ path: this._path,
5387
+ type: this._type,
5388
+ arrowType: this._arrowType,
5389
+ startMarker: this._startMarker,
5390
+ endMarker: this._endMarker
5391
+ });
5147
5392
  }
5148
5393
  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);
5185
- }
5186
- /**
5187
- * Get a point along the sampled path at parameter t (0..1).
5188
- * Also returns the tangent angle in radians at that point.
5189
- */
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);
5394
+ renderEdgeLabel(ctx, {
5395
+ ...this.getLabelLayoutOptions(),
5396
+ label: this._label,
5397
+ background: this._labelBackground
5398
+ });
5209
5399
  }
5210
5400
  /**
5211
5401
  * Get world position of label center along path.
5212
5402
  */
5213
5403
  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
- };
5404
+ return getEdgeLabelPosition(this.getLabelLayoutOptions());
5221
5405
  }
5222
5406
  /**
5223
5407
  * Get label rotation angle in radians (if labelFollowPath is true).
5224
5408
  */
5225
5409
  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;
5410
+ return getEdgeLabelRotation(this.getLabelLayoutOptions());
5232
5411
  }
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];
5412
+ getLabelLayoutOptions() {
5277
5413
  return {
5278
- point: lastSeg.end,
5279
- angle: Math.atan2(lastSeg.end.y - lastSeg.start.y, lastSeg.end.x - lastSeg.start.x)
5414
+ path: this._path,
5415
+ type: this._type,
5416
+ position: this._labelPosition,
5417
+ offset: this._labelOffset,
5418
+ followPath: this._labelFollowPath
5280
5419
  };
5281
5420
  }
5282
5421
  getPathStrategy(type) {
@@ -5412,6 +5551,171 @@ var LabelEditor = class {
5412
5551
  }
5413
5552
  };
5414
5553
  //#endregion
5554
+ //#region src/core/ClipboardManager.ts
5555
+ var ClipboardManager = class {
5556
+ constructor(options) {
5557
+ this.clipboard = null;
5558
+ this.renderer = options.renderer;
5559
+ this.selectionManager = options.selectionManager;
5560
+ this.historyManager = options.historyManager;
5561
+ this.nodeFactory = options.nodeFactory;
5562
+ this.edgeFactory = options.edgeFactory;
5563
+ }
5564
+ copySelection() {
5565
+ const selectedIds = new Set(this.selectionManager.selectedIds);
5566
+ if (selectedIds.size === 0) return;
5567
+ const nodes = [];
5568
+ const edges = [];
5569
+ const nodeIds = /* @__PURE__ */ new Set();
5570
+ for (const id of selectedIds) {
5571
+ const node = this.renderer.getNode(id);
5572
+ if (node) {
5573
+ nodes.push({
5574
+ id: node.id,
5575
+ type: node.typeName,
5576
+ x: node.x,
5577
+ y: node.y,
5578
+ width: node.width,
5579
+ height: node.height,
5580
+ style: node.style,
5581
+ styleClass: node.styleClass,
5582
+ label: node.label?.text,
5583
+ labelStyleClass: node.label?.styleClass,
5584
+ ports: node.ports.map((port) => ({
5585
+ id: port.id,
5586
+ type: port.type,
5587
+ position: port.position,
5588
+ styleClass: port.styleClass
5589
+ })),
5590
+ data: Object.keys(node.data).length > 0 ? node.data : void 0
5591
+ });
5592
+ nodeIds.add(node.id);
5593
+ }
5594
+ }
5595
+ for (const edge of this.renderer.edges.values()) if (nodeIds.has(edge.from.nodeId) && nodeIds.has(edge.to.nodeId)) edges.push({
5596
+ id: edge.id,
5597
+ from: edge.from,
5598
+ to: edge.to,
5599
+ type: edge.type,
5600
+ controlPoints: edge.controlPoints,
5601
+ arrowType: edge.arrowType,
5602
+ style: edge.style,
5603
+ styleClass: edge.styleClass,
5604
+ label: edge.label?.text,
5605
+ labelStyleClass: edge.label?.styleClass,
5606
+ labelOffset: edge.labelOffset !== 0 ? edge.labelOffset : void 0,
5607
+ labelBackground: edge.labelBackground,
5608
+ labelLineGap: edge.labelLineGap ? true : void 0,
5609
+ data: Object.keys(edge.data).length > 0 ? edge.data : void 0
5610
+ });
5611
+ this.clipboard = {
5612
+ nodes,
5613
+ edges
5614
+ };
5615
+ }
5616
+ pasteSelection() {
5617
+ if (!this.clipboard || !this.nodeFactory || !this.edgeFactory) return;
5618
+ const offset = {
5619
+ x: 20,
5620
+ y: 20
5621
+ };
5622
+ const idMap = /* @__PURE__ */ new Map();
5623
+ const newNodes = [];
5624
+ const newEdges = [];
5625
+ for (const nodeData of this.clipboard.nodes) {
5626
+ const newId = `${nodeData.id}_copy_${Date.now()}`;
5627
+ idMap.set(nodeData.id, newId);
5628
+ const copyData = {
5629
+ ...nodeData,
5630
+ id: newId,
5631
+ x: nodeData.x + offset.x,
5632
+ y: nodeData.y + offset.y
5633
+ };
5634
+ newNodes.push(this.nodeFactory(copyData));
5635
+ }
5636
+ for (const edgeData of this.clipboard.edges) {
5637
+ const fromNodeId = idMap.get(edgeData.from.nodeId);
5638
+ const toNodeId = idMap.get(edgeData.to.nodeId);
5639
+ if (!fromNodeId || !toNodeId) continue;
5640
+ const newId = `${edgeData.id}_copy_${Date.now()}`;
5641
+ const copyEdge = {
5642
+ ...edgeData,
5643
+ id: newId,
5644
+ from: {
5645
+ ...edgeData.from,
5646
+ nodeId: fromNodeId
5647
+ },
5648
+ to: {
5649
+ ...edgeData.to,
5650
+ nodeId: toNodeId
5651
+ }
5652
+ };
5653
+ newEdges.push(this.edgeFactory(copyEdge));
5654
+ }
5655
+ if (newNodes.length === 0 && newEdges.length === 0) return;
5656
+ this.historyManager.execute({
5657
+ execute: () => {
5658
+ for (const node of newNodes) this.renderer.addNode(node);
5659
+ for (const edge of newEdges) this.renderer.addEdge(edge);
5660
+ },
5661
+ undo: () => {
5662
+ for (const edge of newEdges) this.renderer.removeEdge(edge.id);
5663
+ for (const node of newNodes) this.renderer.removeNode(node.id);
5664
+ }
5665
+ });
5666
+ }
5667
+ };
5668
+ //#endregion
5669
+ //#region src/core/PropertyChangeBatcher.ts
5670
+ var PropertyChangeBatcher = class {
5671
+ constructor(options) {
5672
+ this.pendingChanges = /* @__PURE__ */ new Map();
5673
+ this.renderer = options.renderer;
5674
+ this.historyManager = options.historyManager;
5675
+ this.debounceMs = options.debounceMs ?? 350;
5676
+ }
5677
+ queue(kind, id, before, after) {
5678
+ const key = `${kind}:${id}`;
5679
+ const existing = this.pendingChanges.get(key);
5680
+ if (existing) {
5681
+ existing.after = after;
5682
+ window.clearTimeout(existing.timerId);
5683
+ existing.timerId = window.setTimeout(() => this.flushOne(key), this.debounceMs);
5684
+ return;
5685
+ }
5686
+ const timerId = window.setTimeout(() => this.flushOne(key), this.debounceMs);
5687
+ this.pendingChanges.set(key, {
5688
+ kind,
5689
+ id,
5690
+ before,
5691
+ after,
5692
+ timerId
5693
+ });
5694
+ }
5695
+ flush() {
5696
+ for (const key of Array.from(this.pendingChanges.keys())) this.flushOne(key);
5697
+ }
5698
+ flushOne(key) {
5699
+ const pending = this.pendingChanges.get(key);
5700
+ if (!pending) return;
5701
+ window.clearTimeout(pending.timerId);
5702
+ this.pendingChanges.delete(key);
5703
+ if (shallowEqual(pending.before, pending.after)) return;
5704
+ switch (pending.kind) {
5705
+ case "node":
5706
+ this.historyManager.execute(new ChangeNodePropertiesCommand((id) => this.renderer.getNode(id), pending.id, pending.before, pending.after));
5707
+ break;
5708
+ case "edge":
5709
+ this.historyManager.execute(new ChangeEdgePropertiesCommand((id) => this.renderer.getEdge(id), pending.id, pending.before, pending.after));
5710
+ break;
5711
+ case "group":
5712
+ this.historyManager.execute(new ChangeGroupPropertiesCommand((id) => this.renderer.getGroup(id), pending.id, pending.before, pending.after));
5713
+ break;
5714
+ }
5715
+ this.renderer.markStyleDirty();
5716
+ }
5717
+ };
5718
+ //#endregion
5415
5719
  //#region src/core/InteractionManager.ts
5416
5720
  var DEFAULT_KEYMAP = {
5417
5721
  deleteKeys: ["Delete", "Backspace"],
@@ -5426,9 +5730,6 @@ var InteractionManager = class {
5426
5730
  this.dragStartPositions = /* @__PURE__ */ new Map();
5427
5731
  this.dragStartEditablePolylinePoints = /* @__PURE__ */ new Map();
5428
5732
  this.reconnectOrigins = /* @__PURE__ */ new Map();
5429
- this.clipboard = null;
5430
- this.pendingPropertyChanges = /* @__PURE__ */ new Map();
5431
- this.propertyChangeDebounceMs = 350;
5432
5733
  this.labelEditor = new LabelEditor();
5433
5734
  this.scrollbarDragState = null;
5434
5735
  this.handledScrollbarMouseDown = false;
@@ -5457,6 +5758,17 @@ var InteractionManager = class {
5457
5758
  });
5458
5759
  this.navigationManager = new NavigationManager({ renderer: this.renderer });
5459
5760
  this.historyManager = new HistoryManager();
5761
+ this.clipboardManager = new ClipboardManager({
5762
+ renderer: this.renderer,
5763
+ selectionManager: this.selectionManager,
5764
+ historyManager: this.historyManager,
5765
+ nodeFactory: options.nodeFactory,
5766
+ edgeFactory: options.edgeFactory
5767
+ });
5768
+ this.propertyChangeBatcher = new PropertyChangeBatcher({
5769
+ renderer: this.renderer,
5770
+ historyManager: this.historyManager
5771
+ });
5460
5772
  this.keymap = {
5461
5773
  ...DEFAULT_KEYMAP,
5462
5774
  ...options.keymap
@@ -5484,7 +5796,7 @@ var InteractionManager = class {
5484
5796
  });
5485
5797
  }
5486
5798
  });
5487
- this.setupEvents(options);
5799
+ this.setupEvents();
5488
5800
  }
5489
5801
  get selection() {
5490
5802
  return this.selectionManager;
@@ -5547,7 +5859,7 @@ var InteractionManager = class {
5547
5859
  const after = createNodeSnapshot(node);
5548
5860
  if (shallowEqual(before, after)) return;
5549
5861
  this.renderer.markStyleDirty();
5550
- this.queuePropertyChange("node", nodeId, before, after);
5862
+ this.propertyChangeBatcher.queue("node", nodeId, before, after);
5551
5863
  }
5552
5864
  changeEdgeProperties(edgeId, apply) {
5553
5865
  const edge = this.renderer.getEdge(edgeId);
@@ -5557,7 +5869,7 @@ var InteractionManager = class {
5557
5869
  const after = createEdgeSnapshot(edge);
5558
5870
  if (shallowEqual(before, after)) return;
5559
5871
  this.renderer.markStyleDirty();
5560
- this.queuePropertyChange("edge", edgeId, before, after);
5872
+ this.propertyChangeBatcher.queue("edge", edgeId, before, after);
5561
5873
  }
5562
5874
  changeGroupProperties(groupId, apply) {
5563
5875
  const group = this.renderer.getGroup(groupId);
@@ -5567,7 +5879,7 @@ var InteractionManager = class {
5567
5879
  const after = createGroupSnapshot(group);
5568
5880
  if (shallowEqual(before, after)) return;
5569
5881
  this.renderer.markStyleDirty();
5570
- this.queuePropertyChange("group", groupId, before, after);
5882
+ this.propertyChangeBatcher.queue("group", groupId, before, after);
5571
5883
  }
5572
5884
  removeNodeFromGroups(nodeId, groupIds) {
5573
5885
  if (!this.renderer.getNode(nodeId)) return;
@@ -5601,7 +5913,7 @@ var InteractionManager = class {
5601
5913
  this.overlayCleanup?.();
5602
5914
  this.overlayCleanup = null;
5603
5915
  }
5604
- setupEvents(options) {
5916
+ setupEvents() {
5605
5917
  this.overlayCleanup = this.renderer.addOverlayRenderer((ctx) => {
5606
5918
  this.selectionManager.renderSelectionRect(ctx);
5607
5919
  if (!this.navigationOnly) {
@@ -5622,7 +5934,7 @@ var InteractionManager = class {
5622
5934
  this.inputHandler.on("wheel", (event) => this.handleWheel(event));
5623
5935
  this.inputHandler.on("pan", (event) => this.handlePan(event));
5624
5936
  this.inputHandler.on("pinch", (event) => this.handlePinch(event));
5625
- this.inputHandler.on("keydown", (event) => this.handleKeyDown(event, options));
5937
+ this.inputHandler.on("keydown", (event) => this.handleKeyDown(event));
5626
5938
  this.inputHandler.on("keyup", (event) => this.handleKeyUp(event));
5627
5939
  if (!this.navigationOnly) {
5628
5940
  this.dragManager.on("dragstart", (nodeIds) => {
@@ -5852,8 +6164,7 @@ var InteractionManager = class {
5852
6164
  const component = node.getComponentAtPoint(point);
5853
6165
  if (!component) return;
5854
6166
  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);
6167
+ component.onClick?.(component);
5857
6168
  }
5858
6169
  handleDoubleClick(event) {
5859
6170
  if (this.renderer.blocksDiagramPointerAtScreen(event.screenX, event.screenY)) return;
@@ -5919,13 +6230,13 @@ var InteractionManager = class {
5919
6230
  if (this.renderer.blocksDiagramPointerAtScreen(event.screenX, event.screenY)) return;
5920
6231
  this.navigationManager.handlePinch(event);
5921
6232
  }
5922
- handleKeyDown(event, options) {
6233
+ handleKeyDown(event) {
5923
6234
  const isCtrlOrMeta = event.ctrlKey || event.metaKey;
5924
- const key = event.code.startsWith("Key") ? event.code.slice(3).toLowerCase() : event.key.toLowerCase();
6235
+ const key = getShortcutKey(event);
5925
6236
  this.navigationManager.handleKeyDown(event);
5926
6237
  if (this.handleViewportNavigationKey(event)) return;
5927
6238
  if (this.navigationOnly) return;
5928
- if (isCtrlOrMeta && (key === "z" || key === "y")) this.flushPendingPropertyChanges();
6239
+ if (getHistoryShortcut(event) !== null) this.propertyChangeBatcher.flush();
5929
6240
  if (this.historyManager.handleKeyDown(event)) return;
5930
6241
  if (this.keymap.deleteKeys.includes(event.key)) {
5931
6242
  event.preventDefault();
@@ -5934,12 +6245,12 @@ var InteractionManager = class {
5934
6245
  }
5935
6246
  if (isCtrlOrMeta && key === this.keymap.copyKey) {
5936
6247
  event.preventDefault();
5937
- this.copySelection();
6248
+ this.clipboardManager.copySelection();
5938
6249
  return;
5939
6250
  }
5940
6251
  if (isCtrlOrMeta && key === this.keymap.pasteKey) {
5941
6252
  event.preventDefault();
5942
- this.pasteSelection(options);
6253
+ this.clipboardManager.pasteSelection();
5943
6254
  return;
5944
6255
  }
5945
6256
  }
@@ -6044,49 +6355,6 @@ var InteractionManager = class {
6044
6355
  edge.label.text = value;
6045
6356
  });
6046
6357
  }
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
6358
  deleteSelection() {
6091
6359
  const selectedIds = Array.from(this.selectionManager.selectedIds);
6092
6360
  this.deleteByIds(selectedIds);
@@ -6142,109 +6410,6 @@ var InteractionManager = class {
6142
6410
  }
6143
6411
  }]);
6144
6412
  }
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
6413
  endpointsEqual(a, b) {
6249
6414
  if (a.nodeId !== b.nodeId) return false;
6250
6415
  if ((a.portId ?? null) !== (b.portId ?? null)) return false;
@@ -6629,40 +6794,403 @@ var ContextMenuManager = class extends EventEmitter {
6629
6794
  iconEl.style.fontSize = "16px";
6630
6795
  iconEl.textContent = icon.value;
6631
6796
  }
6632
- return iconEl;
6797
+ return iconEl;
6798
+ }
6799
+ isSvgString(value) {
6800
+ const trimmed = value.trim();
6801
+ return trimmed.startsWith("<svg") || trimmed.includes("</svg>");
6802
+ }
6803
+ adjustPosition(menu) {
6804
+ const rect = menu.getBoundingClientRect();
6805
+ const padding = 8;
6806
+ let left = rect.left;
6807
+ let top = rect.top;
6808
+ if (rect.right > window.innerWidth - padding) left = Math.max(padding, window.innerWidth - rect.width - padding);
6809
+ if (rect.bottom > window.innerHeight - padding) top = Math.max(padding, window.innerHeight - rect.height - padding);
6810
+ menu.style.left = `${left}px`;
6811
+ menu.style.top = `${top}px`;
6812
+ }
6813
+ };
6814
+ //#endregion
6815
+ //#region src/core/EdgeEndpointUpdater.ts
6816
+ var EdgeEndpointUpdater = class {
6817
+ constructor(host) {
6818
+ this.host = host;
6819
+ }
6820
+ updateForDrag() {
6821
+ this.update(false);
6822
+ }
6823
+ updateAll() {
6824
+ this.update(true);
6825
+ }
6826
+ update(includeRoutingObstacles) {
6827
+ const obstacles = includeRoutingObstacles ? this.host.getNodeObstacles() : void 0;
6828
+ const nodes = this.host.getNodes();
6829
+ for (const edge of this.host.getEdges().values()) {
6830
+ if (!edge.autoUpdateEndpoints) continue;
6831
+ const fromNode = nodes.get(edge.from.nodeId);
6832
+ const toNode = nodes.get(edge.to.nodeId);
6833
+ if (!fromNode || !toNode) continue;
6834
+ if (includeRoutingObstacles) this.lockAnchors(edge, fromNode, toNode);
6835
+ const from = this.resolveEndpoint(fromNode, edge.from.portId, edge.from.outlineParam);
6836
+ const to = this.resolveEndpoint(toNode, edge.to.portId, edge.to.outlineParam);
6837
+ edge.updateEndpoints(from.point, to.point, from.direction, to.direction, obstacles ? { obstacles } : void 0);
6838
+ }
6839
+ }
6840
+ lockAnchors(edge, fromNode, toNode) {
6841
+ if (edge.lockAnchors && edge.from.outlineParam === void 0 && !edge.from.portId) {
6842
+ const anchor = fromNode.getNearestAnchor(toNode.getCenter());
6843
+ if (anchor) edge.from = {
6844
+ ...edge.from,
6845
+ portId: `${ANCHOR_PORT_PREFIX}${anchor.id}`
6846
+ };
6847
+ }
6848
+ if (edge.lockAnchors && edge.to.outlineParam === void 0 && !edge.to.portId) {
6849
+ const anchor = toNode.getNearestAnchor(fromNode.getCenter());
6850
+ if (anchor) edge.to = {
6851
+ ...edge.to,
6852
+ portId: `${ANCHOR_PORT_PREFIX}${anchor.id}`
6853
+ };
6854
+ }
6855
+ }
6856
+ resolveEndpoint(node, portId, outlineParam) {
6857
+ if (portId) {
6858
+ const anchorId = portId.slice(ANCHOR_PORT_PREFIX.length);
6859
+ const point = node.getAnchorPointById(anchorId);
6860
+ return point ? {
6861
+ point,
6862
+ direction: anchorId.split(":")[0]
6863
+ } : { point: node.getCenter() };
6864
+ }
6865
+ if (outlineParam !== void 0) return {
6866
+ point: node.getConnectionPointAtOutlineParam(outlineParam),
6867
+ direction: getDirectionFromOutlineParam(outlineParam)
6868
+ };
6869
+ return { point: node.getCenter() };
6870
+ }
6871
+ };
6872
+ //#endregion
6873
+ //#region src/core/ScrollbarController.ts
6874
+ var DEFAULT_SCROLLBAR_OPTIONS = {
6875
+ enabled: true,
6876
+ autoHide: false,
6877
+ autoHideDelay: 1200,
6878
+ fadeDuration: 220,
6879
+ thickness: 6,
6880
+ hoverThickness: 8,
6881
+ minThumbLength: 24,
6882
+ hitAreaPadding: 4,
6883
+ pageScrollRatio: .8,
6884
+ trackColor: "",
6885
+ thumbColor: "",
6886
+ thumbHoverColor: "",
6887
+ thumbActiveColor: ""
6888
+ };
6889
+ var ScrollbarController = class ScrollbarController {
6890
+ constructor(host, diagramOptions) {
6891
+ this.host = host;
6892
+ this.hoveredAxis = null;
6893
+ this.activeAxis = null;
6894
+ this.lastInteractionAt = performance.now();
6895
+ this.lastAlpha = -1;
6896
+ this.options = ScrollbarController.resolveOptions(diagramOptions);
6897
+ }
6898
+ updateAnimation(now) {
6899
+ if (!this.options.enabled || !this.options.autoHide) return false;
6900
+ const alpha = this.getAlpha(now);
6901
+ if (Math.abs(alpha - this.lastAlpha) <= .001) return false;
6902
+ this.lastAlpha = alpha;
6903
+ return true;
6904
+ }
6905
+ render(ctx, frameTime) {
6906
+ const metrics = this.getMetrics();
6907
+ if (!metrics) return;
6908
+ const alpha = this.getAlpha(frameTime);
6909
+ this.lastAlpha = alpha;
6910
+ if (alpha <= .001) return;
6911
+ const colors = this.resolveColors();
6912
+ ctx.save();
6913
+ const pixelRatio = this.host.getPixelRatio();
6914
+ ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
6915
+ ctx.globalAlpha = alpha;
6916
+ if (metrics.horizontal) {
6917
+ const isHovered = this.hoveredAxis === "horizontal";
6918
+ const isActive = this.activeAxis === "horizontal";
6919
+ const thickness = isHovered || isActive ? this.options.hoverThickness : this.options.thickness;
6920
+ const trackY = metrics.horizontal.trackY + (metrics.horizontal.thickness - thickness) / 2;
6921
+ const radius = thickness / 2;
6922
+ ctx.fillStyle = colors.track;
6923
+ drawRoundedRectPath(ctx, metrics.horizontal.trackX, trackY, metrics.horizontal.trackLength, thickness, radius);
6924
+ ctx.fill();
6925
+ ctx.fillStyle = isActive ? colors.thumbActive : isHovered ? colors.thumbHover : colors.thumb;
6926
+ drawRoundedRectPath(ctx, metrics.horizontal.trackX + metrics.horizontal.thumbOffset, trackY, metrics.horizontal.thumbLength, thickness, radius);
6927
+ ctx.fill();
6928
+ }
6929
+ if (metrics.vertical) {
6930
+ const isHovered = this.hoveredAxis === "vertical";
6931
+ const isActive = this.activeAxis === "vertical";
6932
+ const thickness = isHovered || isActive ? this.options.hoverThickness : this.options.thickness;
6933
+ const trackX = metrics.vertical.trackX + (metrics.vertical.thickness - thickness) / 2;
6934
+ const radius = thickness / 2;
6935
+ ctx.fillStyle = colors.track;
6936
+ drawRoundedRectPath(ctx, trackX, metrics.vertical.trackY, thickness, metrics.vertical.trackLength, radius);
6937
+ ctx.fill();
6938
+ ctx.fillStyle = isActive ? colors.thumbActive : isHovered ? colors.thumbHover : colors.thumb;
6939
+ drawRoundedRectPath(ctx, trackX, metrics.vertical.trackY + metrics.vertical.thumbOffset, thickness, metrics.vertical.thumbLength, radius);
6940
+ ctx.fill();
6941
+ }
6942
+ ctx.restore();
6943
+ }
6944
+ notifyInteraction() {
6945
+ this.lastInteractionAt = performance.now();
6946
+ this.host.markDirty();
6947
+ }
6948
+ setActiveAxis(axis) {
6949
+ if (this.activeAxis === axis) return;
6950
+ this.activeAxis = axis;
6951
+ if (axis === null) this.host.markDirty();
6952
+ else this.notifyInteraction();
6953
+ }
6954
+ updateHover(screenX, screenY) {
6955
+ const axis = this.hitTestArea(screenX, screenY);
6956
+ if (this.hoveredAxis !== axis) {
6957
+ this.hoveredAxis = axis;
6958
+ this.host.markDirty();
6959
+ if (axis !== null) this.notifyInteraction();
6960
+ }
6961
+ return axis !== null;
6962
+ }
6963
+ clearHover() {
6964
+ if (this.hoveredAxis === null) return;
6965
+ this.hoveredAxis = null;
6966
+ this.host.markDirty();
6967
+ }
6968
+ hitTestThumb(screenX, screenY) {
6969
+ const metrics = this.getMetrics();
6970
+ const point = this.host.screenToCanvas(screenX, screenY);
6971
+ if (!metrics || !point) return null;
6972
+ const padding = this.options.hitAreaPadding;
6973
+ if (metrics.horizontal) {
6974
+ const start = metrics.horizontal.trackX + metrics.horizontal.thumbOffset;
6975
+ 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 {
6976
+ axis: "horizontal",
6977
+ pointerOffset: point.x - start
6978
+ };
6979
+ }
6980
+ if (metrics.vertical) {
6981
+ const start = metrics.vertical.trackY + metrics.vertical.thumbOffset;
6982
+ 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 {
6983
+ axis: "vertical",
6984
+ pointerOffset: point.y - start
6985
+ };
6986
+ }
6987
+ return null;
6988
+ }
6989
+ dragThumb(axis, screenX, screenY, pointerOffset) {
6990
+ const metrics = this.getMetrics();
6991
+ const point = this.host.screenToCanvas(screenX, screenY);
6992
+ if (!metrics || !point) return false;
6993
+ const track = axis === "horizontal" ? metrics.horizontal : metrics.vertical;
6994
+ if (!track) return false;
6995
+ const coordinate = axis === "horizontal" ? point.x : point.y;
6996
+ const trackStart = axis === "horizontal" ? track.trackX : track.trackY;
6997
+ const travel = Math.max(0, track.trackLength - track.thumbLength);
6998
+ const thumbOffset = Math.min(Math.max(coordinate - trackStart - pointerOffset, 0), travel);
6999
+ const scroll = travel > 0 && track.maxScroll > 0 ? thumbOffset / travel * track.maxScroll : 0;
7000
+ const viewportStart = (axis === "horizontal" ? metrics.contentBounds.x : metrics.contentBounds.y) + scroll;
7001
+ if (axis === "horizontal") this.host.setOffsetX(-viewportStart * this.host.getZoom());
7002
+ else this.host.setOffsetY(-viewportStart * this.host.getZoom());
7003
+ this.notifyInteraction();
7004
+ return true;
7005
+ }
7006
+ clickTrack(screenX, screenY) {
7007
+ const metrics = this.getMetrics();
7008
+ const point = this.host.screenToCanvas(screenX, screenY);
7009
+ if (!metrics || !point) return false;
7010
+ if (metrics.horizontal && this.clickTrackAxis("horizontal", metrics, point)) return true;
7011
+ return metrics.vertical ? this.clickTrackAxis("vertical", metrics, point) : false;
7012
+ }
7013
+ scrollBy(screenDx, screenDy) {
7014
+ const state = this.getScrollState();
7015
+ if (!state) return false;
7016
+ const zoom = this.host.getZoom();
7017
+ const maxX = Math.max(0, state.contentBounds.width - state.viewportBounds.width);
7018
+ const maxY = Math.max(0, state.contentBounds.height - state.viewportBounds.height);
7019
+ const x = Math.min(Math.max(state.viewportBounds.x + screenDx / zoom, state.contentBounds.x), state.contentBounds.x + maxX);
7020
+ const y = Math.min(Math.max(state.viewportBounds.y + screenDy / zoom, state.contentBounds.y), state.contentBounds.y + maxY);
7021
+ this.host.setOffsetX(-x * zoom);
7022
+ this.host.setOffsetY(-y * zoom);
7023
+ this.notifyInteraction();
7024
+ return true;
7025
+ }
7026
+ scrollToStart() {
7027
+ const state = this.getScrollState();
7028
+ if (!state) return false;
7029
+ const zoom = this.host.getZoom();
7030
+ this.host.setOffsetX(-state.contentBounds.x * zoom);
7031
+ this.host.setOffsetY(-state.contentBounds.y * zoom);
7032
+ this.notifyInteraction();
7033
+ return true;
7034
+ }
7035
+ scrollToEnd() {
7036
+ const state = this.getScrollState();
7037
+ if (!state) return false;
7038
+ const maxX = Math.max(0, state.contentBounds.width - state.viewportBounds.width);
7039
+ const maxY = Math.max(0, state.contentBounds.height - state.viewportBounds.height);
7040
+ const zoom = this.host.getZoom();
7041
+ this.host.setOffsetX(-(state.contentBounds.x + maxX) * zoom);
7042
+ this.host.setOffsetY(-(state.contentBounds.y + maxY) * zoom);
7043
+ this.notifyInteraction();
7044
+ return true;
7045
+ }
7046
+ clickTrackAxis(axis, metrics, point) {
7047
+ const track = axis === "horizontal" ? metrics.horizontal : metrics.vertical;
7048
+ if (!track) return false;
7049
+ const coordinate = axis === "horizontal" ? point.x : point.y;
7050
+ const cross = axis === "horizontal" ? point.y : point.x;
7051
+ const start = axis === "horizontal" ? track.trackX : track.trackY;
7052
+ const crossStart = axis === "horizontal" ? track.trackY : track.trackX;
7053
+ if (coordinate < start || coordinate > start + track.trackLength || cross < crossStart || cross > crossStart + track.thickness) return false;
7054
+ const thumbStart = start + track.thumbOffset;
7055
+ const thumbEnd = thumbStart + track.thumbLength;
7056
+ if (coordinate >= thumbStart && coordinate <= thumbEnd) return false;
7057
+ const direction = coordinate < thumbStart ? -1 : 1;
7058
+ const viewportSize = axis === "horizontal" ? metrics.viewportBounds.width : metrics.viewportBounds.height;
7059
+ const current = (axis === "horizontal" ? metrics.viewportBounds.x : metrics.viewportBounds.y) - (axis === "horizontal" ? metrics.contentBounds.x : metrics.contentBounds.y);
7060
+ const next = Math.min(Math.max(current + viewportSize * this.options.pageScrollRatio * direction, 0), track.maxScroll);
7061
+ const viewportStart = (axis === "horizontal" ? metrics.contentBounds.x : metrics.contentBounds.y) + next;
7062
+ if (axis === "horizontal") this.host.setOffsetX(-viewportStart * this.host.getZoom());
7063
+ else this.host.setOffsetY(-viewportStart * this.host.getZoom());
7064
+ this.notifyInteraction();
7065
+ return true;
7066
+ }
7067
+ getMetrics() {
7068
+ if (!this.options.enabled) return null;
7069
+ const state = this.getScrollState();
7070
+ if (!state) return null;
7071
+ const { contentBounds, viewportBounds } = state;
7072
+ const contentWidth = Math.max(contentBounds.width, 1);
7073
+ const contentHeight = Math.max(contentBounds.height, 1);
7074
+ const showHorizontal = contentWidth > viewportBounds.width + .01;
7075
+ const showVertical = contentHeight > viewportBounds.height + .01;
7076
+ if (!showHorizontal && !showVertical) return null;
7077
+ const padding = 6;
7078
+ const spacing = 4;
7079
+ const width = this.host.getWidth();
7080
+ const height = this.host.getHeight();
7081
+ const availableWidth = width - padding * 2;
7082
+ const availableHeight = height - padding * 2;
7083
+ if (availableWidth <= 0 || availableHeight <= 0) return null;
7084
+ const horizontalLength = Math.max(0, availableWidth - (showVertical ? this.options.thickness + spacing : 0));
7085
+ const verticalLength = Math.max(0, availableHeight - (showHorizontal ? this.options.thickness + spacing : 0));
7086
+ return {
7087
+ contentBounds,
7088
+ viewportBounds,
7089
+ horizontal: showHorizontal ? this.createTrack(horizontalLength, viewportBounds.width, contentWidth, viewportBounds.x - contentBounds.x, padding, height - padding - this.options.thickness) : null,
7090
+ vertical: showVertical ? this.createTrack(verticalLength, viewportBounds.height, contentHeight, viewportBounds.y - contentBounds.y, width - padding - this.options.thickness, padding) : null
7091
+ };
7092
+ }
7093
+ createTrack(length, viewportSize, contentSize, scroll, trackX, trackY) {
7094
+ if (length <= 0) return null;
7095
+ const maxScroll = Math.max(0, contentSize - viewportSize);
7096
+ const thumbLength = Math.min(length, Math.max(this.options.minThumbLength, length * (viewportSize / contentSize)));
7097
+ const travel = Math.max(0, length - thumbLength);
7098
+ return {
7099
+ trackX,
7100
+ trackY,
7101
+ trackLength: length,
7102
+ thickness: this.options.thickness,
7103
+ thumbLength,
7104
+ thumbOffset: maxScroll > 0 ? Math.min(Math.max(scroll, 0), maxScroll) / maxScroll * travel : 0,
7105
+ maxScroll
7106
+ };
7107
+ }
7108
+ getScrollState() {
7109
+ const raw = this.host.getContentBounds();
7110
+ if (!raw) return null;
7111
+ const viewportBounds = this.getViewportBounds();
7112
+ const minX = Math.min(raw.x, viewportBounds.x);
7113
+ const minY = Math.min(raw.y, viewportBounds.y);
7114
+ const maxX = Math.max(raw.x + raw.width, viewportBounds.x + viewportBounds.width);
7115
+ const maxY = Math.max(raw.y + raw.height, viewportBounds.y + viewportBounds.height);
7116
+ return {
7117
+ contentBounds: {
7118
+ x: minX,
7119
+ y: minY,
7120
+ width: maxX - minX,
7121
+ height: maxY - minY
7122
+ },
7123
+ viewportBounds
7124
+ };
7125
+ }
7126
+ getViewportBounds() {
7127
+ const zoom = this.host.getZoom();
7128
+ return {
7129
+ x: -this.host.getOffsetX() / zoom,
7130
+ y: -this.host.getOffsetY() / zoom,
7131
+ width: this.host.getWidth() / zoom,
7132
+ height: this.host.getHeight() / zoom
7133
+ };
7134
+ }
7135
+ hitTestArea(screenX, screenY) {
7136
+ const metrics = this.getMetrics();
7137
+ const point = this.host.screenToCanvas(screenX, screenY);
7138
+ if (!metrics || !point) return null;
7139
+ const padding = this.options.hitAreaPadding;
7140
+ for (const axis of ["horizontal", "vertical"]) {
7141
+ const track = metrics[axis];
7142
+ if (!track) continue;
7143
+ const coordinate = axis === "horizontal" ? point.x : point.y;
7144
+ const cross = axis === "horizontal" ? point.y : point.x;
7145
+ const start = axis === "horizontal" ? track.trackX : track.trackY;
7146
+ const crossStart = axis === "horizontal" ? track.trackY : track.trackX;
7147
+ if (coordinate >= start - padding && coordinate <= start + track.trackLength + padding && cross >= crossStart - padding && cross <= crossStart + track.thickness + padding) return axis;
7148
+ }
7149
+ return null;
6633
7150
  }
6634
- isSvgString(value) {
6635
- const trimmed = value.trim();
6636
- return trimmed.startsWith("<svg") || trimmed.includes("</svg>");
7151
+ getAlpha(now) {
7152
+ if (!this.options.autoHide || this.activeAxis !== null || this.hoveredAxis !== null) return 1;
7153
+ const elapsed = now - this.lastInteractionAt;
7154
+ if (elapsed <= this.options.autoHideDelay) return 1;
7155
+ if (this.options.fadeDuration <= 0) return 0;
7156
+ return Math.max(0, 1 - (elapsed - this.options.autoHideDelay) / this.options.fadeDuration);
6637
7157
  }
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`;
7158
+ resolveColors() {
7159
+ const fallback = this.host.isDarkTheme() ? {
7160
+ track: "rgba(148, 163, 184, 0.22)",
7161
+ thumb: "rgba(226, 232, 240, 0.68)",
7162
+ thumbHover: "rgba(226, 232, 240, 0.86)",
7163
+ thumbActive: "rgba(248, 250, 252, 0.96)"
7164
+ } : {
7165
+ track: "rgba(15, 23, 42, 0.16)",
7166
+ thumb: "rgba(51, 65, 85, 0.7)",
7167
+ thumbHover: "rgba(30, 41, 59, 0.82)",
7168
+ thumbActive: "rgba(15, 23, 42, 0.9)"
7169
+ };
7170
+ return {
7171
+ track: this.options.trackColor || fallback.track,
7172
+ thumb: this.options.thumbColor || fallback.thumb,
7173
+ thumbHover: this.options.thumbHoverColor || fallback.thumbHover,
7174
+ thumbActive: this.options.thumbActiveColor || fallback.thumbActive
7175
+ };
7176
+ }
7177
+ static resolveOptions(options) {
7178
+ const resolved = {
7179
+ ...DEFAULT_SCROLLBAR_OPTIONS,
7180
+ enabled: options.scrollbarOverlay ?? DEFAULT_SCROLLBAR_OPTIONS.enabled
7181
+ };
7182
+ if (typeof options.scrollbar === "boolean") return {
7183
+ ...resolved,
7184
+ enabled: options.scrollbar
7185
+ };
7186
+ return options.scrollbar ? {
7187
+ ...resolved,
7188
+ ...options.scrollbar
7189
+ } : resolved;
6647
7190
  }
6648
7191
  };
6649
7192
  //#endregion
6650
7193
  //#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
7194
  var DEFAULT_OPTIONS$1 = {
6667
7195
  width: 800,
6668
7196
  height: 600,
@@ -6699,10 +7227,6 @@ var DiagramRenderer = class extends EventEmitter {
6699
7227
  this.interactionManager = null;
6700
7228
  this.contextMenuManager = null;
6701
7229
  this.frameTime = 0;
6702
- this.scrollbarHoveredAxis = null;
6703
- this.scrollbarActiveAxis = null;
6704
- this.scrollbarLastInteractionAt = 0;
6705
- this.lastScrollbarAlpha = -1;
6706
7230
  this._zoom = 1;
6707
7231
  this._offsetX = 0;
6708
7232
  this._offsetY = 0;
@@ -6722,14 +7246,35 @@ var DiagramRenderer = class extends EventEmitter {
6722
7246
  ...DEFAULT_OPTIONS$1,
6723
7247
  ...options
6724
7248
  };
6725
- this.scrollbar = this.resolveScrollbarOptions(options);
6726
7249
  const ctx = this.canvas.getContext("2d");
6727
7250
  if (ctx === null) throw new Error("Failed to get 2D rendering context");
6728
7251
  this.ctx = ctx;
6729
7252
  this.devicePixelRatio = this.options.retina ? window.devicePixelRatio || 1 : 1;
6730
7253
  this._zoom = this.options.initialZoom;
6731
7254
  this.animationManager = new AnimationManager(this.options.animations);
6732
- this.scrollbarLastInteractionAt = performance.now();
7255
+ this.scrollbarController = new ScrollbarController({
7256
+ getWidth: () => this.width,
7257
+ getHeight: () => this.height,
7258
+ getZoom: () => this.zoom,
7259
+ getOffsetX: () => this.offsetX,
7260
+ setOffsetX: (value) => {
7261
+ this.offsetX = value;
7262
+ },
7263
+ getOffsetY: () => this.offsetY,
7264
+ setOffsetY: (value) => {
7265
+ this.offsetY = value;
7266
+ },
7267
+ getPixelRatio: () => this.pixelRatio,
7268
+ markDirty: () => this.markDirty(),
7269
+ screenToCanvas: (x, y) => this.screenToCanvas(x, y),
7270
+ getContentBounds: () => this.getContentBounds(),
7271
+ isDarkTheme: () => this.styleManager?.theme.name === "dark"
7272
+ }, options);
7273
+ this.edgeEndpointUpdater = new EdgeEndpointUpdater({
7274
+ getNodes: () => this.nodes,
7275
+ getEdges: () => this.edges,
7276
+ getNodeObstacles: () => this.getNodeObstacles()
7277
+ });
6733
7278
  this.setupCanvas();
6734
7279
  this.startRenderLoop();
6735
7280
  }
@@ -6743,7 +7288,7 @@ var DiagramRenderer = class extends EventEmitter {
6743
7288
  const clampedZoom = Math.max(this.options.minZoom, Math.min(this.options.maxZoom, value));
6744
7289
  if (this._zoom !== clampedZoom) {
6745
7290
  this._zoom = clampedZoom;
6746
- this.scrollbarLastInteractionAt = performance.now();
7291
+ this.scrollbarController.notifyInteraction();
6747
7292
  this.markDirty();
6748
7293
  this.emit("zoom", clampedZoom);
6749
7294
  }
@@ -6757,7 +7302,7 @@ var DiagramRenderer = class extends EventEmitter {
6757
7302
  set offsetX(value) {
6758
7303
  if (this._offsetX !== value) {
6759
7304
  this._offsetX = value;
6760
- this.scrollbarLastInteractionAt = performance.now();
7305
+ this.scrollbarController.notifyInteraction();
6761
7306
  this.markDirty();
6762
7307
  this.emit("pan", this._offsetX, this._offsetY);
6763
7308
  }
@@ -6771,7 +7316,7 @@ var DiagramRenderer = class extends EventEmitter {
6771
7316
  set offsetY(value) {
6772
7317
  if (this._offsetY !== value) {
6773
7318
  this._offsetY = value;
6774
- this.scrollbarLastInteractionAt = performance.now();
7319
+ this.scrollbarController.notifyInteraction();
6775
7320
  this.markDirty();
6776
7321
  this.emit("pan", this._offsetX, this._offsetY);
6777
7322
  }
@@ -7235,13 +7780,7 @@ var DiagramRenderer = class extends EventEmitter {
7235
7780
  if (this._destroyed) return;
7236
7781
  const now = performance.now();
7237
7782
  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
- }
7783
+ if (this.scrollbarController.updateAnimation(now)) this._dirty = true;
7245
7784
  this._canvasRect = null;
7246
7785
  if (this._dirty) {
7247
7786
  this.renderFrame(now);
@@ -7271,7 +7810,7 @@ var DiagramRenderer = class extends EventEmitter {
7271
7810
  this.renderElementWithAnimation(ctx, group, () => group.render(ctx));
7272
7811
  group.clearDirty();
7273
7812
  }
7274
- this.updateEdgeEndpoints();
7813
+ this.edgeEndpointUpdater.updateAll();
7275
7814
  for (const node of this._nodes.values()) if (node.visible) {
7276
7815
  this.renderElementWithAnimation(ctx, node, () => node.render(ctx));
7277
7816
  node.clearDirty();
@@ -7283,7 +7822,7 @@ var DiagramRenderer = class extends EventEmitter {
7283
7822
  for (const edge of this._edges.values()) if (edge.visible) edge.renderHandles(ctx);
7284
7823
  for (const overlayRenderer of this.overlayRenderers) overlayRenderer(ctx);
7285
7824
  for (const topOverlay of this.topOverlayRenderers) topOverlay(ctx);
7286
- this.renderScrollbars(ctx);
7825
+ this.scrollbarController.render(ctx, this.frameTime);
7287
7826
  this.emit("render");
7288
7827
  }
7289
7828
  renderElementWithAnimation(ctx, element, renderFn) {
@@ -7315,93 +7854,7 @@ var DiagramRenderer = class extends EventEmitter {
7315
7854
  * Called during drag to keep control points in sync
7316
7855
  */
7317
7856
  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
- }
7857
+ this.edgeEndpointUpdater.updateForDrag();
7405
7858
  }
7406
7859
  getNodeObstacles() {
7407
7860
  if (this._nodeObstaclesCache !== null) return this._nodeObstaclesCache;
@@ -7414,56 +7867,6 @@ var DiagramRenderer = class extends EventEmitter {
7414
7867
  }));
7415
7868
  return this._nodeObstaclesCache;
7416
7869
  }
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
7870
  getContentBounds() {
7468
7871
  return getContentBounds({
7469
7872
  nodes: this._nodes.values(),
@@ -7471,348 +7874,69 @@ var DiagramRenderer = class extends EventEmitter {
7471
7874
  groups: this._groups.values()
7472
7875
  });
7473
7876
  }
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
7877
  notifyScrollbarInteraction() {
7483
- this.scrollbarLastInteractionAt = performance.now();
7484
- this.markDirty();
7878
+ this.scrollbarController.notifyInteraction();
7485
7879
  }
7486
7880
  setScrollbarActiveAxis(axis) {
7487
- if (this.scrollbarActiveAxis === axis) return;
7488
- this.scrollbarActiveAxis = axis;
7489
- if (axis !== null) this.notifyScrollbarInteraction();
7490
- else this.markDirty();
7881
+ this.scrollbarController.setActiveAxis(axis);
7491
7882
  }
7492
7883
  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;
7884
+ return this.scrollbarController.updateHover(screenX, screenY);
7500
7885
  }
7501
7886
  clearScrollbarHover() {
7502
- if (this.scrollbarHoveredAxis !== null) {
7503
- this.scrollbarHoveredAxis = null;
7504
- this.markDirty();
7505
- }
7887
+ this.scrollbarController.clearHover();
7506
7888
  }
7507
7889
  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;
7890
+ return this.scrollbarController.hitTestThumb(screenX, screenY);
7529
7891
  }
7530
7892
  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;
7554
- }
7555
- 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;
7893
+ return this.scrollbarController.dragThumb(axis, screenX, screenY, pointerOffset);
7648
7894
  }
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";
7895
+ clickScrollbarTrack(screenX, screenY) {
7896
+ return this.scrollbarController.clickTrack(screenX, screenY);
7897
+ }
7898
+ scrollViewportBy(screenDx, screenDy) {
7899
+ return this.scrollbarController.scrollBy(screenDx, screenDy);
7900
+ }
7901
+ scrollViewportToStart() {
7902
+ return this.scrollbarController.scrollToStart();
7903
+ }
7904
+ scrollViewportToEnd() {
7905
+ return this.scrollbarController.scrollToEnd();
7906
+ }
7907
+ beginOverlayDrag(screenX, screenY) {
7908
+ for (const plugin of this.plugins) {
7909
+ const interactivePlugin = plugin;
7910
+ if (!interactivePlugin.beginOverlayDrag) continue;
7911
+ const payload = interactivePlugin.beginOverlayDrag(this, screenX, screenY);
7912
+ if (payload !== null && payload !== void 0) return {
7913
+ plugin: interactivePlugin,
7914
+ payload
7915
+ };
7797
7916
  }
7798
7917
  return null;
7799
7918
  }
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);
7919
+ /**
7920
+ * Точка (client / screen) попадает в оверлей, который не должен «пробрасывать» hit-test в диаграмму.
7921
+ */
7922
+ blocksDiagramPointerAtScreen(screenX, screenY) {
7923
+ for (const plugin of this.plugins) if (plugin.blocksDiagramPointerAtScreen?.(this, screenX, screenY)) return true;
7924
+ return false;
7925
+ }
7926
+ updateOverlayDrag(session, screenX, screenY) {
7927
+ return session.plugin.updateOverlayDrag?.(this, screenX, screenY, session.payload) ?? false;
7928
+ }
7929
+ endOverlayDrag(session) {
7930
+ session.plugin.endOverlayDrag?.(this, session.payload);
7931
+ }
7932
+ screenToCanvas(screenX, screenY) {
7933
+ const rect = this._canvasRect ?? this.canvas.getBoundingClientRect();
7934
+ if (rect.width <= 0 || rect.height <= 0) return null;
7935
+ const scaleX = rect.width / this.options.width;
7936
+ const scaleY = rect.height / this.options.height;
7808
7937
  return {
7809
- contentBounds: {
7810
- x: unionMinX,
7811
- y: unionMinY,
7812
- width: unionMaxX - unionMinX,
7813
- height: unionMaxY - unionMinY
7814
- },
7815
- viewportBounds
7938
+ x: (screenX - rect.left) / scaleX,
7939
+ y: (screenY - rect.top) / scaleY
7816
7940
  };
7817
7941
  }
7818
7942
  };
@@ -8032,6 +8156,7 @@ function generatePortId() {
8032
8156
  }
8033
8157
  /**
8034
8158
  * Reset port ID counter (for testing)
8159
+ * @internal
8035
8160
  */
8036
8161
  function resetPortIdCounter() {
8037
8162
  portIdCounter = 1;
@@ -8159,6 +8284,61 @@ var Port = class {
8159
8284
  }
8160
8285
  };
8161
8286
  //#endregion
8287
+ //#region src/utils/svgTint.ts
8288
+ /**
8289
+ * SVG tinting utilities — shared between NodeImage and CIcon.
8290
+ */
8291
+ function styleSetColor(style, key, color) {
8292
+ if (new RegExp(`${key}\\s*:`).test(style)) return style.replace(new RegExp(`${key}\\s*:[^;]+`), `${key}:${color}`);
8293
+ return `${style}${style.trim().endsWith(";") || style.trim() === "" ? "" : ";"}${key}:${color};`;
8294
+ }
8295
+ /**
8296
+ * Tint all stroke/fill attributes and inline styles in an SVG string.
8297
+ */
8298
+ function tintSvg(svgText, strokeColor, fillColor) {
8299
+ if (!strokeColor && !fillColor) return svgText;
8300
+ const root = new DOMParser().parseFromString(svgText, "image/svg+xml").documentElement;
8301
+ if (!root || root.nodeName.toLowerCase() === "parsererror") return svgText;
8302
+ const all = [root, ...Array.from(root.querySelectorAll("*"))];
8303
+ for (const el of all) {
8304
+ const stroke = el.getAttribute("stroke");
8305
+ const fill = el.getAttribute("fill");
8306
+ if (strokeColor && (stroke === null || stroke.toLowerCase() !== "none")) el.setAttribute("stroke", strokeColor);
8307
+ if (fillColor && (fill === null || fill.toLowerCase() !== "none")) el.setAttribute("fill", fillColor);
8308
+ const style = el.getAttribute("style");
8309
+ if (style) {
8310
+ let next = style;
8311
+ if (strokeColor && /stroke\s*:\s*(?!none)/.test(style)) next = styleSetColor(next, "stroke", strokeColor);
8312
+ if (fillColor && /fill\s*:\s*(?!none)/.test(style)) next = styleSetColor(next, "fill", fillColor);
8313
+ if (next !== style) el.setAttribute("style", next);
8314
+ }
8315
+ }
8316
+ return new XMLSerializer().serializeToString(root);
8317
+ }
8318
+ /**
8319
+ * Check if a string contains inline SVG markup.
8320
+ */
8321
+ function isSvgMarkup(value) {
8322
+ const trimmed = value.trim().toLowerCase();
8323
+ return trimmed.startsWith("<svg") || trimmed.includes("<svg");
8324
+ }
8325
+ /**
8326
+ * Convert SVG markup to a data URL for use in HTMLImageElement.src.
8327
+ */
8328
+ function svgToDataUrl(svg) {
8329
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg).replace(/%0A/g, "").replace(/%0D/g, "").replace(/%09/g, " ").replace(/%20/g, " ")}`;
8330
+ }
8331
+ /**
8332
+ * Check if a URL points to an SVG file.
8333
+ */
8334
+ function isSvgUrl(url) {
8335
+ try {
8336
+ return new URL(url, "http://localhost").pathname.endsWith(".svg");
8337
+ } catch {
8338
+ return url.endsWith(".svg");
8339
+ }
8340
+ }
8341
+ //#endregion
8162
8342
  //#region src/utils/LRUCache.ts
8163
8343
  /**
8164
8344
  * LRU (Least Recently Used) cache implementation
@@ -8225,66 +8405,148 @@ var LRUCache = class {
8225
8405
  }
8226
8406
  };
8227
8407
  //#endregion
8228
- //#region src/utils/svgTint.ts
8408
+ //#region src/utils/svgAssetLoader.ts
8409
+ /** Shared LRU for fetched SVG text (NodeImage, CIcon, etc.). */
8410
+ var svgTextCache = new LRUCache(100);
8229
8411
  /**
8230
- * SVG tinting utilities shared between NodeImage and CIcon.
8412
+ * Fetch SVG text with a shared LRU cache.
8231
8413
  */
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};`;
8414
+ function fetchSvgText(url) {
8415
+ let promise = svgTextCache.get(url);
8416
+ if (!promise) {
8417
+ promise = fetch(url).then((r) => r.ok ? r.text() : "");
8418
+ svgTextCache.set(url, promise);
8419
+ }
8420
+ return promise;
8235
8421
  }
8236
8422
  /**
8237
- * Tint all stroke/fill attributes and inline styles in an SVG string.
8423
+ * Synchronously read SVG from a URL (for SvgExporter). Uses cached promise result when already resolved.
8238
8424
  */
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
- }
8256
- }
8257
- return new XMLSerializer().serializeToString(root);
8425
+ function readSvgFromUrlSync(url) {
8426
+ if (typeof XMLHttpRequest === "undefined") return null;
8427
+ try {
8428
+ const xhr = new XMLHttpRequest();
8429
+ xhr.open("GET", url, false);
8430
+ xhr.send();
8431
+ if (xhr.status >= 200 && xhr.status < 300) return xhr.responseText || null;
8432
+ } catch {}
8433
+ return null;
8258
8434
  }
8435
+ //#endregion
8436
+ //#region src/utils/iconLayout.ts
8259
8437
  /**
8260
- * Check if a string contains inline SVG markup.
8438
+ * Icon zone size including inset padding around the image.
8261
8439
  */
8262
- function isSvgMarkup(value) {
8263
- const trimmed = value.trim().toLowerCase();
8264
- return trimmed.startsWith("<svg") || trimmed.includes("<svg");
8440
+ function getIconBoxSize(imageSize, inset) {
8441
+ return {
8442
+ width: imageSize.width + inset * 2,
8443
+ height: imageSize.height + inset * 2
8444
+ };
8265
8445
  }
8266
8446
  /**
8267
- * Convert SVG markup to a data URL for use in HTMLImageElement.src.
8447
+ * Bounds of the icon zone within a node, based on placement.
8268
8448
  */
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, " ")}`;
8449
+ function getIconBounds(bounds, iconBoxSize, placement, inset) {
8450
+ switch (placement) {
8451
+ case "top": return {
8452
+ x: bounds.x,
8453
+ y: bounds.y,
8454
+ width: bounds.width,
8455
+ height: iconBoxSize.height
8456
+ };
8457
+ case "bottom": return {
8458
+ x: bounds.x,
8459
+ y: bounds.y + bounds.height - iconBoxSize.height,
8460
+ width: bounds.width,
8461
+ height: iconBoxSize.height
8462
+ };
8463
+ case "left": return {
8464
+ x: bounds.x,
8465
+ y: bounds.y,
8466
+ width: iconBoxSize.width,
8467
+ height: bounds.height
8468
+ };
8469
+ case "right": return {
8470
+ x: bounds.x + bounds.width - iconBoxSize.width,
8471
+ y: bounds.y,
8472
+ width: iconBoxSize.width,
8473
+ height: bounds.height
8474
+ };
8475
+ case "top-left": return {
8476
+ x: bounds.x + inset,
8477
+ y: bounds.y + inset,
8478
+ width: iconBoxSize.width,
8479
+ height: iconBoxSize.height
8480
+ };
8481
+ case "top-right": return {
8482
+ x: bounds.x + bounds.width - iconBoxSize.width - inset,
8483
+ y: bounds.y + inset,
8484
+ width: iconBoxSize.width,
8485
+ height: iconBoxSize.height
8486
+ };
8487
+ case "bottom-left": return {
8488
+ x: bounds.x + inset,
8489
+ y: bounds.y + bounds.height - iconBoxSize.height - inset,
8490
+ width: iconBoxSize.width,
8491
+ height: iconBoxSize.height
8492
+ };
8493
+ case "bottom-right": return {
8494
+ x: bounds.x + bounds.width - iconBoxSize.width - inset,
8495
+ y: bounds.y + bounds.height - iconBoxSize.height - inset,
8496
+ width: iconBoxSize.width,
8497
+ height: iconBoxSize.height
8498
+ };
8499
+ default: return bounds;
8500
+ }
8271
8501
  }
8272
8502
  /**
8273
- * Check if a URL points to an SVG file.
8503
+ * Compute the draw rectangle for an icon image inside its zone bounds.
8274
8504
  */
8275
- function isSvgUrl(url) {
8276
- try {
8277
- return new URL(url, "http://localhost").pathname.endsWith(".svg");
8278
- } catch {
8279
- return url.endsWith(".svg");
8280
- }
8505
+ function computeIconDrawRect(bounds, opts, imageSize, inset) {
8506
+ const fit = opts.fit ?? "none";
8507
+ const scaleWithBounds = opts.scaleWithBounds ?? false;
8508
+ const innerBounds = {
8509
+ x: bounds.x + inset,
8510
+ y: bounds.y + inset,
8511
+ width: Math.max(0, bounds.width - inset * 2),
8512
+ height: Math.max(0, bounds.height - inset * 2)
8513
+ };
8514
+ const availableWidth = Math.max(0, innerBounds.width);
8515
+ const availableHeight = Math.max(0, innerBounds.height);
8516
+ let drawWidth = opts.width ?? imageSize.width;
8517
+ let drawHeight = opts.height ?? imageSize.height;
8518
+ if (scaleWithBounds) {
8519
+ if ((fit === "contain" || fit === "cover") && imageSize.width > 0 && imageSize.height > 0) {
8520
+ const scaleX = availableWidth / imageSize.width;
8521
+ const scaleY = availableHeight / imageSize.height;
8522
+ const scale = fit === "contain" ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY);
8523
+ drawWidth = imageSize.width * scale;
8524
+ drawHeight = imageSize.height * scale;
8525
+ } else if (fit === "stretch") {
8526
+ drawWidth = availableWidth;
8527
+ drawHeight = availableHeight;
8528
+ }
8529
+ }
8530
+ drawWidth = Math.min(Math.max(0, drawWidth), Math.max(0, availableWidth));
8531
+ drawHeight = Math.min(Math.max(0, drawHeight), Math.max(0, availableHeight));
8532
+ return {
8533
+ x: innerBounds.x + (innerBounds.width - drawWidth) / 2,
8534
+ y: innerBounds.y + (innerBounds.height - drawHeight) / 2,
8535
+ width: drawWidth,
8536
+ height: drawHeight
8537
+ };
8538
+ }
8539
+ /**
8540
+ * Convenience: compute draw rect from NodeImageOptions.
8541
+ */
8542
+ function computeIconDrawRectFromOptions(bounds, opts, imageSize, inset) {
8543
+ return computeIconDrawRect(bounds, opts, imageSize, inset);
8281
8544
  }
8282
8545
  //#endregion
8283
8546
  //#region src/elements/NodeImage.ts
8284
8547
  function isCornerPlacement(placement) {
8285
8548
  return placement === "top-left" || placement === "top-right" || placement === "bottom-left" || placement === "bottom-right";
8286
8549
  }
8287
- var svgTextCache = new LRUCache(100);
8288
8550
  /**
8289
8551
  * Image helper for node rendering (supports images and inline SVG).
8290
8552
  */
@@ -8327,40 +8589,15 @@ var NodeImage = class {
8327
8589
  render(ctx, bounds) {
8328
8590
  if (!this._loaded) return;
8329
8591
  const ins = this.inset;
8330
- const fit = this._options.fit ?? "none";
8331
- const scaleWithBounds = this._options.scaleWithBounds ?? false;
8332
8592
  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;
8593
+ const drawRect = computeIconDrawRect(bounds, this._options, {
8594
+ width: this._naturalWidth,
8595
+ height: this._naturalHeight
8596
+ }, ins);
8597
+ if (drawRect.width <= 0 || drawRect.height <= 0) return;
8361
8598
  ctx.save();
8362
8599
  ctx.globalAlpha = opacity;
8363
- ctx.drawImage(this._image, x, y, drawWidth, drawHeight);
8600
+ ctx.drawImage(this._image, drawRect.x, drawRect.y, drawRect.width, drawRect.height);
8364
8601
  ctx.restore();
8365
8602
  }
8366
8603
  getSize() {
@@ -8385,12 +8622,7 @@ var NodeImage = class {
8385
8622
  return;
8386
8623
  }
8387
8624
  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;
8625
+ const svgText = await fetchSvgText(source);
8394
8626
  if (version !== this._sourceVersion) return;
8395
8627
  if (svgText) {
8396
8628
  const tinted = tintSvg(svgText, this._options.strokeColor, this._options.fillColor);
@@ -9163,65 +9395,13 @@ var Node = class extends Element {
9163
9395
  if (!this._icon) return;
9164
9396
  const { width, height } = this._icon.getSize();
9165
9397
  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
- };
9398
+ return getIconBoxSize({
9399
+ width,
9400
+ height
9401
+ }, this._icon.inset);
9171
9402
  }
9172
9403
  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
- }
9404
+ return getIconBounds(bounds, iconBoxSize, placement, this._icon?.inset ?? 0);
9225
9405
  }
9226
9406
  normalizeAnchorPoints(config) {
9227
9407
  const normalize = (value) => {
@@ -10122,6 +10302,23 @@ var ShapeFactories = {
10122
10302
  }
10123
10303
  };
10124
10304
  //#endregion
10305
+ //#region src/elements/composite/CompositeComponentBase.ts
10306
+ /**
10307
+ * Shared change notification and default bounds hit testing for composite components.
10308
+ */
10309
+ var CompositeComponentBase = class {
10310
+ setOnChange(cb) {
10311
+ this._onChange = cb;
10312
+ }
10313
+ hitTest(point, bounds) {
10314
+ if (this.style.visible === false) return null;
10315
+ return point.x >= bounds.x && point.x <= bounds.x + bounds.width && point.y >= bounds.y && point.y <= bounds.y + bounds.height ? this : null;
10316
+ }
10317
+ markChanged() {
10318
+ this._onChange?.();
10319
+ }
10320
+ };
10321
+ //#endregion
10125
10322
  //#region src/elements/composite/CText.ts
10126
10323
  var DEFAULT_FONT_FAMILY = "sans-serif";
10127
10324
  var DEFAULT_FONT_SIZE = 14;
@@ -10131,8 +10328,9 @@ var DEFAULT_COLOR$1 = "#000000";
10131
10328
  * Lightweight text component for CompositeNode.
10132
10329
  * Renders directly via canvas ctx without delegating to TextLabel.
10133
10330
  */
10134
- var CText = class {
10331
+ var CText = class extends CompositeComponentBase {
10135
10332
  constructor(options) {
10333
+ super();
10136
10334
  this.type = "text";
10137
10335
  this._cachedLines = null;
10138
10336
  this.id = options.id;
@@ -10158,7 +10356,7 @@ var CText = class {
10158
10356
  if (this._text !== value) {
10159
10357
  this._text = value;
10160
10358
  this._cachedLines = null;
10161
- this._onChange?.();
10359
+ this.markChanged();
10162
10360
  }
10163
10361
  }
10164
10362
  get fontFamily() {
@@ -10167,7 +10365,7 @@ var CText = class {
10167
10365
  set fontFamily(value) {
10168
10366
  if (this._fontFamily !== value) {
10169
10367
  this._fontFamily = value;
10170
- this._onChange?.();
10368
+ this.markChanged();
10171
10369
  }
10172
10370
  }
10173
10371
  get fontWeight() {
@@ -10176,7 +10374,7 @@ var CText = class {
10176
10374
  set fontWeight(value) {
10177
10375
  if (this._fontWeight !== value) {
10178
10376
  this._fontWeight = value;
10179
- this._onChange?.();
10377
+ this.markChanged();
10180
10378
  }
10181
10379
  }
10182
10380
  get fontStyle() {
@@ -10185,7 +10383,7 @@ var CText = class {
10185
10383
  set fontStyle(value) {
10186
10384
  if (this._fontStyle !== value) {
10187
10385
  this._fontStyle = value;
10188
- this._onChange?.();
10386
+ this.markChanged();
10189
10387
  }
10190
10388
  }
10191
10389
  get fontSize() {
@@ -10194,7 +10392,7 @@ var CText = class {
10194
10392
  set fontSize(value) {
10195
10393
  if (this._fontSize !== value) {
10196
10394
  this._fontSize = value;
10197
- this._onChange?.();
10395
+ this.markChanged();
10198
10396
  }
10199
10397
  }
10200
10398
  get color() {
@@ -10203,7 +10401,7 @@ var CText = class {
10203
10401
  set color(value) {
10204
10402
  if (this._color !== value) {
10205
10403
  this._color = value;
10206
- this._onChange?.();
10404
+ this.markChanged();
10207
10405
  }
10208
10406
  }
10209
10407
  get align() {
@@ -10212,7 +10410,7 @@ var CText = class {
10212
10410
  set align(value) {
10213
10411
  if (this._align !== value) {
10214
10412
  this._align = value;
10215
- this._onChange?.();
10413
+ this.markChanged();
10216
10414
  }
10217
10415
  }
10218
10416
  get verticalAlign() {
@@ -10221,7 +10419,7 @@ var CText = class {
10221
10419
  set verticalAlign(value) {
10222
10420
  if (this._verticalAlign !== value) {
10223
10421
  this._verticalAlign = value;
10224
- this._onChange?.();
10422
+ this.markChanged();
10225
10423
  }
10226
10424
  }
10227
10425
  get maxLines() {
@@ -10230,7 +10428,7 @@ var CText = class {
10230
10428
  set maxLines(value) {
10231
10429
  if (this._maxLines !== value) {
10232
10430
  this._maxLines = value;
10233
- this._onChange?.();
10431
+ this.markChanged();
10234
10432
  }
10235
10433
  }
10236
10434
  get lineHeight() {
@@ -10248,12 +10446,9 @@ var CText = class {
10248
10446
  set rotation(value) {
10249
10447
  if (this._rotation !== value) {
10250
10448
  this._rotation = value;
10251
- this._onChange?.();
10449
+ this.markChanged();
10252
10450
  }
10253
10451
  }
10254
- setOnChange(cb) {
10255
- this._onChange = cb;
10256
- }
10257
10452
  getFont() {
10258
10453
  return `${this._fontStyle} ${this._fontWeight} ${this._fontSize}px ${this._fontFamily}`;
10259
10454
  }
@@ -10265,34 +10460,16 @@ var CText = class {
10265
10460
  */
10266
10461
  wrapText(ctx, maxWidth) {
10267
10462
  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;
10463
+ return wrapMeasuredText(ctx, this._text, maxWidth, {
10464
+ maxLines: this._maxLines,
10465
+ appendEllipsis: true
10466
+ });
10286
10467
  }
10287
10468
  measure(ctx) {
10288
10469
  ctx.font = this.getFont();
10289
10470
  const lineHeightPx = this.getLineHeightPx();
10290
10471
  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
- }
10472
+ const maxWidth = measureTextLines(ctx, rawLines);
10296
10473
  let lineCount = rawLines.length;
10297
10474
  if (this._maxLines !== void 0 && lineCount > this._maxLines) lineCount = this._maxLines;
10298
10475
  const width = maxWidth;
@@ -10366,11 +10543,6 @@ var CText = class {
10366
10543
  } else for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], xBase, yStart + i * lineHeightPx);
10367
10544
  ctx.restore();
10368
10545
  }
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;
10373
- }
10374
10546
  serialize() {
10375
10547
  const data = {
10376
10548
  type: "text",
@@ -10442,12 +10614,12 @@ function escapeXml(str) {
10442
10614
  //#endregion
10443
10615
  //#region src/elements/composite/CIcon.ts
10444
10616
  var DEFAULT_ICON_SIZE = 24;
10445
- var svgFetchCache = new LRUCache(100);
10446
10617
  /**
10447
10618
  * Lightweight SVG/image icon component for CompositeNode.
10448
10619
  */
10449
- var CIcon = class {
10620
+ var CIcon = class extends CompositeComponentBase {
10450
10621
  constructor(options) {
10622
+ super();
10451
10623
  this.type = "icon";
10452
10624
  this._loaded = false;
10453
10625
  this.id = options.id;
@@ -10463,10 +10635,10 @@ var CIcon = class {
10463
10635
  this._image = new Image();
10464
10636
  this._image.onload = () => {
10465
10637
  this._loaded = true;
10466
- this._onChange?.();
10638
+ this.markChanged();
10467
10639
  };
10468
10640
  this._image.onerror = () => {
10469
- this._onChange?.();
10641
+ this.markChanged();
10470
10642
  };
10471
10643
  this.loadSource(this._source);
10472
10644
  }
@@ -10478,7 +10650,7 @@ var CIcon = class {
10478
10650
  this._source = value;
10479
10651
  this._loaded = false;
10480
10652
  this.loadSource(value);
10481
- this._onChange?.();
10653
+ this.markChanged();
10482
10654
  }
10483
10655
  }
10484
10656
  get width() {
@@ -10487,7 +10659,7 @@ var CIcon = class {
10487
10659
  set width(value) {
10488
10660
  if (this._width !== value) {
10489
10661
  this._width = value;
10490
- this._onChange?.();
10662
+ this.markChanged();
10491
10663
  }
10492
10664
  }
10493
10665
  get height() {
@@ -10496,7 +10668,7 @@ var CIcon = class {
10496
10668
  set height(value) {
10497
10669
  if (this._height !== value) {
10498
10670
  this._height = value;
10499
- this._onChange?.();
10671
+ this.markChanged();
10500
10672
  }
10501
10673
  }
10502
10674
  get backgroundColor() {
@@ -10505,7 +10677,7 @@ var CIcon = class {
10505
10677
  set backgroundColor(value) {
10506
10678
  if (this._backgroundColor !== value) {
10507
10679
  this._backgroundColor = value;
10508
- this._onChange?.();
10680
+ this.markChanged();
10509
10681
  }
10510
10682
  }
10511
10683
  get fillColor() {
@@ -10515,7 +10687,7 @@ var CIcon = class {
10515
10687
  if (this._fillColor !== value) {
10516
10688
  this._fillColor = value;
10517
10689
  this.loadSource(this._source);
10518
- this._onChange?.();
10690
+ this.markChanged();
10519
10691
  }
10520
10692
  }
10521
10693
  get loaded() {
@@ -10527,32 +10699,21 @@ var CIcon = class {
10527
10699
  set bindsNotationIcon(value) {
10528
10700
  if (this._bindsNotationIcon !== value) {
10529
10701
  this._bindsNotationIcon = value;
10530
- this._onChange?.();
10531
- }
10532
- }
10533
- setOnChange(cb) {
10534
- this._onChange = cb;
10702
+ this.markChanged();
10703
+ }
10535
10704
  }
10536
10705
  async loadSource(source) {
10537
10706
  if (isSvgMarkup(source)) {
10538
10707
  const tinted = this._fillColor ? tintSvg(source, void 0, this._fillColor) : source;
10539
10708
  this._image.src = svgToDataUrl(tinted);
10540
10709
  } else if (isSvgUrl(source) && this._fillColor) try {
10541
- const tinted = tintSvg(await this.fetchSvg(source), void 0, this._fillColor);
10710
+ const tinted = tintSvg(await fetchSvgText(source), void 0, this._fillColor);
10542
10711
  this._image.src = svgToDataUrl(tinted);
10543
10712
  } catch {
10544
10713
  this._image.src = source;
10545
10714
  }
10546
10715
  else this._image.src = source;
10547
10716
  }
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;
10555
- }
10556
10717
  measure(_ctx) {
10557
10718
  return {
10558
10719
  width: this._width,
@@ -10578,11 +10739,6 @@ var CIcon = class {
10578
10739
  }
10579
10740
  ctx.restore();
10580
10741
  }
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
10742
  serialize() {
10587
10743
  const data = {
10588
10744
  type: "icon",
@@ -10622,8 +10778,9 @@ var DEFAULT_THICKNESS = 1;
10622
10778
  * - In a column container → horizontal line
10623
10779
  * - In a row container → vertical line
10624
10780
  */
10625
- var CDivider = class {
10781
+ var CDivider = class extends CompositeComponentBase {
10626
10782
  constructor(options = {}) {
10783
+ super();
10627
10784
  this.type = "divider";
10628
10785
  this.id = options.id;
10629
10786
  this._color = options.color ?? DEFAULT_COLOR;
@@ -10636,7 +10793,7 @@ var CDivider = class {
10636
10793
  set color(value) {
10637
10794
  if (this._color !== value) {
10638
10795
  this._color = value;
10639
- this._onChange?.();
10796
+ this.markChanged();
10640
10797
  }
10641
10798
  }
10642
10799
  get thickness() {
@@ -10645,12 +10802,9 @@ var CDivider = class {
10645
10802
  set thickness(value) {
10646
10803
  if (this._thickness !== value) {
10647
10804
  this._thickness = value;
10648
- this._onChange?.();
10805
+ this.markChanged();
10649
10806
  }
10650
10807
  }
10651
- setOnChange(cb) {
10652
- this._onChange = cb;
10653
- }
10654
10808
  measure(_ctx) {
10655
10809
  return {
10656
10810
  width: this._thickness,
@@ -10676,9 +10830,6 @@ var CDivider = class {
10676
10830
  ctx.stroke();
10677
10831
  ctx.restore();
10678
10832
  }
10679
- hitTest(_point, _bounds) {
10680
- return null;
10681
- }
10682
10833
  serialize() {
10683
10834
  const data = { type: "divider" };
10684
10835
  if (this.id !== void 0) data.id = this.id;
@@ -10893,12 +11044,19 @@ function flexLayout(container, config, children) {
10893
11044
  }
10894
11045
  //#endregion
10895
11046
  //#region src/elements/composite/CContainer.ts
11047
+ function isCContainer(component) {
11048
+ return component.type === "container";
11049
+ }
11050
+ function isCShape(component) {
11051
+ return component.type === "shape";
11052
+ }
10896
11053
  /**
10897
11054
  * Flex container component for CompositeNode.
10898
11055
  * Lays out children using a flexbox-like algorithm.
10899
11056
  */
10900
- var CContainer = class {
11057
+ var CContainer = class extends CompositeComponentBase {
10901
11058
  constructor(options = {}) {
11059
+ super();
10902
11060
  this.type = "container";
10903
11061
  this._cachedBounds = null;
10904
11062
  this._cachedContainerBounds = null;
@@ -10948,12 +11106,9 @@ var CContainer = class {
10948
11106
  this._children.splice(index, 0, child);
10949
11107
  this.handleChildChange();
10950
11108
  }
10951
- setOnChange(cb) {
10952
- this._onChange = cb;
10953
- }
10954
11109
  handleChildChange() {
10955
11110
  this._cachedBounds = null;
10956
- this._onChange?.();
11111
+ this.markChanged();
10957
11112
  }
10958
11113
  getFlexConfig() {
10959
11114
  return {
@@ -11047,11 +11202,11 @@ var CContainer = class {
11047
11202
  findById(id) {
11048
11203
  for (const child of this._children) {
11049
11204
  if (child.id === id) return child;
11050
- if (child.type === "container") {
11205
+ if (isCContainer(child)) {
11051
11206
  const found = child.findById(id);
11052
11207
  if (found) return found;
11053
11208
  }
11054
- if (child.type === "shape") {
11209
+ if (isCShape(child)) {
11055
11210
  const content = child.content;
11056
11211
  if (content) {
11057
11212
  const found = content.findById(id);
@@ -11107,8 +11262,9 @@ var CContainer = class {
11107
11262
  * A bordered box component for CompositeNode.
11108
11263
  * Can contain a CContainer for recursive composition (e.g. UML class sections).
11109
11264
  */
11110
- var CShape = class {
11265
+ var CShape = class extends CompositeComponentBase {
11111
11266
  constructor(options = {}) {
11267
+ super();
11112
11268
  this.type = "shape";
11113
11269
  this.id = options.id;
11114
11270
  this._borderColor = options.borderColor;
@@ -11119,7 +11275,7 @@ var CShape = class {
11119
11275
  this.onClick = options.onClick;
11120
11276
  this._content = options.content;
11121
11277
  this.style = options.style ?? {};
11122
- if (this._content) this._content.setOnChange(() => this._onChange?.());
11278
+ if (this._content) this._content.setOnChange(() => this.markChanged());
11123
11279
  }
11124
11280
  get borderColor() {
11125
11281
  return this._borderColor;
@@ -11127,7 +11283,7 @@ var CShape = class {
11127
11283
  set borderColor(value) {
11128
11284
  if (this._borderColor !== value) {
11129
11285
  this._borderColor = value;
11130
- this._onChange?.();
11286
+ this.markChanged();
11131
11287
  }
11132
11288
  }
11133
11289
  get borderWidth() {
@@ -11136,7 +11292,7 @@ var CShape = class {
11136
11292
  set borderWidth(value) {
11137
11293
  if (this._borderWidth !== value) {
11138
11294
  this._borderWidth = value;
11139
- this._onChange?.();
11295
+ this.markChanged();
11140
11296
  }
11141
11297
  }
11142
11298
  get backgroundColor() {
@@ -11145,7 +11301,7 @@ var CShape = class {
11145
11301
  set backgroundColor(value) {
11146
11302
  if (this._backgroundColor !== value) {
11147
11303
  this._backgroundColor = value;
11148
- this._onChange?.();
11304
+ this.markChanged();
11149
11305
  }
11150
11306
  }
11151
11307
  get cornerRadius() {
@@ -11157,9 +11313,6 @@ var CShape = class {
11157
11313
  get content() {
11158
11314
  return this._content;
11159
11315
  }
11160
- setOnChange(cb) {
11161
- this._onChange = cb;
11162
- }
11163
11316
  measure(ctx) {
11164
11317
  const pad = normalizeSides(this._padding);
11165
11318
  const bw = this._borderWidth;
@@ -11210,8 +11363,7 @@ var CShape = class {
11210
11363
  ctx.restore();
11211
11364
  }
11212
11365
  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;
11366
+ if (!super.hitTest(point, bounds)) return null;
11215
11367
  if (this._content) {
11216
11368
  const pad = normalizeSides(this._padding);
11217
11369
  const bw = this._borderWidth;
@@ -11335,10 +11487,15 @@ function deserializeCComponent(data) {
11335
11487
  var CompositeNode = class extends Node {
11336
11488
  constructor(options) {
11337
11489
  super(options);
11490
+ this._svgPathFactory = null;
11338
11491
  this._content = options.content;
11339
11492
  this._shapeType = options.shapeType ?? "rectangle";
11340
11493
  this._cornerRadius = options.cornerRadius ?? 0;
11341
11494
  this._pathFactory = options.pathFactory;
11495
+ if (options.svgPath !== void 0) if (typeof options.svgPath === "string") {
11496
+ const staticSvg = options.svgPath;
11497
+ this._svgPathFactory = () => staticSvg;
11498
+ } else this._svgPathFactory = options.svgPath;
11342
11499
  this._autoSize = options.autoSize ?? false;
11343
11500
  this._minWidth = options.minWidth ?? 0;
11344
11501
  this._minHeight = options.minHeight ?? 0;
@@ -11356,6 +11513,13 @@ var CompositeNode = class extends Node {
11356
11513
  get cornerRadius() {
11357
11514
  return this._cornerRadius;
11358
11515
  }
11516
+ /**
11517
+ * Get SVG path string for export. Returns null if svgPath was not provided.
11518
+ */
11519
+ getSvgPath() {
11520
+ if (this._svgPathFactory === null) return null;
11521
+ return this._svgPathFactory(this._width, this._height);
11522
+ }
11359
11523
  get autoSize() {
11360
11524
  return this._autoSize;
11361
11525
  }
@@ -12287,174 +12451,6 @@ var ImageExporter = class {
12287
12451
  }
12288
12452
  };
12289
12453
  //#endregion
12290
- //#region src/utils/markers.ts
12291
- /**
12292
- * Calculate marker points based on edge path
12293
- */
12294
- function calculateMarkerPoints(path, position, type) {
12295
- if (path.length < 2) return null;
12296
- if (type === "bezier" && path.length >= 4) {
12297
- const epsilon = .001;
12298
- const isSame = (a, b) => Math.abs(a.x - b.x) < epsilon && Math.abs(a.y - b.y) < epsilon;
12299
- if (position === "end") {
12300
- const endIndex = path.length - 1;
12301
- const endPoint = path[endIndex];
12302
- let from = path[endIndex - 1];
12303
- if (isSame(from, endPoint)) {
12304
- from = path[endIndex - 2];
12305
- if (isSame(from, endPoint)) from = path[0];
12306
- }
12307
- return {
12308
- from,
12309
- to: endPoint
12310
- };
12311
- }
12312
- const start = path[0];
12313
- let next = path[1];
12314
- if (isSame(next, start)) {
12315
- next = path[2];
12316
- if (isSame(next, start)) next = path[path.length - 1];
12317
- }
12318
- return {
12319
- from: next,
12320
- to: start
12321
- };
12322
- }
12323
- if (position === "end") return {
12324
- from: path[path.length - 2],
12325
- to: path[path.length - 1]
12326
- };
12327
- return {
12328
- from: path[1],
12329
- to: path[0]
12330
- };
12331
- }
12332
- /**
12333
- * Calculate arrow marker vertex points
12334
- */
12335
- function calculateArrowMarkerPoints(to, angle, size) {
12336
- return {
12337
- tip: to,
12338
- left: {
12339
- x: to.x - size * Math.cos(angle - ARROW_ANGLE),
12340
- y: to.y - size * Math.sin(angle - ARROW_ANGLE)
12341
- },
12342
- right: {
12343
- x: to.x - size * Math.cos(angle + ARROW_ANGLE),
12344
- y: to.y - size * Math.sin(angle + ARROW_ANGLE)
12345
- }
12346
- };
12347
- }
12348
- /**
12349
- * Calculate diamond marker vertex points
12350
- */
12351
- function calculateDiamondMarkerPoints(to, angle, size) {
12352
- const halfLength = size / 2;
12353
- const halfWidth = size * .3;
12354
- const cos = Math.cos(angle);
12355
- const sin = Math.sin(angle);
12356
- return [
12357
- {
12358
- x: to.x,
12359
- y: to.y
12360
- },
12361
- {
12362
- x: to.x - halfLength * cos + halfWidth * sin,
12363
- y: to.y - halfLength * sin - halfWidth * cos
12364
- },
12365
- {
12366
- x: to.x - size * cos,
12367
- y: to.y - size * sin
12368
- },
12369
- {
12370
- x: to.x - halfLength * cos - halfWidth * sin,
12371
- y: to.y - halfLength * sin + halfWidth * cos
12372
- }
12373
- ];
12374
- }
12375
- /**
12376
- * Calculate circle marker center
12377
- */
12378
- function calculateCircleMarkerCenter(to, angle, size) {
12379
- return {
12380
- x: to.x - size * Math.cos(angle),
12381
- y: to.y - size * Math.sin(angle)
12382
- };
12383
- }
12384
- /**
12385
- * Generate SVG path for arrow marker
12386
- */
12387
- function generateSvgArrowMarker(to, angle, size, fill, fillOpacity, stroke) {
12388
- const { left, right } = calculateArrowMarkerPoints(to, angle, size);
12389
- 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"/>`;
12390
- }
12391
- /**
12392
- * Generate SVG path for open arrow marker
12393
- */
12394
- function generateSvgOpenArrowMarker(to, angle, size, stroke) {
12395
- const { left, right } = calculateArrowMarkerPoints(to, angle, size);
12396
- 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"/>`;
12397
- }
12398
- /**
12399
- * Generate SVG path for diamond marker
12400
- */
12401
- function generateSvgDiamondMarker(to, angle, size, fill, fillOpacity, stroke) {
12402
- const points = calculateDiamondMarkerPoints(to, angle, size);
12403
- 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"/>`;
12404
- }
12405
- /**
12406
- * Generate SVG circle marker
12407
- */
12408
- function generateSvgCircleMarker(to, angle, size, fill, fillOpacity, stroke) {
12409
- const { x: cx, y: cy } = calculateCircleMarkerCenter(to, angle, size);
12410
- return `<circle cx="${cx}" cy="${cy}" r="${size}" fill="${fill}" fill-opacity="${fillOpacity}" stroke="${stroke}" stroke-width="1"/>`;
12411
- }
12412
- /**
12413
- * Square marker: front edge centered at `to`, depth 2×size along the edge toward `from`.
12414
- */
12415
- function generateSvgSquareMarker(to, angle, size, fill, fillOpacity, stroke) {
12416
- const cos = Math.cos(angle);
12417
- const sin = Math.sin(angle);
12418
- const px = -sin;
12419
- const py = cos;
12420
- const p1 = {
12421
- x: to.x + size * px,
12422
- y: to.y + size * py
12423
- };
12424
- const p2 = {
12425
- x: to.x - size * px,
12426
- y: to.y - size * py
12427
- };
12428
- const bx = 2 * size * cos;
12429
- const by = 2 * size * sin;
12430
- const p3 = {
12431
- x: p2.x - bx,
12432
- y: p2.y - by
12433
- };
12434
- const p4 = {
12435
- x: p1.x - bx,
12436
- y: p1.y - by
12437
- };
12438
- 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"/>`;
12439
- }
12440
- /**
12441
- * Generate SVG path for any marker type
12442
- */
12443
- function generateSvgMarker(marker, from, to, edgeStroke) {
12444
- const angle = Math.atan2(to.y - from.y, to.x - from.x);
12445
- const size = marker.size ?? 12;
12446
- const stroke = marker.strokeColor ?? edgeStroke;
12447
- const fill = marker.fillColor ?? stroke;
12448
- const fillOpacity = marker.fillOpacity ?? 1;
12449
- switch (marker.type) {
12450
- case "open": return generateSvgOpenArrowMarker(to, angle, size, stroke);
12451
- case "diamond": return generateSvgDiamondMarker(to, angle, size, fill, fillOpacity, stroke);
12452
- case "circle": return generateSvgCircleMarker(to, angle, size, fill, fillOpacity, stroke);
12453
- case "square": return generateSvgSquareMarker(to, angle, size, fill, fillOpacity, stroke);
12454
- default: return generateSvgArrowMarker(to, angle, size, fill, fillOpacity, stroke);
12455
- }
12456
- }
12457
- //#endregion
12458
12454
  //#region src/utils/SvgExporter.ts
12459
12455
  /**
12460
12456
  * Exports diagram to SVG
@@ -12575,6 +12571,15 @@ var SvgExporter = class {
12575
12571
  ].join(" ")}"`;
12576
12572
  break;
12577
12573
  }
12574
+ case "custom": {
12575
+ const svgPath = cn.getSvgPath();
12576
+ if (svgPath) shape = `<path d="${this.escapeAttribute(svgPath)}" transform="translate(${bounds.x}, ${bounds.y})"`;
12577
+ else {
12578
+ const radius = cn.cornerRadius;
12579
+ shape = `<rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}" rx="${radius}" ry="${radius}"`;
12580
+ }
12581
+ break;
12582
+ }
12578
12583
  default: {
12579
12584
  const radius = cn.cornerRadius;
12580
12585
  shape = `<rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}" rx="${radius}" ry="${radius}"`;
@@ -12948,9 +12953,7 @@ var SvgExporter = class {
12948
12953
  const iconSize = icon.getSize();
12949
12954
  if (iconSize.width <= 0 || iconSize.height <= 0) return "";
12950
12955
  const iconInset = icon.inset;
12951
- const iconBoxSize = this.getIconBoxSize(iconSize, iconInset);
12952
- const iconBounds = this.getIconBounds(nodeBounds, iconBoxSize, opts.placement ?? "center", iconInset);
12953
- const drawRect = this.getIconDrawRect(iconBounds, opts, iconSize, iconInset);
12956
+ const drawRect = computeIconDrawRectFromOptions(getIconBounds(nodeBounds, getIconBoxSize(iconSize, iconInset), opts.placement ?? "center", iconInset), opts, iconSize, iconInset);
12954
12957
  if (drawRect.width <= 0 || drawRect.height <= 0) return "";
12955
12958
  const href = this.resolveIconHref(opts);
12956
12959
  if (!href) return "";
@@ -12958,151 +12961,17 @@ var SvgExporter = class {
12958
12961
  const escapedHref = this.escapeAttribute(href);
12959
12962
  return `<image href="${escapedHref}" xlink:href="${escapedHref}" x="${drawRect.x}" y="${drawRect.y}" width="${drawRect.width}" height="${drawRect.height}" opacity="${opacity}" preserveAspectRatio="none"/>`;
12960
12963
  }
12961
- getIconBoxSize(imageSize, inset) {
12962
- return {
12963
- width: imageSize.width + inset * 2,
12964
- height: imageSize.height + inset * 2
12965
- };
12966
- }
12967
- getIconBounds(bounds, iconBoxSize, placement, inset) {
12968
- switch (placement) {
12969
- case "top": return {
12970
- x: bounds.x,
12971
- y: bounds.y,
12972
- width: bounds.width,
12973
- height: iconBoxSize.height
12974
- };
12975
- case "bottom": return {
12976
- x: bounds.x,
12977
- y: bounds.y + bounds.height - iconBoxSize.height,
12978
- width: bounds.width,
12979
- height: iconBoxSize.height
12980
- };
12981
- case "left": return {
12982
- x: bounds.x,
12983
- y: bounds.y,
12984
- width: iconBoxSize.width,
12985
- height: bounds.height
12986
- };
12987
- case "right": return {
12988
- x: bounds.x + bounds.width - iconBoxSize.width,
12989
- y: bounds.y,
12990
- width: iconBoxSize.width,
12991
- height: bounds.height
12992
- };
12993
- case "top-left": return {
12994
- x: bounds.x + inset,
12995
- y: bounds.y + inset,
12996
- width: iconBoxSize.width,
12997
- height: iconBoxSize.height
12998
- };
12999
- case "top-right": return {
13000
- x: bounds.x + bounds.width - iconBoxSize.width - inset,
13001
- y: bounds.y + inset,
13002
- width: iconBoxSize.width,
13003
- height: iconBoxSize.height
13004
- };
13005
- case "bottom-left": return {
13006
- x: bounds.x + inset,
13007
- y: bounds.y + bounds.height - iconBoxSize.height - inset,
13008
- width: iconBoxSize.width,
13009
- height: iconBoxSize.height
13010
- };
13011
- case "bottom-right": return {
13012
- x: bounds.x + bounds.width - iconBoxSize.width - inset,
13013
- y: bounds.y + bounds.height - iconBoxSize.height - inset,
13014
- width: iconBoxSize.width,
13015
- height: iconBoxSize.height
13016
- };
13017
- default: return bounds;
13018
- }
13019
- }
13020
- getIconDrawRect(bounds, opts, imageSize, inset) {
13021
- const fit = opts.fit ?? "none";
13022
- const scaleWithBounds = opts.scaleWithBounds ?? false;
13023
- const innerBounds = {
13024
- x: bounds.x + inset,
13025
- y: bounds.y + inset,
13026
- width: Math.max(0, bounds.width - inset * 2),
13027
- height: Math.max(0, bounds.height - inset * 2)
13028
- };
13029
- const availableWidth = Math.max(0, innerBounds.width);
13030
- const availableHeight = Math.max(0, innerBounds.height);
13031
- let drawWidth = opts.width ?? imageSize.width;
13032
- let drawHeight = opts.height ?? imageSize.height;
13033
- if (scaleWithBounds) {
13034
- if ((fit === "contain" || fit === "cover") && imageSize.width > 0 && imageSize.height > 0) {
13035
- const scaleX = availableWidth / imageSize.width;
13036
- const scaleY = availableHeight / imageSize.height;
13037
- const scale = fit === "contain" ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY);
13038
- drawWidth = imageSize.width * scale;
13039
- drawHeight = imageSize.height * scale;
13040
- } else if (fit === "stretch") {
13041
- drawWidth = availableWidth;
13042
- drawHeight = availableHeight;
13043
- }
13044
- }
13045
- drawWidth = Math.min(Math.max(0, drawWidth), Math.max(0, availableWidth));
13046
- drawHeight = Math.min(Math.max(0, drawHeight), Math.max(0, availableHeight));
13047
- return {
13048
- x: innerBounds.x + (innerBounds.width - drawWidth) / 2,
13049
- y: innerBounds.y + (innerBounds.height - drawHeight) / 2,
13050
- width: drawWidth,
13051
- height: drawHeight
13052
- };
13053
- }
13054
12964
  resolveIconHref(opts) {
13055
12965
  const source = opts.source;
13056
12966
  if (source instanceof HTMLImageElement) return source.src;
13057
12967
  if (!source) return "";
13058
- if (this.isSvgMarkup(source)) return this.svgToDataUrl(this.tintSvg(source, opts.strokeColor, opts.fillColor));
12968
+ if (isSvgMarkup(source)) return svgToDataUrl(tintSvg(source, opts.strokeColor, opts.fillColor));
13059
12969
  if (source.toLowerCase().endsWith(".svg")) {
13060
- const svgText = this.readSvgFromUrlSync(source);
13061
- if (svgText) return this.svgToDataUrl(this.tintSvg(svgText, opts.strokeColor, opts.fillColor));
12970
+ const svgText = readSvgFromUrlSync(source);
12971
+ if (svgText) return svgToDataUrl(tintSvg(svgText, opts.strokeColor, opts.fillColor));
13062
12972
  }
13063
12973
  return source;
13064
12974
  }
13065
- readSvgFromUrlSync(url) {
13066
- if (typeof XMLHttpRequest === "undefined") return null;
13067
- try {
13068
- const xhr = new XMLHttpRequest();
13069
- xhr.open("GET", url, false);
13070
- xhr.send();
13071
- if (xhr.status >= 200 && xhr.status < 300) return xhr.responseText || null;
13072
- } catch {}
13073
- return null;
13074
- }
13075
- isSvgMarkup(value) {
13076
- const trimmed = value.trim().toLowerCase();
13077
- return trimmed.startsWith("<svg") || trimmed.includes("<svg");
13078
- }
13079
- styleSetColor(style, key, color) {
13080
- if (new RegExp(`${key}\\s*:`).test(style)) return style.replace(new RegExp(`${key}\\s*:[^;]+`), `${key}:${color}`);
13081
- return `${style}${style.trim().endsWith(";") || style.trim() === "" ? "" : ";"}${key}:${color};`;
13082
- }
13083
- tintSvg(svgText, strokeColor, fillColor) {
13084
- if (!strokeColor && !fillColor) return svgText;
13085
- const root = new DOMParser().parseFromString(svgText, "image/svg+xml").documentElement;
13086
- if (!root || root.nodeName.toLowerCase() === "parsererror") return svgText;
13087
- const all = [root, ...Array.from(root.querySelectorAll("*"))];
13088
- for (const el of all) {
13089
- const stroke = el.getAttribute("stroke");
13090
- if (strokeColor && stroke !== null && stroke.toLowerCase() !== "none") el.setAttribute("stroke", strokeColor);
13091
- const fill = el.getAttribute("fill");
13092
- if (fillColor && fill !== null && fill.toLowerCase() !== "none") el.setAttribute("fill", fillColor);
13093
- const style = el.getAttribute("style");
13094
- if (style) {
13095
- let next = style;
13096
- if (strokeColor && /stroke\s*:\s*(?!none)/.test(style)) next = this.styleSetColor(next, "stroke", strokeColor);
13097
- if (fillColor && /fill\s*:\s*(?!none)/.test(style)) next = this.styleSetColor(next, "fill", fillColor);
13098
- if (next !== style) el.setAttribute("style", next);
13099
- }
13100
- }
13101
- return new XMLSerializer().serializeToString(root);
13102
- }
13103
- svgToDataUrl(svg) {
13104
- return `data:image/svg+xml;utf8,${encodeURIComponent(svg).replace(/%0A/g, "").replace(/%0D/g, "").replace(/%09/g, " ").replace(/%20/g, " ")}`;
13105
- }
13106
12975
  escapeAttribute(value) {
13107
12976
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13108
12977
  }
@@ -13398,11 +13267,10 @@ var GuidesOverlay = class extends BaseOverlay {
13398
13267
  };
13399
13268
  //#endregion
13400
13269
  //#region src/core/overlays/MiniMap.ts
13401
- var MiniMap = class {
13270
+ var MiniMap = class extends BaseOverlay {
13402
13271
  constructor(options = {}) {
13403
- this.removeOverlay = null;
13272
+ super(options.enabled ?? true);
13404
13273
  this.options = {
13405
- enabled: options.enabled ?? true,
13406
13274
  width: options.width ?? 180,
13407
13275
  height: options.height ?? 120,
13408
13276
  padding: options.padding ?? 12,
@@ -13415,7 +13283,7 @@ var MiniMap = class {
13415
13283
  }
13416
13284
  install(renderer) {
13417
13285
  this.removeOverlay = renderer.addTopOverlayRenderer((ctx) => {
13418
- if (!this.options.enabled) return;
13286
+ if (!this.enabled) return;
13419
13287
  const layout = this.computeLayout(renderer);
13420
13288
  if (!layout) return;
13421
13289
  const { x, y, width, height, bounds, scale, mapOffsetX, mapOffsetY, viewRect } = layout;
@@ -13464,13 +13332,6 @@ var MiniMap = class {
13464
13332
  ctx.restore();
13465
13333
  });
13466
13334
  }
13467
- destroy() {
13468
- this.removeOverlay?.();
13469
- this.removeOverlay = null;
13470
- }
13471
- setEnabled(enabled) {
13472
- this.options.enabled = enabled;
13473
- }
13474
13335
  beginOverlayDrag(renderer, screenX, screenY) {
13475
13336
  const hit = this.hitTestMiniMap(renderer, screenX, screenY);
13476
13337
  if (!hit) return null;
@@ -13487,7 +13348,7 @@ var MiniMap = class {
13487
13348
  return this.hitTestMiniMap(renderer, screenX, screenY) !== null;
13488
13349
  }
13489
13350
  updateOverlayDrag(renderer, screenX, screenY, payload) {
13490
- if (!this.options.enabled) return false;
13351
+ if (!this.enabled) return false;
13491
13352
  if (typeof payload !== "object" || payload === null || !("kind" in payload)) return false;
13492
13353
  const dragPayload = payload;
13493
13354
  if (dragPayload.kind === "block") return true;
@@ -13509,7 +13370,7 @@ var MiniMap = class {
13509
13370
  return true;
13510
13371
  }
13511
13372
  hitTestMiniMap(renderer, screenX, screenY) {
13512
- if (!this.options.enabled) return null;
13373
+ if (!this.enabled) return null;
13513
13374
  const layout = this.computeLayout(renderer);
13514
13375
  if (!layout) return null;
13515
13376
  const canvasPoint = renderer.screenToCanvas(screenX, screenY);
@@ -13642,6 +13503,6 @@ function applyEdgeStyle(ctx, style) {
13642
13503
  return baseOpacity * strokeOpacity;
13643
13504
  }
13644
13505
  //#endregion
13645
- 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 };
13506
+ 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, 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 };
13646
13507
 
13647
13508
  //# sourceMappingURL=papirus.js.map