@utisha/graph-editor 1.0.3 → 1.0.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.
|
@@ -187,6 +187,11 @@ class GraphEditorComponent {
|
|
|
187
187
|
isBoxSelecting = false;
|
|
188
188
|
boxSelectStart = { x: 0, y: 0 };
|
|
189
189
|
selectionBox = signal(null);
|
|
190
|
+
// Resize state (hand tool)
|
|
191
|
+
resizingNode = null;
|
|
192
|
+
resizeStartSize = { width: 0, height: 0 };
|
|
193
|
+
resizeStartMousePos = { x: 0, y: 0 };
|
|
194
|
+
resizeMinSize = { width: 0, height: 0 };
|
|
190
195
|
// Computed
|
|
191
196
|
transform = computed(() => `translate(${this.panX()}, ${this.panY()}) scale(${this.scale()})`);
|
|
192
197
|
gridBounds = computed(() => {
|
|
@@ -647,6 +652,20 @@ class GraphEditorComponent {
|
|
|
647
652
|
const max = zoomConfig?.max ?? 2.0;
|
|
648
653
|
this.scale.set(Math.max(min, Math.min(max, level)));
|
|
649
654
|
}
|
|
655
|
+
zoomIn() {
|
|
656
|
+
const zoomConfig = this.config.canvas?.zoom;
|
|
657
|
+
const step = zoomConfig?.step ?? 0.1;
|
|
658
|
+
const max = zoomConfig?.max ?? 2.0;
|
|
659
|
+
const newScale = Math.min(max, this.scale() + step);
|
|
660
|
+
this.scale.set(newScale);
|
|
661
|
+
}
|
|
662
|
+
zoomOut() {
|
|
663
|
+
const zoomConfig = this.config.canvas?.zoom;
|
|
664
|
+
const step = zoomConfig?.step ?? 0.1;
|
|
665
|
+
const min = zoomConfig?.min ?? 0.25;
|
|
666
|
+
const newScale = Math.max(min, this.scale() - step);
|
|
667
|
+
this.scale.set(newScale);
|
|
668
|
+
}
|
|
650
669
|
getSelection() {
|
|
651
670
|
return this.selection();
|
|
652
671
|
}
|
|
@@ -699,6 +718,40 @@ class GraphEditorComponent {
|
|
|
699
718
|
const height = Math.abs(mouseY - this.boxSelectStart.y);
|
|
700
719
|
this.selectionBox.set({ x, y, width, height });
|
|
701
720
|
}
|
|
721
|
+
else if (this.resizingNode) {
|
|
722
|
+
// Node resize - calculate new size based on mouse delta
|
|
723
|
+
const dx = (event.clientX - this.resizeStartMousePos.x) / this.scale();
|
|
724
|
+
const dy = (event.clientY - this.resizeStartMousePos.y) / this.scale();
|
|
725
|
+
// Calculate new size, enforcing minimum
|
|
726
|
+
const newWidth = Math.max(this.resizeMinSize.width, this.resizeStartSize.width + dx);
|
|
727
|
+
const newHeight = Math.max(this.resizeMinSize.height, this.resizeStartSize.height + dy);
|
|
728
|
+
// Update node size
|
|
729
|
+
const graph = this.internalGraph();
|
|
730
|
+
const nodeIndex = graph.nodes.findIndex(n => n.id === this.resizingNode.id);
|
|
731
|
+
if (nodeIndex !== -1) {
|
|
732
|
+
const updatedNodes = [...graph.nodes];
|
|
733
|
+
updatedNodes[nodeIndex] = {
|
|
734
|
+
...updatedNodes[nodeIndex],
|
|
735
|
+
size: { width: newWidth, height: newHeight }
|
|
736
|
+
};
|
|
737
|
+
// Recalculate edge ports for edges connected to this node
|
|
738
|
+
const updatedEdges = graph.edges.map(edge => {
|
|
739
|
+
if (edge.source !== this.resizingNode.id && edge.target !== this.resizingNode.id)
|
|
740
|
+
return edge;
|
|
741
|
+
const sourceNode = updatedNodes.find(n => n.id === edge.source);
|
|
742
|
+
const targetNode = updatedNodes.find(n => n.id === edge.target);
|
|
743
|
+
if (!sourceNode || !targetNode)
|
|
744
|
+
return edge;
|
|
745
|
+
const newSourcePort = this.findClosestPortForEdge(sourceNode, targetNode, 'source');
|
|
746
|
+
const newTargetPort = this.findClosestPortForEdge(targetNode, sourceNode, 'target');
|
|
747
|
+
if (edge.sourcePort === newSourcePort && edge.targetPort === newTargetPort)
|
|
748
|
+
return edge;
|
|
749
|
+
return { ...edge, sourcePort: newSourcePort, targetPort: newTargetPort };
|
|
750
|
+
});
|
|
751
|
+
this.internalGraph.set({ ...graph, nodes: updatedNodes, edges: updatedEdges });
|
|
752
|
+
this.emitGraphChange();
|
|
753
|
+
}
|
|
754
|
+
}
|
|
702
755
|
else if (this.isPanning) {
|
|
703
756
|
const dx = event.clientX - this.lastMousePos.x;
|
|
704
757
|
const dy = event.clientY - this.lastMousePos.y;
|
|
@@ -908,6 +961,7 @@ class GraphEditorComponent {
|
|
|
908
961
|
this.hoveredNodeId = null;
|
|
909
962
|
this.hoveredPort = null;
|
|
910
963
|
this.showAttachmentPoints.set(null);
|
|
964
|
+
this.resizingNode = null;
|
|
911
965
|
}
|
|
912
966
|
onNodeMouseDown(event, node) {
|
|
913
967
|
if (this.readonly)
|
|
@@ -1056,6 +1110,17 @@ class GraphEditorComponent {
|
|
|
1056
1110
|
event.stopPropagation();
|
|
1057
1111
|
this.draggedEdge = { edge, endpoint };
|
|
1058
1112
|
}
|
|
1113
|
+
onResizeHandleMouseDown(event, node) {
|
|
1114
|
+
if (this.readonly)
|
|
1115
|
+
return;
|
|
1116
|
+
event.stopPropagation();
|
|
1117
|
+
this.resizingNode = node;
|
|
1118
|
+
const currentSize = this.getNodeSize(node);
|
|
1119
|
+
this.resizeStartSize = { ...currentSize };
|
|
1120
|
+
this.resizeStartMousePos = { x: event.clientX, y: event.clientY };
|
|
1121
|
+
// Use current size as minimum (from config or existing node.size)
|
|
1122
|
+
this.resizeMinSize = { ...currentSize };
|
|
1123
|
+
}
|
|
1059
1124
|
onWheel(event) {
|
|
1060
1125
|
const zoomConfig = this.config.canvas?.zoom;
|
|
1061
1126
|
if (!zoomConfig?.wheelEnabled)
|
|
@@ -1176,6 +1241,9 @@ class GraphEditorComponent {
|
|
|
1176
1241
|
}
|
|
1177
1242
|
}
|
|
1178
1243
|
getNodeSize(node) {
|
|
1244
|
+
// Check instance-level size override first (from resize)
|
|
1245
|
+
if (node.size)
|
|
1246
|
+
return node.size;
|
|
1179
1247
|
const nodeConfig = this.config.nodes.types.find(t => t.type === node.type);
|
|
1180
1248
|
return nodeConfig?.size || this.config.nodes.defaultSize || { width: 220, height: 100 };
|
|
1181
1249
|
}
|
|
@@ -1310,6 +1378,29 @@ class GraphEditorComponent {
|
|
|
1310
1378
|
.map(p => p.trim())
|
|
1311
1379
|
.filter(p => p.length > 0);
|
|
1312
1380
|
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Split node types into columns for the palette.
|
|
1383
|
+
* When there are too many node types to fit vertically, creates additional columns.
|
|
1384
|
+
*/
|
|
1385
|
+
getPaletteColumns() {
|
|
1386
|
+
const types = this.config.nodes.types;
|
|
1387
|
+
if (!types || types.length === 0)
|
|
1388
|
+
return [];
|
|
1389
|
+
// Calculate available height for palette
|
|
1390
|
+
// Top toolbar: 60px (12px top + 36px height + 12px gap)
|
|
1391
|
+
// Bottom padding: 12px
|
|
1392
|
+
// Each item: 40px (36px height + 4px gap)
|
|
1393
|
+
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800;
|
|
1394
|
+
const availableHeight = viewportHeight - 72 - 12 - 12; // toolbar + gaps + bottom padding
|
|
1395
|
+
const itemHeight = 40;
|
|
1396
|
+
const maxItemsPerColumn = Math.max(1, Math.floor(availableHeight / itemHeight));
|
|
1397
|
+
// Split into columns
|
|
1398
|
+
const columns = [];
|
|
1399
|
+
for (let i = 0; i < types.length; i += maxItemsPerColumn) {
|
|
1400
|
+
columns.push(types.slice(i, i + maxItemsPerColumn));
|
|
1401
|
+
}
|
|
1402
|
+
return columns;
|
|
1403
|
+
}
|
|
1313
1404
|
/**
|
|
1314
1405
|
* Get the position for the node image (top-left corner of image).
|
|
1315
1406
|
* Uses same positioning logic as icon but accounts for image dimensions.
|
|
@@ -1374,6 +1465,155 @@ class GraphEditorComponent {
|
|
|
1374
1465
|
};
|
|
1375
1466
|
return labelPositions[pos] || labelPositions['top-left'];
|
|
1376
1467
|
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Get the bounding box for label text within a node.
|
|
1470
|
+
* This box avoids the icon area and has proper padding.
|
|
1471
|
+
*/
|
|
1472
|
+
getLabelBounds(node) {
|
|
1473
|
+
const size = this.getNodeSize(node);
|
|
1474
|
+
const iconPos = this.config.nodes.iconPosition || 'top-left';
|
|
1475
|
+
const padding = 12; // Padding from node edges
|
|
1476
|
+
const iconAreaSize = Math.max(this.getImageSize(node), size.height * 0.35) + 8; // Icon + gap
|
|
1477
|
+
// Default: full node minus padding
|
|
1478
|
+
let x = padding;
|
|
1479
|
+
let y = padding;
|
|
1480
|
+
let width = size.width - padding * 2;
|
|
1481
|
+
let height = size.height - padding * 2;
|
|
1482
|
+
// Adjust based on icon position
|
|
1483
|
+
switch (iconPos) {
|
|
1484
|
+
case 'top-left':
|
|
1485
|
+
case 'left':
|
|
1486
|
+
case 'bottom-left':
|
|
1487
|
+
// Icon on left - text area starts after icon
|
|
1488
|
+
x = iconAreaSize + padding / 2;
|
|
1489
|
+
width = size.width - iconAreaSize - padding - padding / 2;
|
|
1490
|
+
break;
|
|
1491
|
+
case 'top-right':
|
|
1492
|
+
case 'right':
|
|
1493
|
+
case 'bottom-right':
|
|
1494
|
+
// Icon on right - text area ends before icon
|
|
1495
|
+
width = size.width - iconAreaSize - padding - padding / 2;
|
|
1496
|
+
break;
|
|
1497
|
+
case 'top':
|
|
1498
|
+
// Icon on top - text area below icon
|
|
1499
|
+
y = iconAreaSize + padding / 2;
|
|
1500
|
+
height = size.height - iconAreaSize - padding - padding / 2;
|
|
1501
|
+
break;
|
|
1502
|
+
case 'bottom':
|
|
1503
|
+
// Icon on bottom - text area above icon
|
|
1504
|
+
height = size.height - iconAreaSize - padding - padding / 2;
|
|
1505
|
+
break;
|
|
1506
|
+
}
|
|
1507
|
+
return { x, y, width: Math.max(width, 20), height: Math.max(height, 20) };
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Get wrapped text lines and font size for a node label.
|
|
1511
|
+
* Uses text wrapping first, then font downsizing if needed.
|
|
1512
|
+
*/
|
|
1513
|
+
getWrappedLabel(node) {
|
|
1514
|
+
const text = (node.data['name'] || node.type);
|
|
1515
|
+
const bounds = this.getLabelBounds(node);
|
|
1516
|
+
const baseFontSize = 14;
|
|
1517
|
+
const minFontSize = 9;
|
|
1518
|
+
const lineHeightRatio = 1.3;
|
|
1519
|
+
// Try wrapping at current font size, then reduce if needed
|
|
1520
|
+
for (let fontSize = baseFontSize; fontSize >= minFontSize; fontSize -= 1) {
|
|
1521
|
+
const charWidth = fontSize * 0.6; // Approximate character width
|
|
1522
|
+
const lineHeight = fontSize * lineHeightRatio;
|
|
1523
|
+
const maxCharsPerLine = Math.floor(bounds.width / charWidth);
|
|
1524
|
+
const maxLines = Math.floor(bounds.height / lineHeight);
|
|
1525
|
+
if (maxCharsPerLine < 3 || maxLines < 1)
|
|
1526
|
+
continue;
|
|
1527
|
+
const lines = this.wrapText(text, maxCharsPerLine);
|
|
1528
|
+
// Check if text fits
|
|
1529
|
+
if (lines.length <= maxLines) {
|
|
1530
|
+
return { lines, fontSize, lineHeight };
|
|
1531
|
+
}
|
|
1532
|
+
// If at minimum font size, truncate
|
|
1533
|
+
if (fontSize === minFontSize) {
|
|
1534
|
+
const truncatedLines = lines.slice(0, maxLines);
|
|
1535
|
+
if (lines.length > maxLines && truncatedLines.length > 0) {
|
|
1536
|
+
// Add ellipsis to last line
|
|
1537
|
+
const lastLine = truncatedLines[truncatedLines.length - 1];
|
|
1538
|
+
if (lastLine.length > 3) {
|
|
1539
|
+
truncatedLines[truncatedLines.length - 1] = lastLine.slice(0, -3) + '...';
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
return { lines: truncatedLines, fontSize, lineHeight };
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
// Fallback
|
|
1546
|
+
return { lines: [text], fontSize: minFontSize, lineHeight: minFontSize * lineHeightRatio };
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Wrap text into lines respecting max characters per line.
|
|
1550
|
+
* Tries to break at word boundaries.
|
|
1551
|
+
*/
|
|
1552
|
+
wrapText(text, maxCharsPerLine) {
|
|
1553
|
+
if (text.length <= maxCharsPerLine) {
|
|
1554
|
+
return [text];
|
|
1555
|
+
}
|
|
1556
|
+
const words = text.split(/\s+/);
|
|
1557
|
+
const lines = [];
|
|
1558
|
+
let currentLine = '';
|
|
1559
|
+
for (const word of words) {
|
|
1560
|
+
if (currentLine.length === 0) {
|
|
1561
|
+
// First word on line
|
|
1562
|
+
if (word.length > maxCharsPerLine) {
|
|
1563
|
+
// Word too long, break it
|
|
1564
|
+
let remaining = word;
|
|
1565
|
+
while (remaining.length > maxCharsPerLine) {
|
|
1566
|
+
lines.push(remaining.slice(0, maxCharsPerLine - 1) + '-');
|
|
1567
|
+
remaining = remaining.slice(maxCharsPerLine - 1);
|
|
1568
|
+
}
|
|
1569
|
+
currentLine = remaining;
|
|
1570
|
+
}
|
|
1571
|
+
else {
|
|
1572
|
+
currentLine = word;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
else if (currentLine.length + 1 + word.length <= maxCharsPerLine) {
|
|
1576
|
+
// Word fits on current line
|
|
1577
|
+
currentLine += ' ' + word;
|
|
1578
|
+
}
|
|
1579
|
+
else {
|
|
1580
|
+
// Start new line
|
|
1581
|
+
lines.push(currentLine);
|
|
1582
|
+
if (word.length > maxCharsPerLine) {
|
|
1583
|
+
// Word too long, break it
|
|
1584
|
+
let remaining = word;
|
|
1585
|
+
while (remaining.length > maxCharsPerLine) {
|
|
1586
|
+
lines.push(remaining.slice(0, maxCharsPerLine - 1) + '-');
|
|
1587
|
+
remaining = remaining.slice(maxCharsPerLine - 1);
|
|
1588
|
+
}
|
|
1589
|
+
currentLine = remaining;
|
|
1590
|
+
}
|
|
1591
|
+
else {
|
|
1592
|
+
currentLine = word;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
if (currentLine.length > 0) {
|
|
1597
|
+
lines.push(currentLine);
|
|
1598
|
+
}
|
|
1599
|
+
return lines;
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Get the Y position for each line of wrapped text (centered vertically).
|
|
1603
|
+
*/
|
|
1604
|
+
getLabelLineY(node, lineIndex, totalLines, lineHeight) {
|
|
1605
|
+
const bounds = this.getLabelBounds(node);
|
|
1606
|
+
const totalTextHeight = totalLines * lineHeight;
|
|
1607
|
+
const startY = bounds.y + (bounds.height - totalTextHeight) / 2 + lineHeight / 2;
|
|
1608
|
+
return startY + lineIndex * lineHeight;
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Get the X position for label text (centered in bounds).
|
|
1612
|
+
*/
|
|
1613
|
+
getLabelLineX(node) {
|
|
1614
|
+
const bounds = this.getLabelBounds(node);
|
|
1615
|
+
return bounds.x + bounds.width / 2;
|
|
1616
|
+
}
|
|
1377
1617
|
findNodeAtPosition(pos) {
|
|
1378
1618
|
for (const node of this.internalGraph().nodes) {
|
|
1379
1619
|
const size = this.getNodeSize(node);
|
|
@@ -1483,7 +1723,7 @@ class GraphEditorComponent {
|
|
|
1483
1723
|
}
|
|
1484
1724
|
}
|
|
1485
1725
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1486
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.19", type: GraphEditorComponent, isStandalone: true, selector: "graph-editor", inputs: { config: "config", graph: "graph", readonly: "readonly", visualizationMode: "visualizationMode", overlayData: "overlayData" }, outputs: { graphChange: "graphChange", nodeAdded: "nodeAdded", nodeUpdated: "nodeUpdated", nodeRemoved: "nodeRemoved", edgeAdded: "edgeAdded", edgeUpdated: "edgeUpdated", edgeRemoved: "edgeRemoved", selectionChange: "selectionChange", validationChange: "validationChange", nodeClick: "nodeClick", nodeDoubleClick: "nodeDoubleClick", edgeClick: "edgeClick", edgeDoubleClick: "edgeDoubleClick", canvasClick: "canvasClick", contextMenu: "contextMenu" }, host: { attributes: { "tabindex": "0" }, listeners: { "keydown": "onKeyDown($event)" }, styleAttribute: "outline: none;" }, providers: [GraphHistoryService], viewQueries: [{ propertyName: "canvasSvgRef", first: true, predicate: ["canvasSvg"], descendants: true, isSignal: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"graph-editor-container\">\n <!-- Canvas with overlaid palette -->\n <div class=\"graph-canvas-wrapper\">\n <!-- Top-left horizontal palette overlay -->\n @if (config.palette?.enabled !== false) {\n <div class=\"graph-palette-overlay\">\n <!-- Tools -->\n <button\n class=\"palette-item tool-item\"\n [class.active]=\"activeTool() === 'hand'\"\n title=\"Hand tool (move nodes)\"\n (click)=\"switchTool('hand')\"\n >\n <span class=\"icon\">\u270B</span>\n </button>\n <button\n class=\"palette-item tool-item\"\n [class.active]=\"activeTool() === 'line'\"\n title=\"Line tool (draw connections)\"\n (click)=\"switchTool('line')\"\n >\n <span class=\"icon\">\u2215</span>\n </button>\n\n <!-- Divider -->\n <div class=\"palette-divider\"></div>\n\n <!-- Node types -->\n @for (nodeType of config.nodes.types; track nodeType.type) {\n <button\n class=\"palette-item\"\n [attr.data-node-type]=\"nodeType.type\"\n [attr.title]=\"nodeType.label || nodeType.type\"\n (click)=\"addNode(nodeType.type)\"\n >\n @if (nodeType.iconSvg) {\n <svg\n class=\"palette-icon-svg\"\n [attr.viewBox]=\"nodeType.iconSvg.viewBox || '0 0 24 24'\"\n [attr.fill]=\"nodeType.iconSvg.fill || 'none'\"\n [attr.stroke]=\"nodeType.iconSvg.stroke || '#1D6A96'\"\n [attr.stroke-width]=\"nodeType.iconSvg.strokeWidth || 2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n @for (pathData of splitIconPaths(nodeType.iconSvg.path); track $index) {\n <path [attr.d]=\"pathData\"/>\n }\n </svg>\n } @else if (nodeType.defaultData['imageUrl']) {\n <img\n class=\"palette-icon-img\"\n [src]=\"nodeType.defaultData['imageUrl']\"\n [alt]=\"nodeType.label || nodeType.type\"\n />\n } @else {\n <span class=\"icon\">{{ nodeType.icon || '\u25CF' }}</span>\n }\n </button>\n }\n </div>\n }\n\n <svg\n #canvasSvg\n [class.tool-line]=\"activeTool() === 'line'\"\n [attr.width]=\"'100%'\"\n [attr.height]=\"'100%'\"\n (mousedown)=\"onCanvasMouseDown($event)\"\n (mousemove)=\"onCanvasMouseMove($event)\"\n (mouseup)=\"onCanvasMouseUp($event)\"\n (wheel)=\"onWheel($event)\"\n (contextmenu)=\"onContextMenu($event)\"\n >\n <!-- Arrow marker definitions -->\n <defs>\n <marker id=\"arrow-end\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" fill=\"#94a3b8\"/>\n </marker>\n <marker id=\"arrow-end-selected\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" fill=\"#3b82f6\"/>\n </marker>\n <marker id=\"arrow-start\" viewBox=\"0 0 10 10\" refX=\"1\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" fill=\"#94a3b8\"/>\n </marker>\n <marker id=\"arrow-start-selected\" viewBox=\"0 0 10 10\" refX=\"1\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" fill=\"#3b82f6\"/>\n </marker>\n </defs>\n\n <!-- Main transform group (pan + zoom) -->\n <g [attr.transform]=\"transform()\">\n <!-- Grid (if enabled) -->\n <!-- Grid (if enabled) - extended to cover viewport during pan -->\n @if (config.canvas?.grid?.enabled) {\n <defs>\n <pattern\n id=\"grid\"\n [attr.width]=\"config.canvas!.grid!.size\"\n [attr.height]=\"config.canvas!.grid!.size\"\n patternUnits=\"userSpaceOnUse\"\n >\n <path\n [attr.d]=\"'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size\"\n fill=\"none\"\n [attr.stroke]=\"config.canvas!.grid!.color || '#e0e0e0'\"\n stroke-width=\"1\"\n />\n </pattern>\n </defs>\n <!-- Extended grid background covering viewport + pan offset -->\n <rect\n [attr.x]=\"gridBounds().x\"\n [attr.y]=\"gridBounds().y\"\n [attr.width]=\"gridBounds().width\"\n [attr.height]=\"gridBounds().height\"\n fill=\"url(#grid)\"\n />\n }\n\n <!-- Layer 0.5: Preview line for line tool (rubber-band) -->\n @if (previewLine()) {\n <line\n class=\"preview-line\"\n [attr.x1]=\"previewLine()!.source.x\"\n [attr.y1]=\"previewLine()!.source.y\"\n [attr.x2]=\"previewLine()!.target.x\"\n [attr.y2]=\"previewLine()!.target.y\"\n stroke=\"#3b82f6\"\n stroke-width=\"2.5\"\n stroke-dasharray=\"8,6\"\n stroke-linecap=\"round\"\n opacity=\"0.7\"\n />\n }\n\n <!-- Layer 1: Edge paths (behind everything) -->\n @for (edge of internalGraph().edges; track edge.id) {\n <!-- Edge shadow for depth (optional) -->\n @if (shadowsEnabled()) {\n <path\n class=\"edge-shadow\"\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"rgba(0,0,0,0.06)\"\n stroke-width=\"6\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [attr.transform]=\"'translate(1, 2)'\"\n />\n }\n <!-- Invisible wide hit-area for easier clicking (hand tool only) -->\n <path\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"transparent\"\n [attr.stroke-width]=\"20\"\n fill=\"none\"\n class=\"edge-hit-area\"\n [attr.pointer-events]=\"activeTool() === 'hand' ? 'stroke' : 'none'\"\n (click)=\"onEdgeClick($event, edge)\"\n (dblclick)=\"onEdgeDoubleClick($event, edge)\"\n />\n <!-- Visible edge line -->\n <path\n class=\"edge-line\"\n [attr.d]=\"getEdgePath(edge)\"\n [attr.stroke]=\"selection().edges.includes(edge.id) ? '#3b82f6' : '#94a3b8'\"\n [attr.stroke-width]=\"selection().edges.includes(edge.id) ? 2.5 : 2\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [class.selected]=\"selection().edges.includes(edge.id)\"\n [attr.marker-end]=\"getEdgeMarkerEnd(edge)\"\n [attr.marker-start]=\"getEdgeMarkerStart(edge)\"\n pointer-events=\"none\"\n />\n }\n\n <!-- Layer 2: Nodes -->\n @for (node of internalGraph().nodes; track node.id) {\n <g\n [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\"\n class=\"graph-node\"\n [class.selected]=\"selection().nodes.includes(node.id)\"\n [attr.data-node-id]=\"node.id\"\n (mousedown)=\"onNodeMouseDown($event, node)\"\n (click)=\"onNodeClick($event, node)\"\n (dblclick)=\"nodeDoubleClick.emit(node)\"\n >\n <!-- Node shadow (optional) -->\n @if (shadowsEnabled()) {\n <rect\n class=\"node-shadow\"\n [attr.width]=\"getNodeSize(node).width\"\n [attr.height]=\"getNodeSize(node).height\"\n fill=\"rgba(0,0,0,0.08)\"\n rx=\"12\"\n transform=\"translate(2, 3)\"\n style=\"filter: blur(4px);\"\n />\n }\n <!-- Node background -->\n <rect\n class=\"node-bg\"\n [attr.width]=\"getNodeSize(node).width\"\n [attr.height]=\"getNodeSize(node).height\"\n fill=\"white\"\n [attr.stroke]=\"selection().nodes.includes(node.id) ? '#3b82f6' : '#e2e8f0'\"\n [attr.stroke-width]=\"selection().nodes.includes(node.id) ? 2.5 : 1.5\"\n rx=\"12\"\n />\n\n <!-- Node type icon (text/emoji) or custom image -->\n @if (getNodeImage(node)) {\n <image\n class=\"node-image\"\n [attr.href]=\"getNodeImage(node)\"\n [attr.xlink:href]=\"getNodeImage(node)\"\n [attr.x]=\"getImagePosition(node).x\"\n [attr.y]=\"getImagePosition(node).y\"\n [attr.width]=\"getImageSize(node)\"\n [attr.height]=\"getImageSize(node)\"\n preserveAspectRatio=\"xMidYMid meet\"\n />\n } @else {\n <text\n class=\"node-icon\"\n [attr.x]=\"getIconPosition(node).x\"\n [attr.y]=\"getIconPosition(node).y\"\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n [attr.font-size]=\"getNodeSize(node).height * 0.28\"\n >\n {{ getNodeTypeIcon(node) }}\n </text>\n }\n\n <!-- Node label -->\n <text\n class=\"node-label\"\n [attr.x]=\"getLabelPosition(node).x\"\n [attr.y]=\"getLabelPosition(node).y\"\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n font-size=\"14\"\n font-weight=\"500\"\n fill=\"#1e293b\"\n >\n {{ node.data['name'] || node.type }}\n </text>\n </g>\n }\n\n <!-- Layer 3: Attachment points (on top of nodes) -->\n @for (node of internalGraph().nodes; track node.id) {\n @if (showAttachmentPoints() === node.id) {\n <g [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\">\n @for (port of getNodePorts(node); track port.position) {\n <circle\n [attr.cx]=\"port.x\"\n [attr.cy]=\"port.y\"\n [attr.r]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? 8 : 6\"\n [attr.fill]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? '#2563eb' : '#94a3b8'\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"attachment-point\"\n [class.hovered]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position\"\n (mousedown)=\"$event.stopPropagation()\"\n (click)=\"onAttachmentPointClick($event, node, port.position)\"\n />\n }\n </g>\n }\n }\n\n <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->\n @for (edge of internalGraph().edges; track edge.id) {\n @if (selection().edges.includes(edge.id)) {\n <g>\n <!-- Source endpoint -->\n <circle\n [attr.cx]=\"getEdgeSourcePoint(edge).x\"\n [attr.cy]=\"getEdgeSourcePoint(edge).y\"\n r=\"6\"\n fill=\"#3b82f6\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'source')\"\n />\n\n <!-- Target endpoint -->\n <circle\n [attr.cx]=\"getEdgeTargetPoint(edge).x\"\n [attr.cy]=\"getEdgeTargetPoint(edge).y\"\n r=\"6\"\n fill=\"#3b82f6\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'target')\"\n />\n </g>\n }\n }\n\n <!-- Layer 5: Selection box (Shift+drag) -->\n @if (selectionBox()) {\n <rect\n class=\"selection-box\"\n [attr.x]=\"selectionBox()!.x\"\n [attr.y]=\"selectionBox()!.y\"\n [attr.width]=\"selectionBox()!.width\"\n [attr.height]=\"selectionBox()!.height\"\n fill=\"rgba(59, 130, 246, 0.1)\"\n stroke=\"#3b82f6\"\n stroke-width=\"1\"\n stroke-dasharray=\"4,2\"\n />\n }\n </g>\n </svg>\n </div>\n\n <!-- Edge direction selector overlay -->\n @if (selectedEdgeMidpoint()) {\n <div\n class=\"edge-direction-selector\"\n [style.left.px]=\"selectedEdgeMidpoint()!.x\"\n [style.top.px]=\"selectedEdgeMidpoint()!.y\"\n >\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'backward'\"\n title=\"Backward\"\n (click)=\"setEdgeDirection('backward')\"\n >\u2190</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'bidirectional'\"\n title=\"Bidirectional\"\n (click)=\"setEdgeDirection('bidirectional')\"\n >\u2194</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'\"\n title=\"Forward\"\n (click)=\"setEdgeDirection('forward')\"\n >\u2192</button>\n </div>\n }\n</div>\n", styles: [".graph-editor-container{display:flex;width:100%;height:100%;position:relative;background:var(--graph-editor-canvas-bg, #f8f9fa)}.graph-palette-overlay{position:absolute;top:16px;left:16px;display:flex;gap:4px;z-index:10;background:#fffffff2;padding:6px;border-radius:var(--radius-md, 8px);box-shadow:0 2px 8px #0000001a;backdrop-filter:blur(4px)}.palette-item{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;padding:0;border:1.5px solid var(--neutral-200, #e5e7eb);border-radius:var(--radius-md, 8px);background:var(--white, #fff);color:var(--neutral-600, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:20px}.palette-icon-img{width:24px;height:24px;object-fit:contain}.palette-icon-svg{width:22px;height:22px;flex-shrink:0}.palette-item:focus-visible{outline:2px solid var(--indigo-400, #818cf8);outline-offset:2px}.palette-item:hover{background:var(--neutral-50, #f9fafb);border-color:var(--interactive, #3b82f6);color:var(--interactive, #3b82f6);transform:translateY(-1px);box-shadow:0 2px 6px #00000014}.palette-item:active{transform:translateY(0);box-shadow:none}.palette-item.tool-item.active{background:var(--interactive, #3b82f6);border-color:var(--interactive, #3b82f6);color:#fff}.palette-item.tool-item.active:hover{background:var(--interactive-hover, #2563eb);border-color:var(--interactive-hover, #2563eb);color:#fff}.palette-divider{width:1px;background:var(--neutral-200, #e5e7eb);align-self:stretch;margin:4px 2px}.graph-canvas-wrapper{flex:1;position:relative;overflow:hidden}.graph-canvas{width:100%;height:100%;cursor:grab}.graph-canvas:active{cursor:grabbing}.graph-canvas.tool-line,.graph-canvas.tool-line .graph-node{cursor:crosshair}.graph-node{cursor:move;user-select:none;-webkit-user-select:none;transition:transform .1s ease-out}.graph-node:hover .node-bg{stroke:#cbd5e1}.graph-node text{pointer-events:none}.graph-node .node-label{font-family:system-ui,-apple-system,sans-serif}.graph-node.selected .node-bg{stroke:#3b82f6;filter:drop-shadow(0 4px 12px rgba(59,130,246,.25))}.edge-line{transition:stroke .15s,stroke-width .15s}.edge-line.selected{filter:drop-shadow(0 2px 4px rgba(59,130,246,.3))}.edge-hit-area{cursor:pointer}.edge-endpoint{cursor:pointer;transition:r .2s,fill .2s}.edge-endpoint:hover{r:8;fill:#2563eb}.edge-endpoint.selected{fill:#2563eb}.attachment-point{cursor:crosshair;transition:all .2s}.attachment-point.hovered{filter:drop-shadow(0 0 4px rgba(37,99,235,.6))}.validation-panel{position:absolute;bottom:0;left:0;right:0;max-height:200px;overflow-y:auto;background:#fff;border-top:1px solid #e5e7eb;padding:16px}.error-item{padding:8px 12px;margin-bottom:8px;background:#fee2e2;border-left:3px solid #ef4444;border-radius:4px;font-size:14px}.error-item.warning{background:#fef3c7;border-left-color:#f59e0b}.edge-direction-selector{position:absolute;transform:translate(-50%,-100%);margin-top:-12px;display:flex;gap:2px;background:#fffffff2;padding:4px;border-radius:6px;box-shadow:0 2px 8px #00000026;backdrop-filter:blur(4px);z-index:20;pointer-events:auto}.direction-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid #e5e7eb;border-radius:4px;background:#fff;cursor:pointer;font-size:16px;transition:all .15s;color:#6b7280}.direction-btn:hover{background:#f3f4f6;border-color:#3b82f6;color:#3b82f6}.direction-btn.active{background:#3b82f6;border-color:#3b82f6;color:#fff}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1726
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.19", type: GraphEditorComponent, isStandalone: true, selector: "graph-editor", inputs: { config: "config", graph: "graph", readonly: "readonly", visualizationMode: "visualizationMode", overlayData: "overlayData" }, outputs: { graphChange: "graphChange", nodeAdded: "nodeAdded", nodeUpdated: "nodeUpdated", nodeRemoved: "nodeRemoved", edgeAdded: "edgeAdded", edgeUpdated: "edgeUpdated", edgeRemoved: "edgeRemoved", selectionChange: "selectionChange", validationChange: "validationChange", nodeClick: "nodeClick", nodeDoubleClick: "nodeDoubleClick", edgeClick: "edgeClick", edgeDoubleClick: "edgeDoubleClick", canvasClick: "canvasClick", contextMenu: "contextMenu" }, host: { attributes: { "tabindex": "0" }, listeners: { "keydown": "onKeyDown($event)" }, styleAttribute: "outline: none;" }, providers: [GraphHistoryService], viewQueries: [{ propertyName: "canvasSvgRef", first: true, predicate: ["canvasSvg"], descendants: true, isSignal: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"graph-editor-container\">\n <!-- Canvas with overlaid palette -->\n <div class=\"graph-canvas-wrapper\">\n <!-- Top horizontal toolbar -->\n @if (config.palette?.enabled !== false) {\n <div class=\"graph-toolbar-top\">\n <!-- Tools -->\n <button\n class=\"toolbar-btn\"\n [class.active]=\"activeTool() === 'hand'\"\n title=\"Hand tool (move nodes)\"\n (click)=\"switchTool('hand')\"\n >\n <span class=\"icon\">\u270B</span>\n </button>\n <button\n class=\"toolbar-btn\"\n [class.active]=\"activeTool() === 'line'\"\n title=\"Line tool (draw connections)\"\n (click)=\"switchTool('line')\"\n >\n <span class=\"icon\">\u2215</span>\n </button>\n\n <div class=\"toolbar-divider\"></div>\n\n <!-- Zoom -->\n <button\n class=\"toolbar-btn\"\n title=\"Zoom in\"\n (click)=\"zoomIn()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"/>\n <path d=\"M21 21l-4.35-4.35M11 8v6M8 11h6\"/>\n </svg>\n </button>\n <button\n class=\"toolbar-btn\"\n title=\"Zoom out\"\n (click)=\"zoomOut()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"/>\n <path d=\"M21 21l-4.35-4.35M8 11h6\"/>\n </svg>\n </button>\n\n <div class=\"toolbar-divider\"></div>\n\n <!-- Actions -->\n <button\n class=\"toolbar-btn\"\n title=\"Auto layout\"\n (click)=\"applyLayout()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"/>\n <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"/>\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"/>\n <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"/>\n </svg>\n </button>\n <button\n class=\"toolbar-btn\"\n title=\"Fit to screen\"\n (click)=\"fitToScreen()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n </svg>\n </button>\n </div>\n\n <!-- Left vertical palette (node types) - supports multiple columns -->\n <div class=\"graph-palette-container\">\n @for (column of getPaletteColumns(); track $index) {\n <div class=\"graph-palette-column\">\n @for (nodeType of column; track nodeType.type) {\n <button\n class=\"palette-item\"\n [attr.data-node-type]=\"nodeType.type\"\n [attr.title]=\"nodeType.label || nodeType.type\"\n (click)=\"addNode(nodeType.type)\"\n >\n @if (nodeType.iconSvg) {\n <svg\n class=\"palette-icon-svg\"\n [attr.viewBox]=\"nodeType.iconSvg.viewBox || '0 0 24 24'\"\n [attr.fill]=\"nodeType.iconSvg.fill || 'none'\"\n [attr.stroke]=\"nodeType.iconSvg.stroke || '#1D6A96'\"\n [attr.stroke-width]=\"nodeType.iconSvg.strokeWidth || 2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n @for (pathData of splitIconPaths(nodeType.iconSvg.path); track $index) {\n <path [attr.d]=\"pathData\"/>\n }\n </svg>\n } @else if (nodeType.defaultData['imageUrl']) {\n <img\n class=\"palette-icon-img\"\n [src]=\"nodeType.defaultData['imageUrl']\"\n [alt]=\"nodeType.label || nodeType.type\"\n />\n } @else {\n <span class=\"icon\">{{ nodeType.icon || '\u25CF' }}</span>\n }\n </button>\n }\n </div>\n }\n </div>\n }\n\n <svg\n #canvasSvg\n [class.tool-line]=\"activeTool() === 'line'\"\n [attr.width]=\"'100%'\"\n [attr.height]=\"'100%'\"\n (mousedown)=\"onCanvasMouseDown($event)\"\n (mousemove)=\"onCanvasMouseMove($event)\"\n (mouseup)=\"onCanvasMouseUp($event)\"\n (wheel)=\"onWheel($event)\"\n (contextmenu)=\"onContextMenu($event)\"\n >\n <!-- Arrow marker definitions -->\n <defs>\n <marker id=\"arrow-end\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" fill=\"#94a3b8\"/>\n </marker>\n <marker id=\"arrow-end-selected\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" fill=\"#3b82f6\"/>\n </marker>\n <marker id=\"arrow-start\" viewBox=\"0 0 10 10\" refX=\"1\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" fill=\"#94a3b8\"/>\n </marker>\n <marker id=\"arrow-start-selected\" viewBox=\"0 0 10 10\" refX=\"1\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" fill=\"#3b82f6\"/>\n </marker>\n </defs>\n\n <!-- Main transform group (pan + zoom) -->\n <g [attr.transform]=\"transform()\">\n <!-- Grid (if enabled) -->\n <!-- Grid (if enabled) - extended to cover viewport during pan -->\n @if (config.canvas?.grid?.enabled) {\n <defs>\n <pattern\n id=\"grid\"\n [attr.width]=\"config.canvas!.grid!.size\"\n [attr.height]=\"config.canvas!.grid!.size\"\n patternUnits=\"userSpaceOnUse\"\n >\n <path\n [attr.d]=\"'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size\"\n fill=\"none\"\n [attr.stroke]=\"config.canvas!.grid!.color || '#e0e0e0'\"\n stroke-width=\"1\"\n />\n </pattern>\n </defs>\n <!-- Extended grid background covering viewport + pan offset -->\n <rect\n [attr.x]=\"gridBounds().x\"\n [attr.y]=\"gridBounds().y\"\n [attr.width]=\"gridBounds().width\"\n [attr.height]=\"gridBounds().height\"\n fill=\"url(#grid)\"\n />\n }\n\n <!-- Layer 0.5: Preview line for line tool (rubber-band) -->\n @if (previewLine()) {\n <line\n class=\"preview-line\"\n [attr.x1]=\"previewLine()!.source.x\"\n [attr.y1]=\"previewLine()!.source.y\"\n [attr.x2]=\"previewLine()!.target.x\"\n [attr.y2]=\"previewLine()!.target.y\"\n stroke=\"#3b82f6\"\n stroke-width=\"2.5\"\n stroke-dasharray=\"8,6\"\n stroke-linecap=\"round\"\n opacity=\"0.7\"\n />\n }\n\n <!-- Layer 1: Edge paths (behind everything) -->\n @for (edge of internalGraph().edges; track edge.id) {\n <!-- Edge shadow for depth (optional) -->\n @if (shadowsEnabled()) {\n <path\n class=\"edge-shadow\"\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"rgba(0,0,0,0.06)\"\n stroke-width=\"6\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [attr.transform]=\"'translate(1, 2)'\"\n />\n }\n <!-- Invisible wide hit-area for easier clicking (hand tool only) -->\n <path\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"transparent\"\n [attr.stroke-width]=\"20\"\n fill=\"none\"\n class=\"edge-hit-area\"\n [attr.pointer-events]=\"activeTool() === 'hand' ? 'stroke' : 'none'\"\n (click)=\"onEdgeClick($event, edge)\"\n (dblclick)=\"onEdgeDoubleClick($event, edge)\"\n />\n <!-- Visible edge line -->\n <path\n class=\"edge-line\"\n [attr.d]=\"getEdgePath(edge)\"\n [attr.stroke]=\"selection().edges.includes(edge.id) ? '#3b82f6' : '#94a3b8'\"\n [attr.stroke-width]=\"selection().edges.includes(edge.id) ? 2.5 : 2\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [class.selected]=\"selection().edges.includes(edge.id)\"\n [attr.marker-end]=\"getEdgeMarkerEnd(edge)\"\n [attr.marker-start]=\"getEdgeMarkerStart(edge)\"\n pointer-events=\"none\"\n />\n }\n\n <!-- Layer 2: Nodes -->\n @for (node of internalGraph().nodes; track node.id) {\n <g\n [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\"\n class=\"graph-node\"\n [class.selected]=\"selection().nodes.includes(node.id)\"\n [attr.data-node-id]=\"node.id\"\n (mousedown)=\"onNodeMouseDown($event, node)\"\n (click)=\"onNodeClick($event, node)\"\n (dblclick)=\"nodeDoubleClick.emit(node)\"\n >\n <!-- Node shadow (optional) -->\n @if (shadowsEnabled()) {\n <rect\n class=\"node-shadow\"\n [attr.width]=\"getNodeSize(node).width\"\n [attr.height]=\"getNodeSize(node).height\"\n fill=\"rgba(0,0,0,0.08)\"\n rx=\"12\"\n transform=\"translate(2, 3)\"\n style=\"filter: blur(4px);\"\n />\n }\n <!-- Node background -->\n <rect\n class=\"node-bg\"\n [attr.width]=\"getNodeSize(node).width\"\n [attr.height]=\"getNodeSize(node).height\"\n fill=\"white\"\n [attr.stroke]=\"selection().nodes.includes(node.id) ? '#3b82f6' : '#e2e8f0'\"\n [attr.stroke-width]=\"selection().nodes.includes(node.id) ? 2.5 : 1.5\"\n rx=\"12\"\n />\n\n <!-- Node type icon (text/emoji) or custom image -->\n @if (getNodeImage(node)) {\n <image\n class=\"node-image\"\n [attr.href]=\"getNodeImage(node)\"\n [attr.xlink:href]=\"getNodeImage(node)\"\n [attr.x]=\"getImagePosition(node).x\"\n [attr.y]=\"getImagePosition(node).y\"\n [attr.width]=\"getImageSize(node)\"\n [attr.height]=\"getImageSize(node)\"\n preserveAspectRatio=\"xMidYMid meet\"\n />\n } @else {\n <text\n class=\"node-icon\"\n [attr.x]=\"getIconPosition(node).x\"\n [attr.y]=\"getIconPosition(node).y\"\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n [attr.font-size]=\"getNodeSize(node).height * 0.28\"\n >\n {{ getNodeTypeIcon(node) }}\n </text>\n }\n\n <!-- Node label (wrapped and auto-sized) -->\n <text\n class=\"node-label\"\n [attr.x]=\"getLabelLineX(node)\"\n text-anchor=\"middle\"\n [attr.font-size]=\"getWrappedLabel(node).fontSize\"\n font-weight=\"500\"\n fill=\"#1e293b\"\n >\n @for (line of getWrappedLabel(node).lines; track $index) {\n <tspan\n [attr.x]=\"getLabelLineX(node)\"\n [attr.y]=\"getLabelLineY(node, $index, getWrappedLabel(node).lines.length, getWrappedLabel(node).lineHeight)\"\n >{{ line }}</tspan>\n }\n </text>\n\n <!-- Resize handle (SE corner, visible when selected + hand tool) -->\n @if (selection().nodes.includes(node.id) && activeTool() === 'hand' && selection().nodes.length === 1) {\n <rect\n class=\"resize-handle resize-handle-se\"\n [attr.x]=\"getNodeSize(node).width - 8\"\n [attr.y]=\"getNodeSize(node).height - 8\"\n width=\"10\"\n height=\"10\"\n fill=\"#3b82f6\"\n rx=\"2\"\n cursor=\"se-resize\"\n (mousedown)=\"onResizeHandleMouseDown($event, node)\"\n />\n }\n </g>\n }\n\n <!-- Layer 3: Attachment points (on top of nodes) -->\n @for (node of internalGraph().nodes; track node.id) {\n @if (showAttachmentPoints() === node.id) {\n <g [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\">\n @for (port of getNodePorts(node); track port.position) {\n <circle\n [attr.cx]=\"port.x\"\n [attr.cy]=\"port.y\"\n [attr.r]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? 8 : 6\"\n [attr.fill]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? '#2563eb' : '#94a3b8'\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"attachment-point\"\n [class.hovered]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position\"\n (mousedown)=\"$event.stopPropagation()\"\n (click)=\"onAttachmentPointClick($event, node, port.position)\"\n />\n }\n </g>\n }\n }\n\n <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->\n @for (edge of internalGraph().edges; track edge.id) {\n @if (selection().edges.includes(edge.id)) {\n <g>\n <!-- Source endpoint -->\n <circle\n [attr.cx]=\"getEdgeSourcePoint(edge).x\"\n [attr.cy]=\"getEdgeSourcePoint(edge).y\"\n r=\"6\"\n fill=\"#3b82f6\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'source')\"\n />\n\n <!-- Target endpoint -->\n <circle\n [attr.cx]=\"getEdgeTargetPoint(edge).x\"\n [attr.cy]=\"getEdgeTargetPoint(edge).y\"\n r=\"6\"\n fill=\"#3b82f6\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'target')\"\n />\n </g>\n }\n }\n\n <!-- Layer 5: Selection box (Shift+drag) -->\n @if (selectionBox()) {\n <rect\n class=\"selection-box\"\n [attr.x]=\"selectionBox()!.x\"\n [attr.y]=\"selectionBox()!.y\"\n [attr.width]=\"selectionBox()!.width\"\n [attr.height]=\"selectionBox()!.height\"\n fill=\"rgba(59, 130, 246, 0.1)\"\n stroke=\"#3b82f6\"\n stroke-width=\"1\"\n stroke-dasharray=\"4,2\"\n />\n }\n </g>\n </svg>\n </div>\n\n <!-- Edge direction selector overlay -->\n @if (selectedEdgeMidpoint()) {\n <div\n class=\"edge-direction-selector\"\n [style.left.px]=\"selectedEdgeMidpoint()!.x\"\n [style.top.px]=\"selectedEdgeMidpoint()!.y\"\n >\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'backward'\"\n title=\"Backward\"\n (click)=\"setEdgeDirection('backward')\"\n >\u2190</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'bidirectional'\"\n title=\"Bidirectional\"\n (click)=\"setEdgeDirection('bidirectional')\"\n >\u2194</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'\"\n title=\"Forward\"\n (click)=\"setEdgeDirection('forward')\"\n >\u2192</button>\n </div>\n }\n</div>\n", styles: [".graph-editor-container{display:flex;width:100%;height:100%;position:relative;background:var(--graph-editor-canvas-bg, #f8f9fa)}.graph-toolbar-top{position:absolute;top:12px;left:12px;display:flex;gap:4px;z-index:10;background:#fffffff2;padding:6px;border-radius:var(--radius-md, 8px);box-shadow:0 2px 8px #0000001a;backdrop-filter:blur(4px)}.toolbar-btn{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1.5px solid var(--neutral-200, #e5e7eb);border-radius:var(--radius-md, 8px);background:var(--white, #fff);color:var(--neutral-600, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:18px}.toolbar-btn:focus-visible{outline:2px solid var(--indigo-400, #818cf8);outline-offset:2px}.toolbar-btn:hover{background:var(--neutral-50, #f9fafb);border-color:var(--interactive, #3b82f6);color:var(--interactive, #3b82f6)}.toolbar-btn:active{transform:translateY(0);box-shadow:none}.toolbar-btn.active{background:var(--interactive, #3b82f6);border-color:var(--interactive, #3b82f6);color:#fff}.toolbar-btn.active:hover{background:var(--interactive-hover, #2563eb);border-color:var(--interactive-hover, #2563eb);color:#fff}.toolbar-divider{width:1px;background:var(--neutral-200, #e5e7eb);align-self:stretch;margin:4px 2px}.graph-palette-container{position:absolute;top:72px;left:12px;display:flex;flex-direction:row;gap:8px;z-index:10}.graph-palette-column{display:flex;flex-direction:column;gap:4px;background:#fffffff2;padding:6px;border-radius:var(--radius-md, 8px);box-shadow:0 2px 8px #0000001a;backdrop-filter:blur(4px)}.palette-item{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1.5px solid var(--neutral-200, #e5e7eb);border-radius:var(--radius-md, 8px);background:var(--white, #fff);color:var(--neutral-600, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:18px}.graph-canvas-wrapper{flex:1;position:relative;overflow:hidden}.graph-canvas{width:100%;height:100%;cursor:grab}.graph-canvas:active{cursor:grabbing}.graph-canvas.tool-line,.graph-canvas.tool-line .graph-node{cursor:crosshair}.graph-node{cursor:move;user-select:none;-webkit-user-select:none;transition:transform .1s ease-out}.graph-node:hover .node-bg{stroke:#cbd5e1}.graph-node text{pointer-events:none}.graph-node .node-label{font-family:system-ui,-apple-system,sans-serif}.graph-node.selected .node-bg{stroke:#3b82f6;filter:drop-shadow(0 4px 12px rgba(59,130,246,.25))}.edge-line{transition:stroke .15s,stroke-width .15s}.edge-line.selected{filter:drop-shadow(0 2px 4px rgba(59,130,246,.3))}.edge-hit-area{cursor:pointer}.edge-endpoint{cursor:pointer;transition:r .2s,fill .2s}.edge-endpoint:hover{r:8;fill:#2563eb}.edge-endpoint.selected{fill:#2563eb}.attachment-point{cursor:crosshair;transition:all .2s}.attachment-point.hovered{filter:drop-shadow(0 0 4px rgba(37,99,235,.6))}.validation-panel{position:absolute;bottom:0;left:0;right:0;max-height:200px;overflow-y:auto;background:#fff;border-top:1px solid #e5e7eb;padding:16px}.error-item{padding:8px 12px;margin-bottom:8px;background:#fee2e2;border-left:3px solid #ef4444;border-radius:4px;font-size:14px}.error-item.warning{background:#fef3c7;border-left-color:#f59e0b}.edge-direction-selector{position:absolute;transform:translate(-50%,-100%);margin-top:-12px;display:flex;gap:2px;background:#fffffff2;padding:4px;border-radius:6px;box-shadow:0 2px 8px #00000026;backdrop-filter:blur(4px);z-index:20;pointer-events:auto}.direction-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid #e5e7eb;border-radius:4px;background:#fff;cursor:pointer;font-size:16px;transition:all .15s;color:#6b7280}.direction-btn:hover{background:#f3f4f6;border-color:#3b82f6;color:#3b82f6}.direction-btn.active{background:#3b82f6;border-color:#3b82f6;color:#fff}.resize-handle{cursor:se-resize;opacity:.8;transition:opacity .15s,fill .15s}.resize-handle:hover{opacity:1;fill:#2563eb}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1487
1727
|
}
|
|
1488
1728
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphEditorComponent, decorators: [{
|
|
1489
1729
|
type: Component,
|
|
@@ -1491,7 +1731,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImpo
|
|
|
1491
1731
|
'tabindex': '0',
|
|
1492
1732
|
'style': 'outline: none;',
|
|
1493
1733
|
'(keydown)': 'onKeyDown($event)'
|
|
1494
|
-
}, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"graph-editor-container\">\n <!-- Canvas with overlaid palette -->\n <div class=\"graph-canvas-wrapper\">\n <!-- Top-left horizontal palette overlay -->\n @if (config.palette?.enabled !== false) {\n <div class=\"graph-palette-overlay\">\n <!-- Tools -->\n <button\n class=\"palette-item tool-item\"\n [class.active]=\"activeTool() === 'hand'\"\n title=\"Hand tool (move nodes)\"\n (click)=\"switchTool('hand')\"\n >\n <span class=\"icon\">\u270B</span>\n </button>\n <button\n class=\"palette-item tool-item\"\n [class.active]=\"activeTool() === 'line'\"\n title=\"Line tool (draw connections)\"\n (click)=\"switchTool('line')\"\n >\n <span class=\"icon\">\u2215</span>\n </button>\n\n <!-- Divider -->\n <div class=\"palette-divider\"></div>\n\n <!-- Node types -->\n @for (nodeType of config.nodes.types; track nodeType.type) {\n <button\n class=\"palette-item\"\n [attr.data-node-type]=\"nodeType.type\"\n [attr.title]=\"nodeType.label || nodeType.type\"\n (click)=\"addNode(nodeType.type)\"\n >\n @if (nodeType.iconSvg) {\n <svg\n class=\"palette-icon-svg\"\n [attr.viewBox]=\"nodeType.iconSvg.viewBox || '0 0 24 24'\"\n [attr.fill]=\"nodeType.iconSvg.fill || 'none'\"\n [attr.stroke]=\"nodeType.iconSvg.stroke || '#1D6A96'\"\n [attr.stroke-width]=\"nodeType.iconSvg.strokeWidth || 2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n @for (pathData of splitIconPaths(nodeType.iconSvg.path); track $index) {\n <path [attr.d]=\"pathData\"/>\n }\n </svg>\n } @else if (nodeType.defaultData['imageUrl']) {\n <img\n class=\"palette-icon-img\"\n [src]=\"nodeType.defaultData['imageUrl']\"\n [alt]=\"nodeType.label || nodeType.type\"\n />\n } @else {\n <span class=\"icon\">{{ nodeType.icon || '\u25CF' }}</span>\n }\n </button>\n }\n </div>\n }\n\n <svg\n #canvasSvg\n [class.tool-line]=\"activeTool() === 'line'\"\n [attr.width]=\"'100%'\"\n [attr.height]=\"'100%'\"\n (mousedown)=\"onCanvasMouseDown($event)\"\n (mousemove)=\"onCanvasMouseMove($event)\"\n (mouseup)=\"onCanvasMouseUp($event)\"\n (wheel)=\"onWheel($event)\"\n (contextmenu)=\"onContextMenu($event)\"\n >\n <!-- Arrow marker definitions -->\n <defs>\n <marker id=\"arrow-end\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" fill=\"#94a3b8\"/>\n </marker>\n <marker id=\"arrow-end-selected\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" fill=\"#3b82f6\"/>\n </marker>\n <marker id=\"arrow-start\" viewBox=\"0 0 10 10\" refX=\"1\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" fill=\"#94a3b8\"/>\n </marker>\n <marker id=\"arrow-start-selected\" viewBox=\"0 0 10 10\" refX=\"1\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" fill=\"#3b82f6\"/>\n </marker>\n </defs>\n\n <!-- Main transform group (pan + zoom) -->\n <g [attr.transform]=\"transform()\">\n <!-- Grid (if enabled) -->\n <!-- Grid (if enabled) - extended to cover viewport during pan -->\n @if (config.canvas?.grid?.enabled) {\n <defs>\n <pattern\n id=\"grid\"\n [attr.width]=\"config.canvas!.grid!.size\"\n [attr.height]=\"config.canvas!.grid!.size\"\n patternUnits=\"userSpaceOnUse\"\n >\n <path\n [attr.d]=\"'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size\"\n fill=\"none\"\n [attr.stroke]=\"config.canvas!.grid!.color || '#e0e0e0'\"\n stroke-width=\"1\"\n />\n </pattern>\n </defs>\n <!-- Extended grid background covering viewport + pan offset -->\n <rect\n [attr.x]=\"gridBounds().x\"\n [attr.y]=\"gridBounds().y\"\n [attr.width]=\"gridBounds().width\"\n [attr.height]=\"gridBounds().height\"\n fill=\"url(#grid)\"\n />\n }\n\n <!-- Layer 0.5: Preview line for line tool (rubber-band) -->\n @if (previewLine()) {\n <line\n class=\"preview-line\"\n [attr.x1]=\"previewLine()!.source.x\"\n [attr.y1]=\"previewLine()!.source.y\"\n [attr.x2]=\"previewLine()!.target.x\"\n [attr.y2]=\"previewLine()!.target.y\"\n stroke=\"#3b82f6\"\n stroke-width=\"2.5\"\n stroke-dasharray=\"8,6\"\n stroke-linecap=\"round\"\n opacity=\"0.7\"\n />\n }\n\n <!-- Layer 1: Edge paths (behind everything) -->\n @for (edge of internalGraph().edges; track edge.id) {\n <!-- Edge shadow for depth (optional) -->\n @if (shadowsEnabled()) {\n <path\n class=\"edge-shadow\"\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"rgba(0,0,0,0.06)\"\n stroke-width=\"6\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [attr.transform]=\"'translate(1, 2)'\"\n />\n }\n <!-- Invisible wide hit-area for easier clicking (hand tool only) -->\n <path\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"transparent\"\n [attr.stroke-width]=\"20\"\n fill=\"none\"\n class=\"edge-hit-area\"\n [attr.pointer-events]=\"activeTool() === 'hand' ? 'stroke' : 'none'\"\n (click)=\"onEdgeClick($event, edge)\"\n (dblclick)=\"onEdgeDoubleClick($event, edge)\"\n />\n <!-- Visible edge line -->\n <path\n class=\"edge-line\"\n [attr.d]=\"getEdgePath(edge)\"\n [attr.stroke]=\"selection().edges.includes(edge.id) ? '#3b82f6' : '#94a3b8'\"\n [attr.stroke-width]=\"selection().edges.includes(edge.id) ? 2.5 : 2\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [class.selected]=\"selection().edges.includes(edge.id)\"\n [attr.marker-end]=\"getEdgeMarkerEnd(edge)\"\n [attr.marker-start]=\"getEdgeMarkerStart(edge)\"\n pointer-events=\"none\"\n />\n }\n\n <!-- Layer 2: Nodes -->\n @for (node of internalGraph().nodes; track node.id) {\n <g\n [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\"\n class=\"graph-node\"\n [class.selected]=\"selection().nodes.includes(node.id)\"\n [attr.data-node-id]=\"node.id\"\n (mousedown)=\"onNodeMouseDown($event, node)\"\n (click)=\"onNodeClick($event, node)\"\n (dblclick)=\"nodeDoubleClick.emit(node)\"\n >\n <!-- Node shadow (optional) -->\n @if (shadowsEnabled()) {\n <rect\n class=\"node-shadow\"\n [attr.width]=\"getNodeSize(node).width\"\n [attr.height]=\"getNodeSize(node).height\"\n fill=\"rgba(0,0,0,0.08)\"\n rx=\"12\"\n transform=\"translate(2, 3)\"\n style=\"filter: blur(4px);\"\n />\n }\n <!-- Node background -->\n <rect\n class=\"node-bg\"\n [attr.width]=\"getNodeSize(node).width\"\n [attr.height]=\"getNodeSize(node).height\"\n fill=\"white\"\n [attr.stroke]=\"selection().nodes.includes(node.id) ? '#3b82f6' : '#e2e8f0'\"\n [attr.stroke-width]=\"selection().nodes.includes(node.id) ? 2.5 : 1.5\"\n rx=\"12\"\n />\n\n <!-- Node type icon (text/emoji) or custom image -->\n @if (getNodeImage(node)) {\n <image\n class=\"node-image\"\n [attr.href]=\"getNodeImage(node)\"\n [attr.xlink:href]=\"getNodeImage(node)\"\n [attr.x]=\"getImagePosition(node).x\"\n [attr.y]=\"getImagePosition(node).y\"\n [attr.width]=\"getImageSize(node)\"\n [attr.height]=\"getImageSize(node)\"\n preserveAspectRatio=\"xMidYMid meet\"\n />\n } @else {\n <text\n class=\"node-icon\"\n [attr.x]=\"getIconPosition(node).x\"\n [attr.y]=\"getIconPosition(node).y\"\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n [attr.font-size]=\"getNodeSize(node).height * 0.28\"\n >\n {{ getNodeTypeIcon(node) }}\n </text>\n }\n\n <!-- Node label -->\n <text\n class=\"node-label\"\n [attr.x]=\"getLabelPosition(node).x\"\n [attr.y]=\"getLabelPosition(node).y\"\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n font-size=\"14\"\n font-weight=\"500\"\n fill=\"#1e293b\"\n >\n {{ node.data['name'] || node.type }}\n </text>\n </g>\n }\n\n <!-- Layer 3: Attachment points (on top of nodes) -->\n @for (node of internalGraph().nodes; track node.id) {\n @if (showAttachmentPoints() === node.id) {\n <g [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\">\n @for (port of getNodePorts(node); track port.position) {\n <circle\n [attr.cx]=\"port.x\"\n [attr.cy]=\"port.y\"\n [attr.r]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? 8 : 6\"\n [attr.fill]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? '#2563eb' : '#94a3b8'\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"attachment-point\"\n [class.hovered]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position\"\n (mousedown)=\"$event.stopPropagation()\"\n (click)=\"onAttachmentPointClick($event, node, port.position)\"\n />\n }\n </g>\n }\n }\n\n <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->\n @for (edge of internalGraph().edges; track edge.id) {\n @if (selection().edges.includes(edge.id)) {\n <g>\n <!-- Source endpoint -->\n <circle\n [attr.cx]=\"getEdgeSourcePoint(edge).x\"\n [attr.cy]=\"getEdgeSourcePoint(edge).y\"\n r=\"6\"\n fill=\"#3b82f6\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'source')\"\n />\n\n <!-- Target endpoint -->\n <circle\n [attr.cx]=\"getEdgeTargetPoint(edge).x\"\n [attr.cy]=\"getEdgeTargetPoint(edge).y\"\n r=\"6\"\n fill=\"#3b82f6\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'target')\"\n />\n </g>\n }\n }\n\n <!-- Layer 5: Selection box (Shift+drag) -->\n @if (selectionBox()) {\n <rect\n class=\"selection-box\"\n [attr.x]=\"selectionBox()!.x\"\n [attr.y]=\"selectionBox()!.y\"\n [attr.width]=\"selectionBox()!.width\"\n [attr.height]=\"selectionBox()!.height\"\n fill=\"rgba(59, 130, 246, 0.1)\"\n stroke=\"#3b82f6\"\n stroke-width=\"1\"\n stroke-dasharray=\"4,2\"\n />\n }\n </g>\n </svg>\n </div>\n\n <!-- Edge direction selector overlay -->\n @if (selectedEdgeMidpoint()) {\n <div\n class=\"edge-direction-selector\"\n [style.left.px]=\"selectedEdgeMidpoint()!.x\"\n [style.top.px]=\"selectedEdgeMidpoint()!.y\"\n >\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'backward'\"\n title=\"Backward\"\n (click)=\"setEdgeDirection('backward')\"\n >\u2190</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'bidirectional'\"\n title=\"Bidirectional\"\n (click)=\"setEdgeDirection('bidirectional')\"\n >\u2194</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'\"\n title=\"Forward\"\n (click)=\"setEdgeDirection('forward')\"\n >\u2192</button>\n </div>\n }\n</div>\n", styles: [".graph-editor-container{display:flex;width:100%;height:100%;position:relative;background:var(--graph-editor-canvas-bg, #f8f9fa)}.graph-palette-overlay{position:absolute;top:16px;left:16px;display:flex;gap:4px;z-index:10;background:#fffffff2;padding:6px;border-radius:var(--radius-md, 8px);box-shadow:0 2px 8px #0000001a;backdrop-filter:blur(4px)}.palette-item{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;padding:0;border:1.5px solid var(--neutral-200, #e5e7eb);border-radius:var(--radius-md, 8px);background:var(--white, #fff);color:var(--neutral-600, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:20px}.palette-icon-img{width:24px;height:24px;object-fit:contain}.palette-icon-svg{width:22px;height:22px;flex-shrink:0}.palette-item:focus-visible{outline:2px solid var(--indigo-400, #818cf8);outline-offset:2px}.palette-item:hover{background:var(--neutral-50, #f9fafb);border-color:var(--interactive, #3b82f6);color:var(--interactive, #3b82f6);transform:translateY(-1px);box-shadow:0 2px 6px #00000014}.palette-item:active{transform:translateY(0);box-shadow:none}.palette-item.tool-item.active{background:var(--interactive, #3b82f6);border-color:var(--interactive, #3b82f6);color:#fff}.palette-item.tool-item.active:hover{background:var(--interactive-hover, #2563eb);border-color:var(--interactive-hover, #2563eb);color:#fff}.palette-divider{width:1px;background:var(--neutral-200, #e5e7eb);align-self:stretch;margin:4px 2px}.graph-canvas-wrapper{flex:1;position:relative;overflow:hidden}.graph-canvas{width:100%;height:100%;cursor:grab}.graph-canvas:active{cursor:grabbing}.graph-canvas.tool-line,.graph-canvas.tool-line .graph-node{cursor:crosshair}.graph-node{cursor:move;user-select:none;-webkit-user-select:none;transition:transform .1s ease-out}.graph-node:hover .node-bg{stroke:#cbd5e1}.graph-node text{pointer-events:none}.graph-node .node-label{font-family:system-ui,-apple-system,sans-serif}.graph-node.selected .node-bg{stroke:#3b82f6;filter:drop-shadow(0 4px 12px rgba(59,130,246,.25))}.edge-line{transition:stroke .15s,stroke-width .15s}.edge-line.selected{filter:drop-shadow(0 2px 4px rgba(59,130,246,.3))}.edge-hit-area{cursor:pointer}.edge-endpoint{cursor:pointer;transition:r .2s,fill .2s}.edge-endpoint:hover{r:8;fill:#2563eb}.edge-endpoint.selected{fill:#2563eb}.attachment-point{cursor:crosshair;transition:all .2s}.attachment-point.hovered{filter:drop-shadow(0 0 4px rgba(37,99,235,.6))}.validation-panel{position:absolute;bottom:0;left:0;right:0;max-height:200px;overflow-y:auto;background:#fff;border-top:1px solid #e5e7eb;padding:16px}.error-item{padding:8px 12px;margin-bottom:8px;background:#fee2e2;border-left:3px solid #ef4444;border-radius:4px;font-size:14px}.error-item.warning{background:#fef3c7;border-left-color:#f59e0b}.edge-direction-selector{position:absolute;transform:translate(-50%,-100%);margin-top:-12px;display:flex;gap:2px;background:#fffffff2;padding:4px;border-radius:6px;box-shadow:0 2px 8px #00000026;backdrop-filter:blur(4px);z-index:20;pointer-events:auto}.direction-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid #e5e7eb;border-radius:4px;background:#fff;cursor:pointer;font-size:16px;transition:all .15s;color:#6b7280}.direction-btn:hover{background:#f3f4f6;border-color:#3b82f6;color:#3b82f6}.direction-btn.active{background:#3b82f6;border-color:#3b82f6;color:#fff}\n"] }]
|
|
1734
|
+
}, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"graph-editor-container\">\n <!-- Canvas with overlaid palette -->\n <div class=\"graph-canvas-wrapper\">\n <!-- Top horizontal toolbar -->\n @if (config.palette?.enabled !== false) {\n <div class=\"graph-toolbar-top\">\n <!-- Tools -->\n <button\n class=\"toolbar-btn\"\n [class.active]=\"activeTool() === 'hand'\"\n title=\"Hand tool (move nodes)\"\n (click)=\"switchTool('hand')\"\n >\n <span class=\"icon\">\u270B</span>\n </button>\n <button\n class=\"toolbar-btn\"\n [class.active]=\"activeTool() === 'line'\"\n title=\"Line tool (draw connections)\"\n (click)=\"switchTool('line')\"\n >\n <span class=\"icon\">\u2215</span>\n </button>\n\n <div class=\"toolbar-divider\"></div>\n\n <!-- Zoom -->\n <button\n class=\"toolbar-btn\"\n title=\"Zoom in\"\n (click)=\"zoomIn()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"/>\n <path d=\"M21 21l-4.35-4.35M11 8v6M8 11h6\"/>\n </svg>\n </button>\n <button\n class=\"toolbar-btn\"\n title=\"Zoom out\"\n (click)=\"zoomOut()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"/>\n <path d=\"M21 21l-4.35-4.35M8 11h6\"/>\n </svg>\n </button>\n\n <div class=\"toolbar-divider\"></div>\n\n <!-- Actions -->\n <button\n class=\"toolbar-btn\"\n title=\"Auto layout\"\n (click)=\"applyLayout()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"/>\n <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"/>\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"/>\n <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"/>\n </svg>\n </button>\n <button\n class=\"toolbar-btn\"\n title=\"Fit to screen\"\n (click)=\"fitToScreen()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n </svg>\n </button>\n </div>\n\n <!-- Left vertical palette (node types) - supports multiple columns -->\n <div class=\"graph-palette-container\">\n @for (column of getPaletteColumns(); track $index) {\n <div class=\"graph-palette-column\">\n @for (nodeType of column; track nodeType.type) {\n <button\n class=\"palette-item\"\n [attr.data-node-type]=\"nodeType.type\"\n [attr.title]=\"nodeType.label || nodeType.type\"\n (click)=\"addNode(nodeType.type)\"\n >\n @if (nodeType.iconSvg) {\n <svg\n class=\"palette-icon-svg\"\n [attr.viewBox]=\"nodeType.iconSvg.viewBox || '0 0 24 24'\"\n [attr.fill]=\"nodeType.iconSvg.fill || 'none'\"\n [attr.stroke]=\"nodeType.iconSvg.stroke || '#1D6A96'\"\n [attr.stroke-width]=\"nodeType.iconSvg.strokeWidth || 2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n @for (pathData of splitIconPaths(nodeType.iconSvg.path); track $index) {\n <path [attr.d]=\"pathData\"/>\n }\n </svg>\n } @else if (nodeType.defaultData['imageUrl']) {\n <img\n class=\"palette-icon-img\"\n [src]=\"nodeType.defaultData['imageUrl']\"\n [alt]=\"nodeType.label || nodeType.type\"\n />\n } @else {\n <span class=\"icon\">{{ nodeType.icon || '\u25CF' }}</span>\n }\n </button>\n }\n </div>\n }\n </div>\n }\n\n <svg\n #canvasSvg\n [class.tool-line]=\"activeTool() === 'line'\"\n [attr.width]=\"'100%'\"\n [attr.height]=\"'100%'\"\n (mousedown)=\"onCanvasMouseDown($event)\"\n (mousemove)=\"onCanvasMouseMove($event)\"\n (mouseup)=\"onCanvasMouseUp($event)\"\n (wheel)=\"onWheel($event)\"\n (contextmenu)=\"onContextMenu($event)\"\n >\n <!-- Arrow marker definitions -->\n <defs>\n <marker id=\"arrow-end\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" fill=\"#94a3b8\"/>\n </marker>\n <marker id=\"arrow-end-selected\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" fill=\"#3b82f6\"/>\n </marker>\n <marker id=\"arrow-start\" viewBox=\"0 0 10 10\" refX=\"1\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" fill=\"#94a3b8\"/>\n </marker>\n <marker id=\"arrow-start-selected\" viewBox=\"0 0 10 10\" refX=\"1\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" fill=\"#3b82f6\"/>\n </marker>\n </defs>\n\n <!-- Main transform group (pan + zoom) -->\n <g [attr.transform]=\"transform()\">\n <!-- Grid (if enabled) -->\n <!-- Grid (if enabled) - extended to cover viewport during pan -->\n @if (config.canvas?.grid?.enabled) {\n <defs>\n <pattern\n id=\"grid\"\n [attr.width]=\"config.canvas!.grid!.size\"\n [attr.height]=\"config.canvas!.grid!.size\"\n patternUnits=\"userSpaceOnUse\"\n >\n <path\n [attr.d]=\"'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size\"\n fill=\"none\"\n [attr.stroke]=\"config.canvas!.grid!.color || '#e0e0e0'\"\n stroke-width=\"1\"\n />\n </pattern>\n </defs>\n <!-- Extended grid background covering viewport + pan offset -->\n <rect\n [attr.x]=\"gridBounds().x\"\n [attr.y]=\"gridBounds().y\"\n [attr.width]=\"gridBounds().width\"\n [attr.height]=\"gridBounds().height\"\n fill=\"url(#grid)\"\n />\n }\n\n <!-- Layer 0.5: Preview line for line tool (rubber-band) -->\n @if (previewLine()) {\n <line\n class=\"preview-line\"\n [attr.x1]=\"previewLine()!.source.x\"\n [attr.y1]=\"previewLine()!.source.y\"\n [attr.x2]=\"previewLine()!.target.x\"\n [attr.y2]=\"previewLine()!.target.y\"\n stroke=\"#3b82f6\"\n stroke-width=\"2.5\"\n stroke-dasharray=\"8,6\"\n stroke-linecap=\"round\"\n opacity=\"0.7\"\n />\n }\n\n <!-- Layer 1: Edge paths (behind everything) -->\n @for (edge of internalGraph().edges; track edge.id) {\n <!-- Edge shadow for depth (optional) -->\n @if (shadowsEnabled()) {\n <path\n class=\"edge-shadow\"\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"rgba(0,0,0,0.06)\"\n stroke-width=\"6\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [attr.transform]=\"'translate(1, 2)'\"\n />\n }\n <!-- Invisible wide hit-area for easier clicking (hand tool only) -->\n <path\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"transparent\"\n [attr.stroke-width]=\"20\"\n fill=\"none\"\n class=\"edge-hit-area\"\n [attr.pointer-events]=\"activeTool() === 'hand' ? 'stroke' : 'none'\"\n (click)=\"onEdgeClick($event, edge)\"\n (dblclick)=\"onEdgeDoubleClick($event, edge)\"\n />\n <!-- Visible edge line -->\n <path\n class=\"edge-line\"\n [attr.d]=\"getEdgePath(edge)\"\n [attr.stroke]=\"selection().edges.includes(edge.id) ? '#3b82f6' : '#94a3b8'\"\n [attr.stroke-width]=\"selection().edges.includes(edge.id) ? 2.5 : 2\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [class.selected]=\"selection().edges.includes(edge.id)\"\n [attr.marker-end]=\"getEdgeMarkerEnd(edge)\"\n [attr.marker-start]=\"getEdgeMarkerStart(edge)\"\n pointer-events=\"none\"\n />\n }\n\n <!-- Layer 2: Nodes -->\n @for (node of internalGraph().nodes; track node.id) {\n <g\n [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\"\n class=\"graph-node\"\n [class.selected]=\"selection().nodes.includes(node.id)\"\n [attr.data-node-id]=\"node.id\"\n (mousedown)=\"onNodeMouseDown($event, node)\"\n (click)=\"onNodeClick($event, node)\"\n (dblclick)=\"nodeDoubleClick.emit(node)\"\n >\n <!-- Node shadow (optional) -->\n @if (shadowsEnabled()) {\n <rect\n class=\"node-shadow\"\n [attr.width]=\"getNodeSize(node).width\"\n [attr.height]=\"getNodeSize(node).height\"\n fill=\"rgba(0,0,0,0.08)\"\n rx=\"12\"\n transform=\"translate(2, 3)\"\n style=\"filter: blur(4px);\"\n />\n }\n <!-- Node background -->\n <rect\n class=\"node-bg\"\n [attr.width]=\"getNodeSize(node).width\"\n [attr.height]=\"getNodeSize(node).height\"\n fill=\"white\"\n [attr.stroke]=\"selection().nodes.includes(node.id) ? '#3b82f6' : '#e2e8f0'\"\n [attr.stroke-width]=\"selection().nodes.includes(node.id) ? 2.5 : 1.5\"\n rx=\"12\"\n />\n\n <!-- Node type icon (text/emoji) or custom image -->\n @if (getNodeImage(node)) {\n <image\n class=\"node-image\"\n [attr.href]=\"getNodeImage(node)\"\n [attr.xlink:href]=\"getNodeImage(node)\"\n [attr.x]=\"getImagePosition(node).x\"\n [attr.y]=\"getImagePosition(node).y\"\n [attr.width]=\"getImageSize(node)\"\n [attr.height]=\"getImageSize(node)\"\n preserveAspectRatio=\"xMidYMid meet\"\n />\n } @else {\n <text\n class=\"node-icon\"\n [attr.x]=\"getIconPosition(node).x\"\n [attr.y]=\"getIconPosition(node).y\"\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n [attr.font-size]=\"getNodeSize(node).height * 0.28\"\n >\n {{ getNodeTypeIcon(node) }}\n </text>\n }\n\n <!-- Node label (wrapped and auto-sized) -->\n <text\n class=\"node-label\"\n [attr.x]=\"getLabelLineX(node)\"\n text-anchor=\"middle\"\n [attr.font-size]=\"getWrappedLabel(node).fontSize\"\n font-weight=\"500\"\n fill=\"#1e293b\"\n >\n @for (line of getWrappedLabel(node).lines; track $index) {\n <tspan\n [attr.x]=\"getLabelLineX(node)\"\n [attr.y]=\"getLabelLineY(node, $index, getWrappedLabel(node).lines.length, getWrappedLabel(node).lineHeight)\"\n >{{ line }}</tspan>\n }\n </text>\n\n <!-- Resize handle (SE corner, visible when selected + hand tool) -->\n @if (selection().nodes.includes(node.id) && activeTool() === 'hand' && selection().nodes.length === 1) {\n <rect\n class=\"resize-handle resize-handle-se\"\n [attr.x]=\"getNodeSize(node).width - 8\"\n [attr.y]=\"getNodeSize(node).height - 8\"\n width=\"10\"\n height=\"10\"\n fill=\"#3b82f6\"\n rx=\"2\"\n cursor=\"se-resize\"\n (mousedown)=\"onResizeHandleMouseDown($event, node)\"\n />\n }\n </g>\n }\n\n <!-- Layer 3: Attachment points (on top of nodes) -->\n @for (node of internalGraph().nodes; track node.id) {\n @if (showAttachmentPoints() === node.id) {\n <g [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\">\n @for (port of getNodePorts(node); track port.position) {\n <circle\n [attr.cx]=\"port.x\"\n [attr.cy]=\"port.y\"\n [attr.r]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? 8 : 6\"\n [attr.fill]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? '#2563eb' : '#94a3b8'\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"attachment-point\"\n [class.hovered]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position\"\n (mousedown)=\"$event.stopPropagation()\"\n (click)=\"onAttachmentPointClick($event, node, port.position)\"\n />\n }\n </g>\n }\n }\n\n <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->\n @for (edge of internalGraph().edges; track edge.id) {\n @if (selection().edges.includes(edge.id)) {\n <g>\n <!-- Source endpoint -->\n <circle\n [attr.cx]=\"getEdgeSourcePoint(edge).x\"\n [attr.cy]=\"getEdgeSourcePoint(edge).y\"\n r=\"6\"\n fill=\"#3b82f6\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'source')\"\n />\n\n <!-- Target endpoint -->\n <circle\n [attr.cx]=\"getEdgeTargetPoint(edge).x\"\n [attr.cy]=\"getEdgeTargetPoint(edge).y\"\n r=\"6\"\n fill=\"#3b82f6\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'target')\"\n />\n </g>\n }\n }\n\n <!-- Layer 5: Selection box (Shift+drag) -->\n @if (selectionBox()) {\n <rect\n class=\"selection-box\"\n [attr.x]=\"selectionBox()!.x\"\n [attr.y]=\"selectionBox()!.y\"\n [attr.width]=\"selectionBox()!.width\"\n [attr.height]=\"selectionBox()!.height\"\n fill=\"rgba(59, 130, 246, 0.1)\"\n stroke=\"#3b82f6\"\n stroke-width=\"1\"\n stroke-dasharray=\"4,2\"\n />\n }\n </g>\n </svg>\n </div>\n\n <!-- Edge direction selector overlay -->\n @if (selectedEdgeMidpoint()) {\n <div\n class=\"edge-direction-selector\"\n [style.left.px]=\"selectedEdgeMidpoint()!.x\"\n [style.top.px]=\"selectedEdgeMidpoint()!.y\"\n >\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'backward'\"\n title=\"Backward\"\n (click)=\"setEdgeDirection('backward')\"\n >\u2190</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'bidirectional'\"\n title=\"Bidirectional\"\n (click)=\"setEdgeDirection('bidirectional')\"\n >\u2194</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'\"\n title=\"Forward\"\n (click)=\"setEdgeDirection('forward')\"\n >\u2192</button>\n </div>\n }\n</div>\n", styles: [".graph-editor-container{display:flex;width:100%;height:100%;position:relative;background:var(--graph-editor-canvas-bg, #f8f9fa)}.graph-toolbar-top{position:absolute;top:12px;left:12px;display:flex;gap:4px;z-index:10;background:#fffffff2;padding:6px;border-radius:var(--radius-md, 8px);box-shadow:0 2px 8px #0000001a;backdrop-filter:blur(4px)}.toolbar-btn{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1.5px solid var(--neutral-200, #e5e7eb);border-radius:var(--radius-md, 8px);background:var(--white, #fff);color:var(--neutral-600, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:18px}.toolbar-btn:focus-visible{outline:2px solid var(--indigo-400, #818cf8);outline-offset:2px}.toolbar-btn:hover{background:var(--neutral-50, #f9fafb);border-color:var(--interactive, #3b82f6);color:var(--interactive, #3b82f6)}.toolbar-btn:active{transform:translateY(0);box-shadow:none}.toolbar-btn.active{background:var(--interactive, #3b82f6);border-color:var(--interactive, #3b82f6);color:#fff}.toolbar-btn.active:hover{background:var(--interactive-hover, #2563eb);border-color:var(--interactive-hover, #2563eb);color:#fff}.toolbar-divider{width:1px;background:var(--neutral-200, #e5e7eb);align-self:stretch;margin:4px 2px}.graph-palette-container{position:absolute;top:72px;left:12px;display:flex;flex-direction:row;gap:8px;z-index:10}.graph-palette-column{display:flex;flex-direction:column;gap:4px;background:#fffffff2;padding:6px;border-radius:var(--radius-md, 8px);box-shadow:0 2px 8px #0000001a;backdrop-filter:blur(4px)}.palette-item{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1.5px solid var(--neutral-200, #e5e7eb);border-radius:var(--radius-md, 8px);background:var(--white, #fff);color:var(--neutral-600, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:18px}.graph-canvas-wrapper{flex:1;position:relative;overflow:hidden}.graph-canvas{width:100%;height:100%;cursor:grab}.graph-canvas:active{cursor:grabbing}.graph-canvas.tool-line,.graph-canvas.tool-line .graph-node{cursor:crosshair}.graph-node{cursor:move;user-select:none;-webkit-user-select:none;transition:transform .1s ease-out}.graph-node:hover .node-bg{stroke:#cbd5e1}.graph-node text{pointer-events:none}.graph-node .node-label{font-family:system-ui,-apple-system,sans-serif}.graph-node.selected .node-bg{stroke:#3b82f6;filter:drop-shadow(0 4px 12px rgba(59,130,246,.25))}.edge-line{transition:stroke .15s,stroke-width .15s}.edge-line.selected{filter:drop-shadow(0 2px 4px rgba(59,130,246,.3))}.edge-hit-area{cursor:pointer}.edge-endpoint{cursor:pointer;transition:r .2s,fill .2s}.edge-endpoint:hover{r:8;fill:#2563eb}.edge-endpoint.selected{fill:#2563eb}.attachment-point{cursor:crosshair;transition:all .2s}.attachment-point.hovered{filter:drop-shadow(0 0 4px rgba(37,99,235,.6))}.validation-panel{position:absolute;bottom:0;left:0;right:0;max-height:200px;overflow-y:auto;background:#fff;border-top:1px solid #e5e7eb;padding:16px}.error-item{padding:8px 12px;margin-bottom:8px;background:#fee2e2;border-left:3px solid #ef4444;border-radius:4px;font-size:14px}.error-item.warning{background:#fef3c7;border-left-color:#f59e0b}.edge-direction-selector{position:absolute;transform:translate(-50%,-100%);margin-top:-12px;display:flex;gap:2px;background:#fffffff2;padding:4px;border-radius:6px;box-shadow:0 2px 8px #00000026;backdrop-filter:blur(4px);z-index:20;pointer-events:auto}.direction-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid #e5e7eb;border-radius:4px;background:#fff;cursor:pointer;font-size:16px;transition:all .15s;color:#6b7280}.direction-btn:hover{background:#f3f4f6;border-color:#3b82f6;color:#3b82f6}.direction-btn.active{background:#3b82f6;border-color:#3b82f6;color:#fff}.resize-handle{cursor:se-resize;opacity:.8;transition:opacity .15s,fill .15s}.resize-handle:hover{opacity:1;fill:#2563eb}\n"] }]
|
|
1495
1735
|
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
1496
1736
|
type: Input,
|
|
1497
1737
|
args: [{ required: true }]
|