@utisha/graph-editor 1.0.0 → 1.0.1

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.
@@ -1,5 +1,126 @@
1
1
  import * as i0 from '@angular/core';
2
- import { EventEmitter, viewChild, signal, computed, Output, Input, ChangeDetectionStrategy, Component } from '@angular/core';
2
+ import { signal, Injectable, EventEmitter, viewChild, inject, computed, Output, Input, ChangeDetectionStrategy, Component } from '@angular/core';
3
+
4
+ /**
5
+ * Service for managing undo/redo history of graph state.
6
+ *
7
+ * Uses a simple snapshot-based approach where each history entry
8
+ * is a deep copy of the entire graph state.
9
+ */
10
+ class GraphHistoryService {
11
+ history = [];
12
+ historyIndex = -1;
13
+ maxHistorySize = 100;
14
+ /** Signal to track if an undo/redo operation is in progress */
15
+ isUndoRedo = signal(false);
16
+ /**
17
+ * Initialize history with the given graph state.
18
+ * Clears any existing history.
19
+ */
20
+ init(graph) {
21
+ this.history = [structuredClone(graph)];
22
+ this.historyIndex = 0;
23
+ }
24
+ /**
25
+ * Push a new state to the history stack.
26
+ * If we're not at the end of history, truncates future states.
27
+ * Prevents duplicate consecutive states.
28
+ *
29
+ * @returns true if state was pushed, false if it was a duplicate
30
+ */
31
+ push(graph) {
32
+ const currentState = structuredClone(graph);
33
+ // Don't push if state hasn't changed
34
+ if (this.historyIndex >= 0 && this.historyIndex < this.history.length) {
35
+ const lastState = this.history[this.historyIndex];
36
+ if (JSON.stringify(lastState) === JSON.stringify(currentState)) {
37
+ return false;
38
+ }
39
+ }
40
+ // If we're not at the end of history, truncate future states
41
+ if (this.historyIndex < this.history.length - 1) {
42
+ this.history = this.history.slice(0, this.historyIndex + 1);
43
+ }
44
+ // Add new state
45
+ this.history.push(currentState);
46
+ this.historyIndex = this.history.length - 1;
47
+ // Enforce max history size
48
+ if (this.history.length > this.maxHistorySize) {
49
+ this.history.shift();
50
+ this.historyIndex--;
51
+ }
52
+ return true;
53
+ }
54
+ /**
55
+ * Undo the last action.
56
+ * @returns The previous graph state, or null if nothing to undo
57
+ */
58
+ undo() {
59
+ if (this.historyIndex <= 0) {
60
+ return null;
61
+ }
62
+ this.historyIndex--;
63
+ this.isUndoRedo.set(true);
64
+ const state = structuredClone(this.history[this.historyIndex]);
65
+ // Note: caller should set isUndoRedo back to false after applying state
66
+ return state;
67
+ }
68
+ /**
69
+ * Redo the last undone action.
70
+ * @returns The next graph state, or null if nothing to redo
71
+ */
72
+ redo() {
73
+ if (this.historyIndex >= this.history.length - 1) {
74
+ return null;
75
+ }
76
+ this.historyIndex++;
77
+ this.isUndoRedo.set(true);
78
+ const state = structuredClone(this.history[this.historyIndex]);
79
+ // Note: caller should set isUndoRedo back to false after applying state
80
+ return state;
81
+ }
82
+ /**
83
+ * Complete an undo/redo operation.
84
+ * Call this after applying the state returned by undo() or redo().
85
+ */
86
+ completeUndoRedo() {
87
+ this.isUndoRedo.set(false);
88
+ }
89
+ /** Check if undo is available */
90
+ canUndo() {
91
+ return this.historyIndex > 0;
92
+ }
93
+ /** Check if redo is available */
94
+ canRedo() {
95
+ return this.historyIndex < this.history.length - 1;
96
+ }
97
+ /** Clear history and reset to given state */
98
+ clear(graph) {
99
+ this.init(graph);
100
+ }
101
+ /** Get current history size */
102
+ get size() {
103
+ return this.history.length;
104
+ }
105
+ /** Get current position in history (0-indexed) */
106
+ get position() {
107
+ return this.historyIndex;
108
+ }
109
+ /** Set maximum history size */
110
+ setMaxSize(size) {
111
+ this.maxHistorySize = size;
112
+ // Trim history if necessary
113
+ while (this.history.length > this.maxHistorySize) {
114
+ this.history.shift();
115
+ this.historyIndex--;
116
+ }
117
+ }
118
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphHistoryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
119
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphHistoryService });
120
+ }
121
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphHistoryService, decorators: [{
122
+ type: Injectable
123
+ }] });
3
124
 
4
125
  /**
5
126
  * Main graph editor component.
@@ -35,6 +156,7 @@ class GraphEditorComponent {
35
156
  canvasClick = new EventEmitter();
36
157
  contextMenu = new EventEmitter();
37
158
  canvasSvgRef = viewChild('canvasSvg');
159
+ historyService = inject(GraphHistoryService);
38
160
  // Internal state
39
161
  internalGraph = signal({ nodes: [], edges: [] });
40
162
  selection = signal({ nodes: [], edges: [] });
@@ -46,6 +168,8 @@ class GraphEditorComponent {
46
168
  // Dragging state
47
169
  draggedNode = null;
48
170
  dragOffset = { x: 0, y: 0 };
171
+ draggedNodeOffsets = new Map(); // For multi-node drag
172
+ didDrag = false; // Track if actual dragging occurred (to suppress click after drag)
49
173
  isPanning = false;
50
174
  lastMousePos = { x: 0, y: 0 };
51
175
  draggedEdge = null;
@@ -59,6 +183,10 @@ class GraphEditorComponent {
59
183
  pendingEdge = null;
60
184
  // Preview line for line tool (rubber-band from source to cursor)
61
185
  previewLine = signal(null);
186
+ // Box selection state (Shift+drag)
187
+ isBoxSelecting = false;
188
+ boxSelectStart = { x: 0, y: 0 };
189
+ selectionBox = signal(null);
62
190
  // Computed
63
191
  transform = computed(() => `translate(${this.panX()}, ${this.panY()}) scale(${this.scale()})`);
64
192
  gridBounds = computed(() => {
@@ -108,6 +236,8 @@ class GraphEditorComponent {
108
236
  this.internalGraph.set(structuredClone(this.graph));
109
237
  }
110
238
  this.validate();
239
+ // Initialize history with starting state
240
+ this.historyService.init(this.internalGraph());
111
241
  }
112
242
  // Node operations
113
243
  addNode(type, position) {
@@ -190,6 +320,46 @@ class GraphEditorComponent {
190
320
  }
191
321
  this.selectionChange.emit(this.selection());
192
322
  }
323
+ /** Toggle a node in/out of the current selection (for Ctrl+Click) */
324
+ toggleNodeSelection(nodeId) {
325
+ const sel = this.selection();
326
+ const isSelected = sel.nodes.includes(nodeId);
327
+ if (isSelected) {
328
+ // Remove from selection
329
+ this.selection.set({
330
+ nodes: sel.nodes.filter(id => id !== nodeId),
331
+ edges: sel.edges
332
+ });
333
+ }
334
+ else {
335
+ // Add to selection
336
+ this.selection.set({
337
+ nodes: [...sel.nodes, nodeId],
338
+ edges: sel.edges
339
+ });
340
+ }
341
+ this.selectionChange.emit(this.selection());
342
+ }
343
+ /** Toggle an edge in/out of the current selection (for Ctrl+Click) */
344
+ toggleEdgeSelection(edgeId) {
345
+ const sel = this.selection();
346
+ const isSelected = sel.edges.includes(edgeId);
347
+ if (isSelected) {
348
+ // Remove from selection
349
+ this.selection.set({
350
+ nodes: sel.nodes,
351
+ edges: sel.edges.filter(id => id !== edgeId)
352
+ });
353
+ }
354
+ else {
355
+ // Add to selection
356
+ this.selection.set({
357
+ nodes: sel.nodes,
358
+ edges: [...sel.edges, edgeId]
359
+ });
360
+ }
361
+ this.selectionChange.emit(this.selection());
362
+ }
193
363
  onKeyDown(event) {
194
364
  if (this.readonly || this.config.interaction?.readonly)
195
365
  return;
@@ -203,24 +373,58 @@ class GraphEditorComponent {
203
373
  event.preventDefault();
204
374
  return;
205
375
  }
206
- if (event.key === 'Delete' || event.key === 'Backspace') {
207
- const sel = this.selection();
208
- // Delete selected edges
209
- if (sel.edges.length > 0) {
210
- for (const edgeId of sel.edges) {
211
- this.removeEdge(edgeId);
212
- }
376
+ // Undo: Ctrl+Z (or Cmd+Z on Mac)
377
+ if ((event.ctrlKey || event.metaKey) && event.key === 'z' && !event.shiftKey) {
378
+ if (this.undo()) {
213
379
  event.preventDefault();
214
- return;
215
380
  }
216
- // Delete selected nodes (keep attached edges)
217
- if (sel.nodes.length > 0) {
218
- for (const nodeId of sel.nodes) {
219
- this.removeNode(nodeId, false);
220
- }
381
+ return;
382
+ }
383
+ // Redo: Ctrl+Y or Ctrl+Shift+Z (or Cmd+Y / Cmd+Shift+Z on Mac)
384
+ if ((event.ctrlKey || event.metaKey) && (event.key === 'y' || (event.key === 'z' && event.shiftKey))) {
385
+ if (this.redo()) {
221
386
  event.preventDefault();
387
+ }
388
+ return;
389
+ }
390
+ if (event.key === 'Delete' || event.key === 'Backspace') {
391
+ const sel = this.selection();
392
+ if (sel.nodes.length === 0 && sel.edges.length === 0)
222
393
  return;
394
+ // Batch delete: remove all selected items atomically (single history entry)
395
+ const graph = this.internalGraph();
396
+ const nodeIdsToRemove = new Set(sel.nodes);
397
+ const edgeIdsToRemove = new Set(sel.edges);
398
+ // Collect removed items for events
399
+ const removedNodes = graph.nodes.filter(n => nodeIdsToRemove.has(n.id));
400
+ const removedEdges = graph.edges.filter(e => edgeIdsToRemove.has(e.id));
401
+ // Filter out selected nodes
402
+ const remainingNodes = graph.nodes.filter(n => !nodeIdsToRemove.has(n.id));
403
+ // Filter out selected edges AND edges connected to deleted nodes
404
+ const remainingEdges = graph.edges.filter(e => !edgeIdsToRemove.has(e.id) &&
405
+ !nodeIdsToRemove.has(e.source) &&
406
+ !nodeIdsToRemove.has(e.target));
407
+ // Find edges that were removed because they connected to deleted nodes
408
+ const additionalRemovedEdges = graph.edges.filter(e => !edgeIdsToRemove.has(e.id) &&
409
+ (nodeIdsToRemove.has(e.source) || nodeIdsToRemove.has(e.target)));
410
+ // Update graph atomically (single history push)
411
+ this.internalGraph.set({ ...graph, nodes: remainingNodes, edges: remainingEdges });
412
+ this.emitGraphChange();
413
+ // Emit removal events
414
+ for (const edge of removedEdges) {
415
+ this.edgeRemoved.emit(edge);
416
+ }
417
+ for (const edge of additionalRemovedEdges) {
418
+ this.edgeRemoved.emit(edge);
223
419
  }
420
+ for (const node of removedNodes) {
421
+ this.nodeRemoved.emit(node);
422
+ }
423
+ // Clear selection
424
+ this.selection.set({ nodes: [], edges: [] });
425
+ this.selectionChange.emit(this.selection());
426
+ event.preventDefault();
427
+ return;
224
428
  }
225
429
  // Arrow keys: nudge selected node(s) — 1px default, 10px with Shift
226
430
  if (event.key.startsWith('Arrow')) {
@@ -301,7 +505,14 @@ class GraphEditorComponent {
301
505
  if (this.activeTool() !== 'hand')
302
506
  return;
303
507
  event.stopPropagation();
304
- this.selectEdge(edge.id);
508
+ if (event.ctrlKey || event.metaKey) {
509
+ // Ctrl/Cmd+Click: toggle edge in selection
510
+ this.toggleEdgeSelection(edge.id);
511
+ }
512
+ else {
513
+ // Normal click: replace selection with this edge
514
+ this.selectEdge(edge.id);
515
+ }
305
516
  this.edgeClick.emit(edge);
306
517
  }
307
518
  onEdgeDoubleClick(event, edge) {
@@ -369,7 +580,17 @@ class GraphEditorComponent {
369
580
  },
370
581
  };
371
582
  });
372
- this.internalGraph.set({ ...graph, nodes: updatedNodes });
583
+ // Recalculate edge ports based on new node positions
584
+ const updatedEdges = graph.edges.map(edge => {
585
+ const sourceNode = updatedNodes.find(n => n.id === edge.source);
586
+ const targetNode = updatedNodes.find(n => n.id === edge.target);
587
+ if (!sourceNode || !targetNode)
588
+ return edge;
589
+ const newSourcePort = this.findClosestPortForEdge(sourceNode, targetNode, 'source');
590
+ const newTargetPort = this.findClosestPortForEdge(targetNode, sourceNode, 'target');
591
+ return { ...edge, sourcePort: newSourcePort, targetPort: newTargetPort };
592
+ });
593
+ this.internalGraph.set({ ...graph, nodes: updatedNodes, edges: updatedEdges });
373
594
  this.emitGraphChange();
374
595
  setTimeout(() => this.fitToScreen());
375
596
  }
@@ -448,14 +669,37 @@ class GraphEditorComponent {
448
669
  const isHitArea = target.classList.contains('edge-hit-area');
449
670
  const isInteractive = isNode || isEdgeEndpoint || isAttachmentPoint || isHitArea;
450
671
  if (!isInteractive) {
451
- this.isPanning = true;
672
+ const rect = event.currentTarget.getBoundingClientRect();
673
+ const mouseX = (event.clientX - rect.left - this.panX()) / this.scale();
674
+ const mouseY = (event.clientY - rect.top - this.panY()) / this.scale();
675
+ if (event.shiftKey && this.activeTool() === 'hand') {
676
+ // Shift+drag = box selection (only with hand tool)
677
+ this.isBoxSelecting = true;
678
+ this.boxSelectStart = { x: mouseX, y: mouseY };
679
+ this.selectionBox.set({ x: mouseX, y: mouseY, width: 0, height: 0 });
680
+ }
681
+ else {
682
+ // Normal drag = pan
683
+ this.isPanning = true;
684
+ }
452
685
  this.lastMousePos = { x: event.clientX, y: event.clientY };
453
686
  this.clearSelection();
454
687
  event.preventDefault();
455
688
  }
456
689
  }
457
690
  onCanvasMouseMove(event) {
458
- if (this.isPanning) {
691
+ if (this.isBoxSelecting) {
692
+ // Update selection box
693
+ const rect = event.currentTarget.getBoundingClientRect();
694
+ const mouseX = (event.clientX - rect.left - this.panX()) / this.scale();
695
+ const mouseY = (event.clientY - rect.top - this.panY()) / this.scale();
696
+ const x = Math.min(this.boxSelectStart.x, mouseX);
697
+ const y = Math.min(this.boxSelectStart.y, mouseY);
698
+ const width = Math.abs(mouseX - this.boxSelectStart.x);
699
+ const height = Math.abs(mouseY - this.boxSelectStart.y);
700
+ this.selectionBox.set({ x, y, width, height });
701
+ }
702
+ else if (this.isPanning) {
459
703
  const dx = event.clientX - this.lastMousePos.x;
460
704
  const dy = event.clientY - this.lastMousePos.y;
461
705
  this.panX.set(this.panX() + dx);
@@ -463,46 +707,74 @@ class GraphEditorComponent {
463
707
  this.lastMousePos = { x: event.clientX, y: event.clientY };
464
708
  }
465
709
  else if (this.draggedNode) {
710
+ this.didDrag = true; // Mark that dragging occurred
466
711
  const rect = event.currentTarget.getBoundingClientRect();
467
712
  const mouseX = (event.clientX - rect.left - this.panX()) / this.scale();
468
713
  const mouseY = (event.clientY - rect.top - this.panY()) / this.scale();
469
- let x = mouseX - this.dragOffset.x;
470
- let y = mouseY - this.dragOffset.y;
471
- // Smart snap to grid
472
- if (this.config.canvas?.grid?.snap) {
473
- const gridSize = this.config.canvas.grid.size || 20;
474
- const snapThreshold = gridSize / 4;
475
- const snapX = Math.round(x / gridSize) * gridSize;
476
- const snapY = Math.round(y / gridSize) * gridSize;
477
- if (Math.abs(x - snapX) < snapThreshold)
478
- x = snapX;
479
- if (Math.abs(y - snapY) < snapThreshold)
480
- y = snapY;
481
- }
482
- // Atomic update: node position + edge port recalculation in one graph set
483
714
  const graph = this.internalGraph();
484
- const nodeIndex = graph.nodes.findIndex(n => n.id === this.draggedNode.id);
485
- if (nodeIndex !== -1) {
486
- const updatedNodes = [...graph.nodes];
487
- updatedNodes[nodeIndex] = { ...updatedNodes[nodeIndex], position: { x, y } };
488
- // Recalculate ports for all edges connected to this node
489
- const draggedId = this.draggedNode.id;
490
- const updatedEdges = graph.edges.map(edge => {
491
- if (edge.source !== draggedId && edge.target !== draggedId)
492
- return edge;
493
- const sourceNode = updatedNodes.find(n => n.id === edge.source);
494
- const targetNode = updatedNodes.find(n => n.id === edge.target);
495
- if (!sourceNode || !targetNode)
496
- return edge;
497
- const newSourcePort = this.findClosestPortForEdge(sourceNode, targetNode, 'source');
498
- const newTargetPort = this.findClosestPortForEdge(targetNode, sourceNode, 'target');
499
- if (edge.sourcePort === newSourcePort && edge.targetPort === newTargetPort)
500
- return edge;
501
- return { ...edge, sourcePort: newSourcePort, targetPort: newTargetPort };
502
- });
503
- this.internalGraph.set({ ...graph, nodes: updatedNodes, edges: updatedEdges });
504
- this.emitGraphChange();
715
+ const updatedNodes = [...graph.nodes];
716
+ const movedNodeIds = new Set();
717
+ // Check if we're dragging multiple selected nodes
718
+ if (this.draggedNodeOffsets.size > 1) {
719
+ // Multi-node drag: move all selected nodes
720
+ for (const [nodeId, offset] of this.draggedNodeOffsets) {
721
+ const nodeIndex = updatedNodes.findIndex(n => n.id === nodeId);
722
+ if (nodeIndex === -1)
723
+ continue;
724
+ let x = mouseX - offset.x;
725
+ let y = mouseY - offset.y;
726
+ // Smart snap to grid
727
+ if (this.config.canvas?.grid?.snap) {
728
+ const gridSize = this.config.canvas.grid.size || 20;
729
+ const snapThreshold = gridSize / 4;
730
+ const snapX = Math.round(x / gridSize) * gridSize;
731
+ const snapY = Math.round(y / gridSize) * gridSize;
732
+ if (Math.abs(x - snapX) < snapThreshold)
733
+ x = snapX;
734
+ if (Math.abs(y - snapY) < snapThreshold)
735
+ y = snapY;
736
+ }
737
+ updatedNodes[nodeIndex] = { ...updatedNodes[nodeIndex], position: { x, y } };
738
+ movedNodeIds.add(nodeId);
739
+ }
740
+ }
741
+ else {
742
+ // Single node drag
743
+ let x = mouseX - this.dragOffset.x;
744
+ let y = mouseY - this.dragOffset.y;
745
+ // Smart snap to grid
746
+ if (this.config.canvas?.grid?.snap) {
747
+ const gridSize = this.config.canvas.grid.size || 20;
748
+ const snapThreshold = gridSize / 4;
749
+ const snapX = Math.round(x / gridSize) * gridSize;
750
+ const snapY = Math.round(y / gridSize) * gridSize;
751
+ if (Math.abs(x - snapX) < snapThreshold)
752
+ x = snapX;
753
+ if (Math.abs(y - snapY) < snapThreshold)
754
+ y = snapY;
755
+ }
756
+ const nodeIndex = updatedNodes.findIndex(n => n.id === this.draggedNode.id);
757
+ if (nodeIndex !== -1) {
758
+ updatedNodes[nodeIndex] = { ...updatedNodes[nodeIndex], position: { x, y } };
759
+ movedNodeIds.add(this.draggedNode.id);
760
+ }
505
761
  }
762
+ // Recalculate ports for all edges connected to moved nodes
763
+ const updatedEdges = graph.edges.map(edge => {
764
+ if (!movedNodeIds.has(edge.source) && !movedNodeIds.has(edge.target))
765
+ return edge;
766
+ const sourceNode = updatedNodes.find(n => n.id === edge.source);
767
+ const targetNode = updatedNodes.find(n => n.id === edge.target);
768
+ if (!sourceNode || !targetNode)
769
+ return edge;
770
+ const newSourcePort = this.findClosestPortForEdge(sourceNode, targetNode, 'source');
771
+ const newTargetPort = this.findClosestPortForEdge(targetNode, sourceNode, 'target');
772
+ if (edge.sourcePort === newSourcePort && edge.targetPort === newTargetPort)
773
+ return edge;
774
+ return { ...edge, sourcePort: newSourcePort, targetPort: newTargetPort };
775
+ });
776
+ this.internalGraph.set({ ...graph, nodes: updatedNodes, edges: updatedEdges });
777
+ this.emitGraphChange();
506
778
  }
507
779
  else if (this.draggedEdge) {
508
780
  // Edge reconnection - find hovered node and closest port
@@ -572,6 +844,41 @@ class GraphEditorComponent {
572
844
  }
573
845
  }
574
846
  onCanvasMouseUp(_event) {
847
+ // Handle box selection completion
848
+ if (this.isBoxSelecting) {
849
+ const box = this.selectionBox();
850
+ if (box && (box.width > 5 || box.height > 5)) {
851
+ // Find all nodes within the selection box
852
+ const selectedNodes = [];
853
+ for (const node of this.internalGraph().nodes) {
854
+ const size = this.getNodeSize(node);
855
+ const nodeRight = node.position.x + size.width;
856
+ const nodeBottom = node.position.y + size.height;
857
+ const boxRight = box.x + box.width;
858
+ const boxBottom = box.y + box.height;
859
+ // Check if node intersects with selection box
860
+ if (node.position.x < boxRight &&
861
+ nodeRight > box.x &&
862
+ node.position.y < boxBottom &&
863
+ nodeBottom > box.y) {
864
+ selectedNodes.push(node.id);
865
+ }
866
+ }
867
+ if (selectedNodes.length > 0) {
868
+ // Also select edges where both source and target are selected
869
+ const selectedEdges = [];
870
+ for (const edge of this.internalGraph().edges) {
871
+ if (selectedNodes.includes(edge.source) && selectedNodes.includes(edge.target)) {
872
+ selectedEdges.push(edge.id);
873
+ }
874
+ }
875
+ this.selection.set({ nodes: selectedNodes, edges: selectedEdges });
876
+ this.selectionChange.emit(this.selection());
877
+ }
878
+ }
879
+ this.isBoxSelecting = false;
880
+ this.selectionBox.set(null);
881
+ }
575
882
  // Handle edge reconnection with port snapping
576
883
  if (this.draggedEdge && this.hoveredNodeId && this.hoveredPort) {
577
884
  const graph = this.internalGraph();
@@ -596,6 +903,7 @@ class GraphEditorComponent {
596
903
  }
597
904
  this.isPanning = false;
598
905
  this.draggedNode = null;
906
+ this.draggedNodeOffsets.clear();
599
907
  this.draggedEdge = null;
600
908
  this.hoveredNodeId = null;
601
909
  this.hoveredPort = null;
@@ -608,6 +916,7 @@ class GraphEditorComponent {
608
916
  if (this.activeTool() !== 'hand')
609
917
  return;
610
918
  this.draggedNode = node;
919
+ this.didDrag = false; // Reset - will be set true if actual movement occurs
611
920
  // Calculate offset between mouse position and node origin to prevent jump
612
921
  const svg = event.target.closest('svg');
613
922
  const rect = svg.getBoundingClientRect();
@@ -617,6 +926,21 @@ class GraphEditorComponent {
617
926
  x: mouseX - node.position.x,
618
927
  y: mouseY - node.position.y
619
928
  };
929
+ // If this node is part of a multi-selection, calculate offsets for all selected nodes
930
+ const sel = this.selection();
931
+ this.draggedNodeOffsets.clear();
932
+ if (sel.nodes.includes(node.id) && sel.nodes.length > 1) {
933
+ const graph = this.internalGraph();
934
+ for (const nodeId of sel.nodes) {
935
+ const n = graph.nodes.find(nd => nd.id === nodeId);
936
+ if (n) {
937
+ this.draggedNodeOffsets.set(nodeId, {
938
+ x: mouseX - n.position.x,
939
+ y: mouseY - n.position.y
940
+ });
941
+ }
942
+ }
943
+ }
620
944
  }
621
945
  onNodeClick(event, node) {
622
946
  if (this.activeTool() === 'line') {
@@ -664,8 +988,20 @@ class GraphEditorComponent {
664
988
  }
665
989
  }
666
990
  else {
667
- // Hand tool - normal select
668
- this.selectNode(node.id);
991
+ // Hand tool - select or toggle selection
992
+ // Skip selection change if we just finished dragging
993
+ if (this.didDrag) {
994
+ this.didDrag = false;
995
+ return;
996
+ }
997
+ if (event.ctrlKey || event.metaKey) {
998
+ // Ctrl/Cmd+Click: toggle node in selection
999
+ this.toggleNodeSelection(node.id);
1000
+ }
1001
+ else {
1002
+ // Normal click: replace selection with this node
1003
+ this.selectNode(node.id);
1004
+ }
669
1005
  }
670
1006
  }
671
1007
  onAttachmentPointClick(event, node, port) {
@@ -766,11 +1102,55 @@ class GraphEditorComponent {
766
1102
  }
767
1103
  // Helper methods
768
1104
  emitGraphChange() {
1105
+ // Push to history (unless this is an undo/redo operation)
1106
+ if (!this.historyService.isUndoRedo()) {
1107
+ this.historyService.push(this.internalGraph());
1108
+ }
769
1109
  this.graphChange.emit(this.internalGraph());
770
1110
  if (this.config.validation?.validateOnChange) {
771
1111
  this.validate();
772
1112
  }
773
1113
  }
1114
+ /** Undo the last action (Ctrl+Z) */
1115
+ undo() {
1116
+ const state = this.historyService.undo();
1117
+ if (!state) {
1118
+ return false;
1119
+ }
1120
+ this.internalGraph.set(state);
1121
+ this.graphChange.emit(this.internalGraph());
1122
+ this.historyService.completeUndoRedo();
1123
+ // Clear selection after undo
1124
+ this.selection.set({ nodes: [], edges: [] });
1125
+ this.selectionChange.emit(this.selection());
1126
+ return true;
1127
+ }
1128
+ /** Redo the last undone action (Ctrl+Y / Ctrl+Shift+Z) */
1129
+ redo() {
1130
+ const state = this.historyService.redo();
1131
+ if (!state) {
1132
+ return false;
1133
+ }
1134
+ this.internalGraph.set(state);
1135
+ this.graphChange.emit(this.internalGraph());
1136
+ this.historyService.completeUndoRedo();
1137
+ // Clear selection after redo
1138
+ this.selection.set({ nodes: [], edges: [] });
1139
+ this.selectionChange.emit(this.selection());
1140
+ return true;
1141
+ }
1142
+ /** Check if undo is available */
1143
+ canUndo() {
1144
+ return this.historyService.canUndo();
1145
+ }
1146
+ /** Check if redo is available */
1147
+ canRedo() {
1148
+ return this.historyService.canRedo();
1149
+ }
1150
+ /** Clear history and reset to current state */
1151
+ clearHistory() {
1152
+ this.historyService.clear(this.internalGraph());
1153
+ }
774
1154
  generateId() {
775
1155
  return `node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
776
1156
  }
@@ -865,6 +1245,53 @@ class GraphEditorComponent {
865
1245
  const nodeConfig = this.config.nodes.types.find(t => t.type === node.type);
866
1246
  return nodeConfig?.icon || '●';
867
1247
  }
1248
+ /**
1249
+ * Get custom image URL for a node.
1250
+ * Checks node.data['imageUrl'] first, then falls back to nodeType.defaultData['imageUrl'].
1251
+ * Returns null if no image is configured (will render text icon instead).
1252
+ */
1253
+ getNodeImage(node) {
1254
+ // Check instance-level image first
1255
+ if (node.data['imageUrl']) {
1256
+ return node.data['imageUrl'];
1257
+ }
1258
+ // Fall back to node type default
1259
+ const nodeConfig = this.config.nodes.types.find(t => t.type === node.type);
1260
+ if (nodeConfig?.defaultData['imageUrl']) {
1261
+ return nodeConfig.defaultData['imageUrl'];
1262
+ }
1263
+ return null;
1264
+ }
1265
+ /**
1266
+ * Get the position for the node image (top-left corner of image).
1267
+ * Uses same positioning logic as icon but accounts for image dimensions.
1268
+ */
1269
+ getImagePosition(node) {
1270
+ const size = this.getNodeSize(node);
1271
+ const imageSize = this.getImageSize(node);
1272
+ const pos = this.config.nodes.iconPosition || 'top-left';
1273
+ const padding = 8;
1274
+ const positions = {
1275
+ 'top-left': { x: padding, y: padding },
1276
+ 'top': { x: (size.width - imageSize) / 2, y: padding },
1277
+ 'top-right': { x: size.width - imageSize - padding, y: padding },
1278
+ 'right': { x: size.width - imageSize - padding, y: (size.height - imageSize) / 2 },
1279
+ 'bottom-right': { x: size.width - imageSize - padding, y: size.height - imageSize - padding },
1280
+ 'bottom': { x: (size.width - imageSize) / 2, y: size.height - imageSize - padding },
1281
+ 'bottom-left': { x: padding, y: size.height - imageSize - padding },
1282
+ 'left': { x: padding, y: (size.height - imageSize) / 2 }
1283
+ };
1284
+ return positions[pos] || positions['top-left'];
1285
+ }
1286
+ /**
1287
+ * Get the size (width/height) for node images.
1288
+ * Images are rendered as squares, sized proportionally to node height.
1289
+ */
1290
+ getImageSize(node) {
1291
+ const size = this.getNodeSize(node);
1292
+ // Image takes up ~40% of node height, with min 24px and max 64px
1293
+ return Math.min(64, Math.max(24, size.height * 0.4));
1294
+ }
868
1295
  getIconPosition(node) {
869
1296
  const size = this.getNodeSize(node);
870
1297
  const pos = this.config.nodes.iconPosition || 'top-left';
@@ -1008,647 +1435,15 @@ class GraphEditorComponent {
1008
1435
  }
1009
1436
  }
1010
1437
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1011
- 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;" }, viewQueries: [{ propertyName: "canvasSvgRef", first: true, predicate: ["canvasSvg"], descendants: true, isSignal: true }], usesOnChanges: true, ngImport: i0, template: `
1012
- <div class="graph-editor-container">
1013
- <!-- Canvas with overlaid palette -->
1014
- <div class="graph-canvas-wrapper">
1015
- <!-- Top-left horizontal palette overlay -->
1016
- @if (config.palette?.enabled !== false) {
1017
- <div class="graph-palette-overlay">
1018
- <!-- Tools -->
1019
- <button
1020
- class="palette-item tool-item"
1021
- [class.active]="activeTool() === 'hand'"
1022
- title="Hand tool (move nodes)"
1023
- (click)="switchTool('hand')"
1024
- >
1025
- <span class="icon">✋</span>
1026
- </button>
1027
- <button
1028
- class="palette-item tool-item"
1029
- [class.active]="activeTool() === 'line'"
1030
- title="Line tool (draw connections)"
1031
- (click)="switchTool('line')"
1032
- >
1033
- <span class="icon">∕</span>
1034
- </button>
1035
-
1036
- <!-- Divider -->
1037
- <div class="palette-divider"></div>
1038
-
1039
- <!-- Node types -->
1040
- @for (nodeType of config.nodes.types; track nodeType.type) {
1041
- <button
1042
- class="palette-item"
1043
- [attr.data-node-type]="nodeType.type"
1044
- [attr.title]="nodeType.label || nodeType.type"
1045
- (click)="addNode(nodeType.type)"
1046
- >
1047
- <span class="icon">{{ nodeType.icon || '●' }}</span>
1048
- </button>
1049
- }
1050
- </div>
1051
- }
1052
-
1053
- <svg
1054
- #canvasSvg
1055
- [class.tool-line]="activeTool() === 'line'"
1056
- [attr.width]="'100%'"
1057
- [attr.height]="'100%'"
1058
- (mousedown)="onCanvasMouseDown($event)"
1059
- (mousemove)="onCanvasMouseMove($event)"
1060
- (mouseup)="onCanvasMouseUp($event)"
1061
- (wheel)="onWheel($event)"
1062
- (contextmenu)="onContextMenu($event)"
1063
- >
1064
- <!-- Arrow marker definitions -->
1065
- <defs>
1066
- <marker id="arrow-end" viewBox="0 0 10 10" refX="9" refY="5"
1067
- markerWidth="8" markerHeight="8" orient="auto">
1068
- <path d="M 0 1 L 8 5 L 0 9 z" fill="#94a3b8"/>
1069
- </marker>
1070
- <marker id="arrow-end-selected" viewBox="0 0 10 10" refX="9" refY="5"
1071
- markerWidth="8" markerHeight="8" orient="auto">
1072
- <path d="M 0 1 L 8 5 L 0 9 z" fill="#3b82f6"/>
1073
- </marker>
1074
- <marker id="arrow-start" viewBox="0 0 10 10" refX="1" refY="5"
1075
- markerWidth="8" markerHeight="8" orient="auto">
1076
- <path d="M 10 1 L 2 5 L 10 9 z" fill="#94a3b8"/>
1077
- </marker>
1078
- <marker id="arrow-start-selected" viewBox="0 0 10 10" refX="1" refY="5"
1079
- markerWidth="8" markerHeight="8" orient="auto">
1080
- <path d="M 10 1 L 2 5 L 10 9 z" fill="#3b82f6"/>
1081
- </marker>
1082
- </defs>
1083
-
1084
- <!-- Main transform group (pan + zoom) -->
1085
- <g [attr.transform]="transform()">
1086
- <!-- Grid (if enabled) -->
1087
- <!-- Grid (if enabled) - extended to cover viewport during pan -->
1088
- @if (config.canvas?.grid?.enabled) {
1089
- <defs>
1090
- <pattern
1091
- id="grid"
1092
- [attr.width]="config.canvas!.grid!.size"
1093
- [attr.height]="config.canvas!.grid!.size"
1094
- patternUnits="userSpaceOnUse"
1095
- >
1096
- <path
1097
- [attr.d]="'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size"
1098
- fill="none"
1099
- [attr.stroke]="config.canvas!.grid!.color || '#e0e0e0'"
1100
- stroke-width="1"
1101
- />
1102
- </pattern>
1103
- </defs>
1104
- <!-- Extended grid background covering viewport + pan offset -->
1105
- <rect
1106
- [attr.x]="gridBounds().x"
1107
- [attr.y]="gridBounds().y"
1108
- [attr.width]="gridBounds().width"
1109
- [attr.height]="gridBounds().height"
1110
- fill="url(#grid)"
1111
- />
1112
- }
1113
-
1114
- <!-- Layer 0.5: Preview line for line tool (rubber-band) -->
1115
- @if (previewLine()) {
1116
- <line
1117
- class="preview-line"
1118
- [attr.x1]="previewLine()!.source.x"
1119
- [attr.y1]="previewLine()!.source.y"
1120
- [attr.x2]="previewLine()!.target.x"
1121
- [attr.y2]="previewLine()!.target.y"
1122
- stroke="#3b82f6"
1123
- stroke-width="2.5"
1124
- stroke-dasharray="8,6"
1125
- stroke-linecap="round"
1126
- opacity="0.7"
1127
- />
1128
- }
1129
-
1130
- <!-- Layer 1: Edge paths (behind everything) -->
1131
- @for (edge of internalGraph().edges; track edge.id) {
1132
- <!-- Edge shadow for depth (optional) -->
1133
- @if (shadowsEnabled()) {
1134
- <path
1135
- class="edge-shadow"
1136
- [attr.d]="getEdgePath(edge)"
1137
- stroke="rgba(0,0,0,0.06)"
1138
- stroke-width="6"
1139
- fill="none"
1140
- stroke-linecap="round"
1141
- [attr.transform]="'translate(1, 2)'"
1142
- />
1143
- }
1144
- <!-- Invisible wide hit-area for easier clicking (hand tool only) -->
1145
- <path
1146
- [attr.d]="getEdgePath(edge)"
1147
- stroke="transparent"
1148
- [attr.stroke-width]="20"
1149
- fill="none"
1150
- class="edge-hit-area"
1151
- [attr.pointer-events]="activeTool() === 'hand' ? 'stroke' : 'none'"
1152
- (click)="onEdgeClick($event, edge)"
1153
- (dblclick)="onEdgeDoubleClick($event, edge)"
1154
- />
1155
- <!-- Visible edge line -->
1156
- <path
1157
- class="edge-line"
1158
- [attr.d]="getEdgePath(edge)"
1159
- [attr.stroke]="selection().edges.includes(edge.id) ? '#3b82f6' : '#94a3b8'"
1160
- [attr.stroke-width]="selection().edges.includes(edge.id) ? 2.5 : 2"
1161
- fill="none"
1162
- stroke-linecap="round"
1163
- [class.selected]="selection().edges.includes(edge.id)"
1164
- [attr.marker-end]="getEdgeMarkerEnd(edge)"
1165
- [attr.marker-start]="getEdgeMarkerStart(edge)"
1166
- pointer-events="none"
1167
- />
1168
- }
1169
-
1170
- <!-- Layer 2: Nodes -->
1171
- @for (node of internalGraph().nodes; track node.id) {
1172
- <g
1173
- [attr.transform]="'translate(' + node.position.x + ',' + node.position.y + ')'"
1174
- class="graph-node"
1175
- [class.selected]="selection().nodes.includes(node.id)"
1176
- [attr.data-node-id]="node.id"
1177
- (mousedown)="onNodeMouseDown($event, node)"
1178
- (click)="onNodeClick($event, node)"
1179
- (dblclick)="nodeDoubleClick.emit(node)"
1180
- >
1181
- <!-- Node shadow (optional) -->
1182
- @if (shadowsEnabled()) {
1183
- <rect
1184
- class="node-shadow"
1185
- [attr.width]="getNodeSize(node).width"
1186
- [attr.height]="getNodeSize(node).height"
1187
- fill="rgba(0,0,0,0.08)"
1188
- rx="12"
1189
- transform="translate(2, 3)"
1190
- style="filter: blur(4px);"
1191
- />
1192
- }
1193
- <!-- Node background -->
1194
- <rect
1195
- class="node-bg"
1196
- [attr.width]="getNodeSize(node).width"
1197
- [attr.height]="getNodeSize(node).height"
1198
- fill="white"
1199
- [attr.stroke]="selection().nodes.includes(node.id) ? '#3b82f6' : '#e2e8f0'"
1200
- [attr.stroke-width]="selection().nodes.includes(node.id) ? 2.5 : 1.5"
1201
- rx="12"
1202
- />
1203
-
1204
- <!-- Node type icon -->
1205
- <text
1206
- class="node-icon"
1207
- [attr.x]="getIconPosition(node).x"
1208
- [attr.y]="getIconPosition(node).y"
1209
- text-anchor="middle"
1210
- dominant-baseline="middle"
1211
- [attr.font-size]="getNodeSize(node).height * 0.28"
1212
- >
1213
- {{ getNodeTypeIcon(node) }}
1214
- </text>
1215
-
1216
- <!-- Node label -->
1217
- <text
1218
- class="node-label"
1219
- [attr.x]="getLabelPosition(node).x"
1220
- [attr.y]="getLabelPosition(node).y"
1221
- text-anchor="middle"
1222
- dominant-baseline="middle"
1223
- font-size="14"
1224
- font-weight="500"
1225
- fill="#1e293b"
1226
- >
1227
- {{ node.data['name'] || node.type }}
1228
- </text>
1229
- </g>
1230
- }
1231
-
1232
- <!-- Layer 3: Attachment points (on top of nodes) -->
1233
- @for (node of internalGraph().nodes; track node.id) {
1234
- @if (showAttachmentPoints() === node.id) {
1235
- <g [attr.transform]="'translate(' + node.position.x + ',' + node.position.y + ')'">
1236
- @for (port of getNodePorts(node); track port.position) {
1237
- <circle
1238
- [attr.cx]="port.x"
1239
- [attr.cy]="port.y"
1240
- [attr.r]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? 8 : 6"
1241
- [attr.fill]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? '#2563eb' : '#94a3b8'"
1242
- stroke="white"
1243
- stroke-width="2"
1244
- class="attachment-point"
1245
- [class.hovered]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position"
1246
- (mousedown)="$event.stopPropagation()"
1247
- (click)="onAttachmentPointClick($event, node, port.position)"
1248
- />
1249
- }
1250
- </g>
1251
- }
1252
- }
1253
-
1254
- <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->
1255
- @for (edge of internalGraph().edges; track edge.id) {
1256
- @if (selection().edges.includes(edge.id)) {
1257
- <g>
1258
- <!-- Source endpoint -->
1259
- <circle
1260
- [attr.cx]="getEdgeSourcePoint(edge).x"
1261
- [attr.cy]="getEdgeSourcePoint(edge).y"
1262
- r="6"
1263
- fill="#3b82f6"
1264
- stroke="white"
1265
- stroke-width="2"
1266
- class="edge-endpoint selected"
1267
- (mousedown)="onEdgeEndpointMouseDown($event, edge, 'source')"
1268
- />
1269
-
1270
- <!-- Target endpoint -->
1271
- <circle
1272
- [attr.cx]="getEdgeTargetPoint(edge).x"
1273
- [attr.cy]="getEdgeTargetPoint(edge).y"
1274
- r="6"
1275
- fill="#3b82f6"
1276
- stroke="white"
1277
- stroke-width="2"
1278
- class="edge-endpoint selected"
1279
- (mousedown)="onEdgeEndpointMouseDown($event, edge, 'target')"
1280
- />
1281
- </g>
1282
- }
1283
- }
1284
- </g>
1285
- </svg>
1286
- </div>
1287
-
1288
- <!-- Edge direction selector overlay -->
1289
- @if (selectedEdgeMidpoint()) {
1290
- <div
1291
- class="edge-direction-selector"
1292
- [style.left.px]="selectedEdgeMidpoint()!.x"
1293
- [style.top.px]="selectedEdgeMidpoint()!.y"
1294
- >
1295
- <button
1296
- class="direction-btn"
1297
- [class.active]="selectedEdgeMidpoint()!.edge.direction === 'backward'"
1298
- title="Backward"
1299
- (click)="setEdgeDirection('backward')"
1300
- >←</button>
1301
- <button
1302
- class="direction-btn"
1303
- [class.active]="selectedEdgeMidpoint()!.edge.direction === 'bidirectional'"
1304
- title="Bidirectional"
1305
- (click)="setEdgeDirection('bidirectional')"
1306
- >↔</button>
1307
- <button
1308
- class="direction-btn"
1309
- [class.active]="!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'"
1310
- title="Forward"
1311
- (click)="setEdgeDirection('forward')"
1312
- >→</button>
1313
- </div>
1314
- }
1315
- <!-- Validation errors -->
1316
- @if (validationResult() && !validationResult()!.valid) {
1317
- <div class="validation-panel">
1318
- <h4>Validation Errors</h4>
1319
- @for (error of validationResult()!.errors; track error.rule) {
1320
- <div class="error-item" [class.warning]="error.severity === 'warning'">
1321
- {{ error.message }}
1322
- </div>
1323
- }
1324
- </div>
1325
- }
1326
- </div>
1327
- `, isInline: true, 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-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 });
1438
+ 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.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 <!-- Validation errors -->\n @if (validationResult() && !validationResult()!.valid) {\n <div class=\"validation-panel\">\n <h4>Validation Errors</h4>\n @for (error of validationResult()!.errors; track error.rule) {\n <div class=\"error-item\" [class.warning]=\"error.severity === 'warning'\">\n {{ error.message }}\n </div>\n }\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-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 });
1328
1439
  }
1329
1440
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphEditorComponent, decorators: [{
1330
1441
  type: Component,
1331
- args: [{ selector: 'graph-editor', standalone: true, imports: [], host: {
1442
+ args: [{ selector: 'graph-editor', standalone: true, imports: [], providers: [GraphHistoryService], host: {
1332
1443
  'tabindex': '0',
1333
1444
  'style': 'outline: none;',
1334
1445
  '(keydown)': 'onKeyDown($event)'
1335
- }, template: `
1336
- <div class="graph-editor-container">
1337
- <!-- Canvas with overlaid palette -->
1338
- <div class="graph-canvas-wrapper">
1339
- <!-- Top-left horizontal palette overlay -->
1340
- @if (config.palette?.enabled !== false) {
1341
- <div class="graph-palette-overlay">
1342
- <!-- Tools -->
1343
- <button
1344
- class="palette-item tool-item"
1345
- [class.active]="activeTool() === 'hand'"
1346
- title="Hand tool (move nodes)"
1347
- (click)="switchTool('hand')"
1348
- >
1349
- <span class="icon">✋</span>
1350
- </button>
1351
- <button
1352
- class="palette-item tool-item"
1353
- [class.active]="activeTool() === 'line'"
1354
- title="Line tool (draw connections)"
1355
- (click)="switchTool('line')"
1356
- >
1357
- <span class="icon">∕</span>
1358
- </button>
1359
-
1360
- <!-- Divider -->
1361
- <div class="palette-divider"></div>
1362
-
1363
- <!-- Node types -->
1364
- @for (nodeType of config.nodes.types; track nodeType.type) {
1365
- <button
1366
- class="palette-item"
1367
- [attr.data-node-type]="nodeType.type"
1368
- [attr.title]="nodeType.label || nodeType.type"
1369
- (click)="addNode(nodeType.type)"
1370
- >
1371
- <span class="icon">{{ nodeType.icon || '●' }}</span>
1372
- </button>
1373
- }
1374
- </div>
1375
- }
1376
-
1377
- <svg
1378
- #canvasSvg
1379
- [class.tool-line]="activeTool() === 'line'"
1380
- [attr.width]="'100%'"
1381
- [attr.height]="'100%'"
1382
- (mousedown)="onCanvasMouseDown($event)"
1383
- (mousemove)="onCanvasMouseMove($event)"
1384
- (mouseup)="onCanvasMouseUp($event)"
1385
- (wheel)="onWheel($event)"
1386
- (contextmenu)="onContextMenu($event)"
1387
- >
1388
- <!-- Arrow marker definitions -->
1389
- <defs>
1390
- <marker id="arrow-end" viewBox="0 0 10 10" refX="9" refY="5"
1391
- markerWidth="8" markerHeight="8" orient="auto">
1392
- <path d="M 0 1 L 8 5 L 0 9 z" fill="#94a3b8"/>
1393
- </marker>
1394
- <marker id="arrow-end-selected" viewBox="0 0 10 10" refX="9" refY="5"
1395
- markerWidth="8" markerHeight="8" orient="auto">
1396
- <path d="M 0 1 L 8 5 L 0 9 z" fill="#3b82f6"/>
1397
- </marker>
1398
- <marker id="arrow-start" viewBox="0 0 10 10" refX="1" refY="5"
1399
- markerWidth="8" markerHeight="8" orient="auto">
1400
- <path d="M 10 1 L 2 5 L 10 9 z" fill="#94a3b8"/>
1401
- </marker>
1402
- <marker id="arrow-start-selected" viewBox="0 0 10 10" refX="1" refY="5"
1403
- markerWidth="8" markerHeight="8" orient="auto">
1404
- <path d="M 10 1 L 2 5 L 10 9 z" fill="#3b82f6"/>
1405
- </marker>
1406
- </defs>
1407
-
1408
- <!-- Main transform group (pan + zoom) -->
1409
- <g [attr.transform]="transform()">
1410
- <!-- Grid (if enabled) -->
1411
- <!-- Grid (if enabled) - extended to cover viewport during pan -->
1412
- @if (config.canvas?.grid?.enabled) {
1413
- <defs>
1414
- <pattern
1415
- id="grid"
1416
- [attr.width]="config.canvas!.grid!.size"
1417
- [attr.height]="config.canvas!.grid!.size"
1418
- patternUnits="userSpaceOnUse"
1419
- >
1420
- <path
1421
- [attr.d]="'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size"
1422
- fill="none"
1423
- [attr.stroke]="config.canvas!.grid!.color || '#e0e0e0'"
1424
- stroke-width="1"
1425
- />
1426
- </pattern>
1427
- </defs>
1428
- <!-- Extended grid background covering viewport + pan offset -->
1429
- <rect
1430
- [attr.x]="gridBounds().x"
1431
- [attr.y]="gridBounds().y"
1432
- [attr.width]="gridBounds().width"
1433
- [attr.height]="gridBounds().height"
1434
- fill="url(#grid)"
1435
- />
1436
- }
1437
-
1438
- <!-- Layer 0.5: Preview line for line tool (rubber-band) -->
1439
- @if (previewLine()) {
1440
- <line
1441
- class="preview-line"
1442
- [attr.x1]="previewLine()!.source.x"
1443
- [attr.y1]="previewLine()!.source.y"
1444
- [attr.x2]="previewLine()!.target.x"
1445
- [attr.y2]="previewLine()!.target.y"
1446
- stroke="#3b82f6"
1447
- stroke-width="2.5"
1448
- stroke-dasharray="8,6"
1449
- stroke-linecap="round"
1450
- opacity="0.7"
1451
- />
1452
- }
1453
-
1454
- <!-- Layer 1: Edge paths (behind everything) -->
1455
- @for (edge of internalGraph().edges; track edge.id) {
1456
- <!-- Edge shadow for depth (optional) -->
1457
- @if (shadowsEnabled()) {
1458
- <path
1459
- class="edge-shadow"
1460
- [attr.d]="getEdgePath(edge)"
1461
- stroke="rgba(0,0,0,0.06)"
1462
- stroke-width="6"
1463
- fill="none"
1464
- stroke-linecap="round"
1465
- [attr.transform]="'translate(1, 2)'"
1466
- />
1467
- }
1468
- <!-- Invisible wide hit-area for easier clicking (hand tool only) -->
1469
- <path
1470
- [attr.d]="getEdgePath(edge)"
1471
- stroke="transparent"
1472
- [attr.stroke-width]="20"
1473
- fill="none"
1474
- class="edge-hit-area"
1475
- [attr.pointer-events]="activeTool() === 'hand' ? 'stroke' : 'none'"
1476
- (click)="onEdgeClick($event, edge)"
1477
- (dblclick)="onEdgeDoubleClick($event, edge)"
1478
- />
1479
- <!-- Visible edge line -->
1480
- <path
1481
- class="edge-line"
1482
- [attr.d]="getEdgePath(edge)"
1483
- [attr.stroke]="selection().edges.includes(edge.id) ? '#3b82f6' : '#94a3b8'"
1484
- [attr.stroke-width]="selection().edges.includes(edge.id) ? 2.5 : 2"
1485
- fill="none"
1486
- stroke-linecap="round"
1487
- [class.selected]="selection().edges.includes(edge.id)"
1488
- [attr.marker-end]="getEdgeMarkerEnd(edge)"
1489
- [attr.marker-start]="getEdgeMarkerStart(edge)"
1490
- pointer-events="none"
1491
- />
1492
- }
1493
-
1494
- <!-- Layer 2: Nodes -->
1495
- @for (node of internalGraph().nodes; track node.id) {
1496
- <g
1497
- [attr.transform]="'translate(' + node.position.x + ',' + node.position.y + ')'"
1498
- class="graph-node"
1499
- [class.selected]="selection().nodes.includes(node.id)"
1500
- [attr.data-node-id]="node.id"
1501
- (mousedown)="onNodeMouseDown($event, node)"
1502
- (click)="onNodeClick($event, node)"
1503
- (dblclick)="nodeDoubleClick.emit(node)"
1504
- >
1505
- <!-- Node shadow (optional) -->
1506
- @if (shadowsEnabled()) {
1507
- <rect
1508
- class="node-shadow"
1509
- [attr.width]="getNodeSize(node).width"
1510
- [attr.height]="getNodeSize(node).height"
1511
- fill="rgba(0,0,0,0.08)"
1512
- rx="12"
1513
- transform="translate(2, 3)"
1514
- style="filter: blur(4px);"
1515
- />
1516
- }
1517
- <!-- Node background -->
1518
- <rect
1519
- class="node-bg"
1520
- [attr.width]="getNodeSize(node).width"
1521
- [attr.height]="getNodeSize(node).height"
1522
- fill="white"
1523
- [attr.stroke]="selection().nodes.includes(node.id) ? '#3b82f6' : '#e2e8f0'"
1524
- [attr.stroke-width]="selection().nodes.includes(node.id) ? 2.5 : 1.5"
1525
- rx="12"
1526
- />
1527
-
1528
- <!-- Node type icon -->
1529
- <text
1530
- class="node-icon"
1531
- [attr.x]="getIconPosition(node).x"
1532
- [attr.y]="getIconPosition(node).y"
1533
- text-anchor="middle"
1534
- dominant-baseline="middle"
1535
- [attr.font-size]="getNodeSize(node).height * 0.28"
1536
- >
1537
- {{ getNodeTypeIcon(node) }}
1538
- </text>
1539
-
1540
- <!-- Node label -->
1541
- <text
1542
- class="node-label"
1543
- [attr.x]="getLabelPosition(node).x"
1544
- [attr.y]="getLabelPosition(node).y"
1545
- text-anchor="middle"
1546
- dominant-baseline="middle"
1547
- font-size="14"
1548
- font-weight="500"
1549
- fill="#1e293b"
1550
- >
1551
- {{ node.data['name'] || node.type }}
1552
- </text>
1553
- </g>
1554
- }
1555
-
1556
- <!-- Layer 3: Attachment points (on top of nodes) -->
1557
- @for (node of internalGraph().nodes; track node.id) {
1558
- @if (showAttachmentPoints() === node.id) {
1559
- <g [attr.transform]="'translate(' + node.position.x + ',' + node.position.y + ')'">
1560
- @for (port of getNodePorts(node); track port.position) {
1561
- <circle
1562
- [attr.cx]="port.x"
1563
- [attr.cy]="port.y"
1564
- [attr.r]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? 8 : 6"
1565
- [attr.fill]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? '#2563eb' : '#94a3b8'"
1566
- stroke="white"
1567
- stroke-width="2"
1568
- class="attachment-point"
1569
- [class.hovered]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position"
1570
- (mousedown)="$event.stopPropagation()"
1571
- (click)="onAttachmentPointClick($event, node, port.position)"
1572
- />
1573
- }
1574
- </g>
1575
- }
1576
- }
1577
-
1578
- <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->
1579
- @for (edge of internalGraph().edges; track edge.id) {
1580
- @if (selection().edges.includes(edge.id)) {
1581
- <g>
1582
- <!-- Source endpoint -->
1583
- <circle
1584
- [attr.cx]="getEdgeSourcePoint(edge).x"
1585
- [attr.cy]="getEdgeSourcePoint(edge).y"
1586
- r="6"
1587
- fill="#3b82f6"
1588
- stroke="white"
1589
- stroke-width="2"
1590
- class="edge-endpoint selected"
1591
- (mousedown)="onEdgeEndpointMouseDown($event, edge, 'source')"
1592
- />
1593
-
1594
- <!-- Target endpoint -->
1595
- <circle
1596
- [attr.cx]="getEdgeTargetPoint(edge).x"
1597
- [attr.cy]="getEdgeTargetPoint(edge).y"
1598
- r="6"
1599
- fill="#3b82f6"
1600
- stroke="white"
1601
- stroke-width="2"
1602
- class="edge-endpoint selected"
1603
- (mousedown)="onEdgeEndpointMouseDown($event, edge, 'target')"
1604
- />
1605
- </g>
1606
- }
1607
- }
1608
- </g>
1609
- </svg>
1610
- </div>
1611
-
1612
- <!-- Edge direction selector overlay -->
1613
- @if (selectedEdgeMidpoint()) {
1614
- <div
1615
- class="edge-direction-selector"
1616
- [style.left.px]="selectedEdgeMidpoint()!.x"
1617
- [style.top.px]="selectedEdgeMidpoint()!.y"
1618
- >
1619
- <button
1620
- class="direction-btn"
1621
- [class.active]="selectedEdgeMidpoint()!.edge.direction === 'backward'"
1622
- title="Backward"
1623
- (click)="setEdgeDirection('backward')"
1624
- >←</button>
1625
- <button
1626
- class="direction-btn"
1627
- [class.active]="selectedEdgeMidpoint()!.edge.direction === 'bidirectional'"
1628
- title="Bidirectional"
1629
- (click)="setEdgeDirection('bidirectional')"
1630
- >↔</button>
1631
- <button
1632
- class="direction-btn"
1633
- [class.active]="!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'"
1634
- title="Forward"
1635
- (click)="setEdgeDirection('forward')"
1636
- >→</button>
1637
- </div>
1638
- }
1639
- <!-- Validation errors -->
1640
- @if (validationResult() && !validationResult()!.valid) {
1641
- <div class="validation-panel">
1642
- <h4>Validation Errors</h4>
1643
- @for (error of validationResult()!.errors; track error.rule) {
1644
- <div class="error-item" [class.warning]="error.severity === 'warning'">
1645
- {{ error.message }}
1646
- </div>
1647
- }
1648
- </div>
1649
- }
1650
- </div>
1651
- `, changeDetection: ChangeDetectionStrategy.OnPush, 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-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"] }]
1446
+ }, 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.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 <!-- Validation errors -->\n @if (validationResult() && !validationResult()!.valid) {\n <div class=\"validation-panel\">\n <h4>Validation Errors</h4>\n @for (error of validationResult()!.errors; track error.rule) {\n <div class=\"error-item\" [class.warning]=\"error.severity === 'warning'\">\n {{ error.message }}\n </div>\n }\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-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"] }]
1652
1447
  }], ctorParameters: () => [], propDecorators: { config: [{
1653
1448
  type: Input,
1654
1449
  args: [{ required: true }]