@utisha/graph-editor 1.0.0-beta.1 → 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(() => {
@@ -75,6 +203,8 @@ class GraphEditorComponent {
75
203
  height: viewportHeight * 2
76
204
  };
77
205
  });
206
+ // Shadow configuration (defaults to true)
207
+ shadowsEnabled = computed(() => this.config.theme?.shadows !== false);
78
208
  // Selected edge info for direction selector positioning
79
209
  selectedEdgeMidpoint = computed(() => {
80
210
  const sel = this.selection();
@@ -106,6 +236,8 @@ class GraphEditorComponent {
106
236
  this.internalGraph.set(structuredClone(this.graph));
107
237
  }
108
238
  this.validate();
239
+ // Initialize history with starting state
240
+ this.historyService.init(this.internalGraph());
109
241
  }
110
242
  // Node operations
111
243
  addNode(type, position) {
@@ -188,6 +320,46 @@ class GraphEditorComponent {
188
320
  }
189
321
  this.selectionChange.emit(this.selection());
190
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
+ }
191
363
  onKeyDown(event) {
192
364
  if (this.readonly || this.config.interaction?.readonly)
193
365
  return;
@@ -201,24 +373,58 @@ class GraphEditorComponent {
201
373
  event.preventDefault();
202
374
  return;
203
375
  }
204
- if (event.key === 'Delete' || event.key === 'Backspace') {
205
- const sel = this.selection();
206
- // Delete selected edges
207
- if (sel.edges.length > 0) {
208
- for (const edgeId of sel.edges) {
209
- this.removeEdge(edgeId);
210
- }
376
+ // Undo: Ctrl+Z (or Cmd+Z on Mac)
377
+ if ((event.ctrlKey || event.metaKey) && event.key === 'z' && !event.shiftKey) {
378
+ if (this.undo()) {
211
379
  event.preventDefault();
212
- return;
213
380
  }
214
- // Delete selected nodes (keep attached edges)
215
- if (sel.nodes.length > 0) {
216
- for (const nodeId of sel.nodes) {
217
- this.removeNode(nodeId, false);
218
- }
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()) {
219
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)
220
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);
419
+ }
420
+ for (const node of removedNodes) {
421
+ this.nodeRemoved.emit(node);
221
422
  }
423
+ // Clear selection
424
+ this.selection.set({ nodes: [], edges: [] });
425
+ this.selectionChange.emit(this.selection());
426
+ event.preventDefault();
427
+ return;
222
428
  }
223
429
  // Arrow keys: nudge selected node(s) — 1px default, 10px with Shift
224
430
  if (event.key.startsWith('Arrow')) {
@@ -299,7 +505,14 @@ class GraphEditorComponent {
299
505
  if (this.activeTool() !== 'hand')
300
506
  return;
301
507
  event.stopPropagation();
302
- 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
+ }
303
516
  this.edgeClick.emit(edge);
304
517
  }
305
518
  onEdgeDoubleClick(event, edge) {
@@ -367,7 +580,17 @@ class GraphEditorComponent {
367
580
  },
368
581
  };
369
582
  });
370
- 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 });
371
594
  this.emitGraphChange();
372
595
  setTimeout(() => this.fitToScreen());
373
596
  }
@@ -446,14 +669,37 @@ class GraphEditorComponent {
446
669
  const isHitArea = target.classList.contains('edge-hit-area');
447
670
  const isInteractive = isNode || isEdgeEndpoint || isAttachmentPoint || isHitArea;
448
671
  if (!isInteractive) {
449
- 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
+ }
450
685
  this.lastMousePos = { x: event.clientX, y: event.clientY };
451
686
  this.clearSelection();
452
687
  event.preventDefault();
453
688
  }
454
689
  }
455
690
  onCanvasMouseMove(event) {
456
- 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) {
457
703
  const dx = event.clientX - this.lastMousePos.x;
458
704
  const dy = event.clientY - this.lastMousePos.y;
459
705
  this.panX.set(this.panX() + dx);
@@ -461,46 +707,74 @@ class GraphEditorComponent {
461
707
  this.lastMousePos = { x: event.clientX, y: event.clientY };
462
708
  }
463
709
  else if (this.draggedNode) {
710
+ this.didDrag = true; // Mark that dragging occurred
464
711
  const rect = event.currentTarget.getBoundingClientRect();
465
712
  const mouseX = (event.clientX - rect.left - this.panX()) / this.scale();
466
713
  const mouseY = (event.clientY - rect.top - this.panY()) / this.scale();
467
- let x = mouseX - this.dragOffset.x;
468
- let y = mouseY - this.dragOffset.y;
469
- // Smart snap to grid
470
- if (this.config.canvas?.grid?.snap) {
471
- const gridSize = this.config.canvas.grid.size || 20;
472
- const snapThreshold = gridSize / 4;
473
- const snapX = Math.round(x / gridSize) * gridSize;
474
- const snapY = Math.round(y / gridSize) * gridSize;
475
- if (Math.abs(x - snapX) < snapThreshold)
476
- x = snapX;
477
- if (Math.abs(y - snapY) < snapThreshold)
478
- y = snapY;
479
- }
480
- // Atomic update: node position + edge port recalculation in one graph set
481
714
  const graph = this.internalGraph();
482
- const nodeIndex = graph.nodes.findIndex(n => n.id === this.draggedNode.id);
483
- if (nodeIndex !== -1) {
484
- const updatedNodes = [...graph.nodes];
485
- updatedNodes[nodeIndex] = { ...updatedNodes[nodeIndex], position: { x, y } };
486
- // Recalculate ports for all edges connected to this node
487
- const draggedId = this.draggedNode.id;
488
- const updatedEdges = graph.edges.map(edge => {
489
- if (edge.source !== draggedId && edge.target !== draggedId)
490
- return edge;
491
- const sourceNode = updatedNodes.find(n => n.id === edge.source);
492
- const targetNode = updatedNodes.find(n => n.id === edge.target);
493
- if (!sourceNode || !targetNode)
494
- return edge;
495
- const newSourcePort = this.findClosestPortForEdge(sourceNode, targetNode, 'source');
496
- const newTargetPort = this.findClosestPortForEdge(targetNode, sourceNode, 'target');
497
- if (edge.sourcePort === newSourcePort && edge.targetPort === newTargetPort)
498
- return edge;
499
- return { ...edge, sourcePort: newSourcePort, targetPort: newTargetPort };
500
- });
501
- this.internalGraph.set({ ...graph, nodes: updatedNodes, edges: updatedEdges });
502
- 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
+ }
503
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
+ }
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();
504
778
  }
505
779
  else if (this.draggedEdge) {
506
780
  // Edge reconnection - find hovered node and closest port
@@ -570,6 +844,41 @@ class GraphEditorComponent {
570
844
  }
571
845
  }
572
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
+ }
573
882
  // Handle edge reconnection with port snapping
574
883
  if (this.draggedEdge && this.hoveredNodeId && this.hoveredPort) {
575
884
  const graph = this.internalGraph();
@@ -594,6 +903,7 @@ class GraphEditorComponent {
594
903
  }
595
904
  this.isPanning = false;
596
905
  this.draggedNode = null;
906
+ this.draggedNodeOffsets.clear();
597
907
  this.draggedEdge = null;
598
908
  this.hoveredNodeId = null;
599
909
  this.hoveredPort = null;
@@ -606,6 +916,7 @@ class GraphEditorComponent {
606
916
  if (this.activeTool() !== 'hand')
607
917
  return;
608
918
  this.draggedNode = node;
919
+ this.didDrag = false; // Reset - will be set true if actual movement occurs
609
920
  // Calculate offset between mouse position and node origin to prevent jump
610
921
  const svg = event.target.closest('svg');
611
922
  const rect = svg.getBoundingClientRect();
@@ -615,6 +926,21 @@ class GraphEditorComponent {
615
926
  x: mouseX - node.position.x,
616
927
  y: mouseY - node.position.y
617
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
+ }
618
944
  }
619
945
  onNodeClick(event, node) {
620
946
  if (this.activeTool() === 'line') {
@@ -662,8 +988,20 @@ class GraphEditorComponent {
662
988
  }
663
989
  }
664
990
  else {
665
- // Hand tool - normal select
666
- 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
+ }
667
1005
  }
668
1006
  }
669
1007
  onAttachmentPointClick(event, node, port) {
@@ -730,15 +1068,89 @@ class GraphEditorComponent {
730
1068
  }
731
1069
  onContextMenu(event) {
732
1070
  event.preventDefault();
733
- // TODO: Show context menu
1071
+ const svgRect = this.canvasSvgRef()?.nativeElement.getBoundingClientRect();
1072
+ if (!svgRect)
1073
+ return;
1074
+ // Calculate position in graph coordinates
1075
+ const x = (event.clientX - svgRect.left - this.panX()) / this.scale();
1076
+ const y = (event.clientY - svgRect.top - this.panY()) / this.scale();
1077
+ // Check if clicking on a node
1078
+ const nodeId = this.findNodeAtPosition({ x, y });
1079
+ if (nodeId) {
1080
+ this.contextMenu.emit({
1081
+ type: 'node',
1082
+ position: { x: event.clientX, y: event.clientY },
1083
+ nodeId
1084
+ });
1085
+ return;
1086
+ }
1087
+ // Check if clicking on an edge (use hit area logic)
1088
+ const edgeId = this.findEdgeAtPosition({ x, y });
1089
+ if (edgeId) {
1090
+ this.contextMenu.emit({
1091
+ type: 'edge',
1092
+ position: { x: event.clientX, y: event.clientY },
1093
+ edgeId
1094
+ });
1095
+ return;
1096
+ }
1097
+ // Canvas click
1098
+ this.contextMenu.emit({
1099
+ type: 'canvas',
1100
+ position: { x: event.clientX, y: event.clientY }
1101
+ });
734
1102
  }
735
1103
  // Helper methods
736
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
+ }
737
1109
  this.graphChange.emit(this.internalGraph());
738
1110
  if (this.config.validation?.validateOnChange) {
739
1111
  this.validate();
740
1112
  }
741
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
+ }
742
1154
  generateId() {
743
1155
  return `node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
744
1156
  }
@@ -833,6 +1245,87 @@ class GraphEditorComponent {
833
1245
  const nodeConfig = this.config.nodes.types.find(t => t.type === node.type);
834
1246
  return nodeConfig?.icon || '●';
835
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
+ }
1295
+ getIconPosition(node) {
1296
+ const size = this.getNodeSize(node);
1297
+ const pos = this.config.nodes.iconPosition || 'top-left';
1298
+ const padding = size.height * 0.25;
1299
+ const iconSize = size.height * 0.28;
1300
+ const positions = {
1301
+ 'top-left': { x: padding, y: padding },
1302
+ 'top': { x: size.width / 2, y: padding },
1303
+ 'top-right': { x: size.width - padding, y: padding },
1304
+ 'right': { x: size.width - padding, y: size.height / 2 },
1305
+ 'bottom-right': { x: size.width - padding, y: size.height - padding },
1306
+ 'bottom': { x: size.width / 2, y: size.height - padding },
1307
+ 'bottom-left': { x: padding, y: size.height - padding },
1308
+ 'left': { x: padding, y: size.height / 2 }
1309
+ };
1310
+ return positions[pos] || positions['left'];
1311
+ }
1312
+ getLabelPosition(node) {
1313
+ const size = this.getNodeSize(node);
1314
+ const pos = this.config.nodes.iconPosition || 'top-left';
1315
+ const padding = size.height * 0.25;
1316
+ // Label position adjusts based on icon position
1317
+ const labelPositions = {
1318
+ 'top-left': { x: size.width / 2 + padding / 2, y: size.height / 2 + 4 },
1319
+ 'top': { x: size.width / 2, y: size.height / 2 + padding / 2 },
1320
+ 'top-right': { x: size.width / 2 - padding / 2, y: size.height / 2 + 4 },
1321
+ 'right': { x: size.width / 2 - padding / 2, y: size.height / 2 },
1322
+ 'bottom-right': { x: size.width / 2 - padding / 2, y: size.height / 2 - 4 },
1323
+ 'bottom': { x: size.width / 2, y: size.height / 2 - padding / 2 },
1324
+ 'bottom-left': { x: size.width / 2 + padding / 2, y: size.height / 2 - 4 },
1325
+ 'left': { x: size.width / 2 + padding / 2, y: size.height / 2 }
1326
+ };
1327
+ return labelPositions[pos] || labelPositions['top-left'];
1328
+ }
836
1329
  findNodeAtPosition(pos) {
837
1330
  for (const node of this.internalGraph().nodes) {
838
1331
  const size = this.getNodeSize(node);
@@ -845,6 +1338,34 @@ class GraphEditorComponent {
845
1338
  }
846
1339
  return null;
847
1340
  }
1341
+ findEdgeAtPosition(pos) {
1342
+ const hitDistance = 10; // pixels tolerance
1343
+ for (const edge of this.internalGraph().edges) {
1344
+ const sourcePoint = this.getEdgeSourcePoint(edge);
1345
+ const targetPoint = this.getEdgeTargetPoint(edge);
1346
+ // Calculate distance from point to line segment
1347
+ const dist = this.pointToSegmentDistance(pos, sourcePoint, targetPoint);
1348
+ if (dist < hitDistance) {
1349
+ return edge.id;
1350
+ }
1351
+ }
1352
+ return null;
1353
+ }
1354
+ pointToSegmentDistance(point, lineStart, lineEnd) {
1355
+ const dx = lineEnd.x - lineStart.x;
1356
+ const dy = lineEnd.y - lineStart.y;
1357
+ const lengthSquared = dx * dx + dy * dy;
1358
+ if (lengthSquared === 0) {
1359
+ // Line segment is a point
1360
+ return Math.sqrt((point.x - lineStart.x) ** 2 + (point.y - lineStart.y) ** 2);
1361
+ }
1362
+ // Project point onto line segment
1363
+ let t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / lengthSquared;
1364
+ t = Math.max(0, Math.min(1, t));
1365
+ const projX = lineStart.x + t * dx;
1366
+ const projY = lineStart.y + t * dy;
1367
+ return Math.sqrt((point.x - projX) ** 2 + (point.y - projY) ** 2);
1368
+ }
848
1369
  getNodePorts(node) {
849
1370
  const size = this.getNodeSize(node);
850
1371
  return [
@@ -914,603 +1435,15 @@ class GraphEditorComponent {
914
1435
  }
915
1436
  }
916
1437
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
917
- 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: `
918
- <div class="graph-editor-container">
919
- <!-- Canvas with overlaid palette -->
920
- <div class="graph-canvas-wrapper">
921
- <!-- Top-left horizontal palette overlay -->
922
- @if (config.palette?.enabled !== false) {
923
- <div class="graph-palette-overlay">
924
- <!-- Tools -->
925
- <button
926
- class="palette-item tool-item"
927
- [class.active]="activeTool() === 'hand'"
928
- title="Hand tool (move nodes)"
929
- (click)="switchTool('hand')"
930
- >
931
- <span class="icon">✋</span>
932
- </button>
933
- <button
934
- class="palette-item tool-item"
935
- [class.active]="activeTool() === 'line'"
936
- title="Line tool (draw connections)"
937
- (click)="switchTool('line')"
938
- >
939
- <span class="icon">∕</span>
940
- </button>
941
-
942
- <!-- Divider -->
943
- <div class="palette-divider"></div>
944
-
945
- <!-- Node types -->
946
- @for (nodeType of config.nodes.types; track nodeType.type) {
947
- <button
948
- class="palette-item"
949
- [attr.data-node-type]="nodeType.type"
950
- [attr.title]="nodeType.label || nodeType.type"
951
- (click)="addNode(nodeType.type)"
952
- >
953
- <span class="icon">{{ nodeType.icon || '●' }}</span>
954
- </button>
955
- }
956
- </div>
957
- }
958
-
959
- <svg
960
- #canvasSvg
961
- [class.tool-line]="activeTool() === 'line'"
962
- [attr.width]="'100%'"
963
- [attr.height]="'100%'"
964
- (mousedown)="onCanvasMouseDown($event)"
965
- (mousemove)="onCanvasMouseMove($event)"
966
- (mouseup)="onCanvasMouseUp($event)"
967
- (wheel)="onWheel($event)"
968
- (contextmenu)="onContextMenu($event)"
969
- >
970
- <!-- Arrow marker definitions -->
971
- <defs>
972
- <marker id="arrow-end" viewBox="0 0 10 10" refX="9" refY="5"
973
- markerWidth="8" markerHeight="8" orient="auto">
974
- <path d="M 0 1 L 8 5 L 0 9 z" fill="#94a3b8"/>
975
- </marker>
976
- <marker id="arrow-end-selected" viewBox="0 0 10 10" refX="9" refY="5"
977
- markerWidth="8" markerHeight="8" orient="auto">
978
- <path d="M 0 1 L 8 5 L 0 9 z" fill="#3b82f6"/>
979
- </marker>
980
- <marker id="arrow-start" viewBox="0 0 10 10" refX="1" refY="5"
981
- markerWidth="8" markerHeight="8" orient="auto">
982
- <path d="M 10 1 L 2 5 L 10 9 z" fill="#94a3b8"/>
983
- </marker>
984
- <marker id="arrow-start-selected" viewBox="0 0 10 10" refX="1" refY="5"
985
- markerWidth="8" markerHeight="8" orient="auto">
986
- <path d="M 10 1 L 2 5 L 10 9 z" fill="#3b82f6"/>
987
- </marker>
988
- </defs>
989
-
990
- <!-- Main transform group (pan + zoom) -->
991
- <g [attr.transform]="transform()">
992
- <!-- Grid (if enabled) -->
993
- <!-- Grid (if enabled) - extended to cover viewport during pan -->
994
- @if (config.canvas?.grid?.enabled) {
995
- <defs>
996
- <pattern
997
- id="grid"
998
- [attr.width]="config.canvas!.grid!.size"
999
- [attr.height]="config.canvas!.grid!.size"
1000
- patternUnits="userSpaceOnUse"
1001
- >
1002
- <path
1003
- [attr.d]="'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size"
1004
- fill="none"
1005
- [attr.stroke]="config.canvas!.grid!.color || '#e0e0e0'"
1006
- stroke-width="1"
1007
- />
1008
- </pattern>
1009
- </defs>
1010
- <!-- Extended grid background covering viewport + pan offset -->
1011
- <rect
1012
- [attr.x]="gridBounds().x"
1013
- [attr.y]="gridBounds().y"
1014
- [attr.width]="gridBounds().width"
1015
- [attr.height]="gridBounds().height"
1016
- fill="url(#grid)"
1017
- />
1018
- }
1019
-
1020
- <!-- Layer 0.5: Preview line for line tool (rubber-band) -->
1021
- @if (previewLine()) {
1022
- <line
1023
- [attr.x1]="previewLine()!.source.x"
1024
- [attr.y1]="previewLine()!.source.y"
1025
- [attr.x2]="previewLine()!.target.x"
1026
- [attr.y2]="previewLine()!.target.y"
1027
- stroke="#3b82f6"
1028
- stroke-width="2"
1029
- stroke-dasharray="6,4"
1030
- opacity="0.6"
1031
- />
1032
- }
1033
-
1034
- <!-- Layer 1: Edge paths (behind everything) -->
1035
- @for (edge of internalGraph().edges; track edge.id) {
1036
- <!-- Invisible wide hit-area for easier clicking (hand tool only) -->
1037
- <path
1038
- [attr.d]="getEdgePath(edge)"
1039
- stroke="transparent"
1040
- [attr.stroke-width]="16"
1041
- fill="none"
1042
- class="edge-hit-area"
1043
- [attr.pointer-events]="activeTool() === 'hand' ? 'stroke' : 'none'"
1044
- (click)="onEdgeClick($event, edge)"
1045
- (dblclick)="onEdgeDoubleClick($event, edge)"
1046
- />
1047
- <!-- Visible edge line -->
1048
- <path
1049
- [attr.d]="getEdgePath(edge)"
1050
- [attr.stroke]="getEdgeColor(edge)"
1051
- [attr.stroke-width]="2"
1052
- fill="none"
1053
- [class.selected]="selection().edges.includes(edge.id)"
1054
- [attr.marker-end]="getEdgeMarkerEnd(edge)"
1055
- [attr.marker-start]="getEdgeMarkerStart(edge)"
1056
- pointer-events="none"
1057
- />
1058
- }
1059
-
1060
- <!-- Layer 2: Nodes -->
1061
- @for (node of internalGraph().nodes; track node.id) {
1062
- <g
1063
- [attr.transform]="'translate(' + node.position.x + ',' + node.position.y + ')'"
1064
- class="graph-node"
1065
- [class.selected]="selection().nodes.includes(node.id)"
1066
- [attr.data-node-id]="node.id"
1067
- (mousedown)="onNodeMouseDown($event, node)"
1068
- (click)="onNodeClick($event, node)"
1069
- (dblclick)="nodeDoubleClick.emit(node)"
1070
- >
1071
- <!-- Node background -->
1072
- <rect
1073
- [attr.width]="getNodeSize(node).width"
1074
- [attr.height]="getNodeSize(node).height"
1075
- [attr.fill]="'white'"
1076
- [attr.stroke]="selection().nodes.includes(node.id) ? '#3b82f6' : '#cbd5e0'"
1077
- [attr.stroke-width]="selection().nodes.includes(node.id) ? 3 : 2"
1078
- rx="8"
1079
- />
1080
-
1081
- <!-- Node type icon badge (top-left, with padding from corner) -->
1082
- <g class="node-type-badge">
1083
- <circle
1084
- cx="28"
1085
- cy="28"
1086
- r="16"
1087
- fill="#f3f4f6"
1088
- stroke="#cbd5e0"
1089
- stroke-width="2"
1090
- />
1091
- <text
1092
- x="28"
1093
- y="28"
1094
- text-anchor="middle"
1095
- dominant-baseline="middle"
1096
- font-size="20"
1097
- fill="#374151"
1098
- >
1099
- {{ getNodeTypeIcon(node) }}
1100
- </text>
1101
- </g>
1102
-
1103
- <!-- Node label -->
1104
- <text
1105
- [attr.x]="getNodeSize(node).width / 2"
1106
- [attr.y]="getNodeSize(node).height / 2"
1107
- text-anchor="middle"
1108
- dominant-baseline="middle"
1109
- font-size="14"
1110
- >
1111
- {{ node.data['name'] || node.type }}
1112
- </text>
1113
- </g>
1114
- }
1115
-
1116
- <!-- Layer 3: Attachment points (on top of nodes) -->
1117
- @for (node of internalGraph().nodes; track node.id) {
1118
- @if (showAttachmentPoints() === node.id) {
1119
- <g [attr.transform]="'translate(' + node.position.x + ',' + node.position.y + ')'">
1120
- @for (port of getNodePorts(node); track port.position) {
1121
- <circle
1122
- [attr.cx]="port.x"
1123
- [attr.cy]="port.y"
1124
- [attr.r]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? 8 : 6"
1125
- [attr.fill]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? '#2563eb' : '#94a3b8'"
1126
- stroke="white"
1127
- stroke-width="2"
1128
- class="attachment-point"
1129
- [class.hovered]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position"
1130
- (mousedown)="$event.stopPropagation()"
1131
- (click)="onAttachmentPointClick($event, node, port.position)"
1132
- />
1133
- }
1134
- </g>
1135
- }
1136
- }
1137
-
1138
- <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->
1139
- @for (edge of internalGraph().edges; track edge.id) {
1140
- @if (selection().edges.includes(edge.id)) {
1141
- <g>
1142
- <!-- Source endpoint -->
1143
- <circle
1144
- [attr.cx]="getEdgeSourcePoint(edge).x"
1145
- [attr.cy]="getEdgeSourcePoint(edge).y"
1146
- r="6"
1147
- fill="#3b82f6"
1148
- stroke="white"
1149
- stroke-width="2"
1150
- class="edge-endpoint selected"
1151
- (mousedown)="onEdgeEndpointMouseDown($event, edge, 'source')"
1152
- />
1153
-
1154
- <!-- Target endpoint -->
1155
- <circle
1156
- [attr.cx]="getEdgeTargetPoint(edge).x"
1157
- [attr.cy]="getEdgeTargetPoint(edge).y"
1158
- r="6"
1159
- fill="#3b82f6"
1160
- stroke="white"
1161
- stroke-width="2"
1162
- class="edge-endpoint selected"
1163
- (mousedown)="onEdgeEndpointMouseDown($event, edge, 'target')"
1164
- />
1165
- </g>
1166
- }
1167
- }
1168
- </g>
1169
- </svg>
1170
- </div>
1171
-
1172
- <!-- Edge direction selector overlay -->
1173
- @if (selectedEdgeMidpoint()) {
1174
- <div
1175
- class="edge-direction-selector"
1176
- [style.left.px]="selectedEdgeMidpoint()!.x"
1177
- [style.top.px]="selectedEdgeMidpoint()!.y"
1178
- >
1179
- <button
1180
- class="direction-btn"
1181
- [class.active]="selectedEdgeMidpoint()!.edge.direction === 'backward'"
1182
- title="Backward"
1183
- (click)="setEdgeDirection('backward')"
1184
- >←</button>
1185
- <button
1186
- class="direction-btn"
1187
- [class.active]="selectedEdgeMidpoint()!.edge.direction === 'bidirectional'"
1188
- title="Bidirectional"
1189
- (click)="setEdgeDirection('bidirectional')"
1190
- >↔</button>
1191
- <button
1192
- class="direction-btn"
1193
- [class.active]="!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'"
1194
- title="Forward"
1195
- (click)="setEdgeDirection('forward')"
1196
- >→</button>
1197
- </div>
1198
- }
1199
- <!-- Validation errors -->
1200
- @if (validationResult() && !validationResult()!.valid) {
1201
- <div class="validation-panel">
1202
- <h4>Validation Errors</h4>
1203
- @for (error of validationResult()!.errors; track error.rule) {
1204
- <div class="error-item" [class.warning]="error.severity === 'warning'">
1205
- {{ error.message }}
1206
- </div>
1207
- }
1208
- </div>
1209
- }
1210
- </div>
1211
- `, 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}.graph-node text{pointer-events:none}.graph-node.selected rect{filter:drop-shadow(0 0 6px rgba(59,130,246,.4))}path.selected{stroke:#3b82f6!important;stroke-width:3!important}.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 });
1212
1439
  }
1213
1440
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphEditorComponent, decorators: [{
1214
1441
  type: Component,
1215
- args: [{ selector: 'graph-editor', standalone: true, imports: [], host: {
1442
+ args: [{ selector: 'graph-editor', standalone: true, imports: [], providers: [GraphHistoryService], host: {
1216
1443
  'tabindex': '0',
1217
1444
  'style': 'outline: none;',
1218
1445
  '(keydown)': 'onKeyDown($event)'
1219
- }, template: `
1220
- <div class="graph-editor-container">
1221
- <!-- Canvas with overlaid palette -->
1222
- <div class="graph-canvas-wrapper">
1223
- <!-- Top-left horizontal palette overlay -->
1224
- @if (config.palette?.enabled !== false) {
1225
- <div class="graph-palette-overlay">
1226
- <!-- Tools -->
1227
- <button
1228
- class="palette-item tool-item"
1229
- [class.active]="activeTool() === 'hand'"
1230
- title="Hand tool (move nodes)"
1231
- (click)="switchTool('hand')"
1232
- >
1233
- <span class="icon">✋</span>
1234
- </button>
1235
- <button
1236
- class="palette-item tool-item"
1237
- [class.active]="activeTool() === 'line'"
1238
- title="Line tool (draw connections)"
1239
- (click)="switchTool('line')"
1240
- >
1241
- <span class="icon">∕</span>
1242
- </button>
1243
-
1244
- <!-- Divider -->
1245
- <div class="palette-divider"></div>
1246
-
1247
- <!-- Node types -->
1248
- @for (nodeType of config.nodes.types; track nodeType.type) {
1249
- <button
1250
- class="palette-item"
1251
- [attr.data-node-type]="nodeType.type"
1252
- [attr.title]="nodeType.label || nodeType.type"
1253
- (click)="addNode(nodeType.type)"
1254
- >
1255
- <span class="icon">{{ nodeType.icon || '●' }}</span>
1256
- </button>
1257
- }
1258
- </div>
1259
- }
1260
-
1261
- <svg
1262
- #canvasSvg
1263
- [class.tool-line]="activeTool() === 'line'"
1264
- [attr.width]="'100%'"
1265
- [attr.height]="'100%'"
1266
- (mousedown)="onCanvasMouseDown($event)"
1267
- (mousemove)="onCanvasMouseMove($event)"
1268
- (mouseup)="onCanvasMouseUp($event)"
1269
- (wheel)="onWheel($event)"
1270
- (contextmenu)="onContextMenu($event)"
1271
- >
1272
- <!-- Arrow marker definitions -->
1273
- <defs>
1274
- <marker id="arrow-end" viewBox="0 0 10 10" refX="9" refY="5"
1275
- markerWidth="8" markerHeight="8" orient="auto">
1276
- <path d="M 0 1 L 8 5 L 0 9 z" fill="#94a3b8"/>
1277
- </marker>
1278
- <marker id="arrow-end-selected" viewBox="0 0 10 10" refX="9" refY="5"
1279
- markerWidth="8" markerHeight="8" orient="auto">
1280
- <path d="M 0 1 L 8 5 L 0 9 z" fill="#3b82f6"/>
1281
- </marker>
1282
- <marker id="arrow-start" viewBox="0 0 10 10" refX="1" refY="5"
1283
- markerWidth="8" markerHeight="8" orient="auto">
1284
- <path d="M 10 1 L 2 5 L 10 9 z" fill="#94a3b8"/>
1285
- </marker>
1286
- <marker id="arrow-start-selected" viewBox="0 0 10 10" refX="1" refY="5"
1287
- markerWidth="8" markerHeight="8" orient="auto">
1288
- <path d="M 10 1 L 2 5 L 10 9 z" fill="#3b82f6"/>
1289
- </marker>
1290
- </defs>
1291
-
1292
- <!-- Main transform group (pan + zoom) -->
1293
- <g [attr.transform]="transform()">
1294
- <!-- Grid (if enabled) -->
1295
- <!-- Grid (if enabled) - extended to cover viewport during pan -->
1296
- @if (config.canvas?.grid?.enabled) {
1297
- <defs>
1298
- <pattern
1299
- id="grid"
1300
- [attr.width]="config.canvas!.grid!.size"
1301
- [attr.height]="config.canvas!.grid!.size"
1302
- patternUnits="userSpaceOnUse"
1303
- >
1304
- <path
1305
- [attr.d]="'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size"
1306
- fill="none"
1307
- [attr.stroke]="config.canvas!.grid!.color || '#e0e0e0'"
1308
- stroke-width="1"
1309
- />
1310
- </pattern>
1311
- </defs>
1312
- <!-- Extended grid background covering viewport + pan offset -->
1313
- <rect
1314
- [attr.x]="gridBounds().x"
1315
- [attr.y]="gridBounds().y"
1316
- [attr.width]="gridBounds().width"
1317
- [attr.height]="gridBounds().height"
1318
- fill="url(#grid)"
1319
- />
1320
- }
1321
-
1322
- <!-- Layer 0.5: Preview line for line tool (rubber-band) -->
1323
- @if (previewLine()) {
1324
- <line
1325
- [attr.x1]="previewLine()!.source.x"
1326
- [attr.y1]="previewLine()!.source.y"
1327
- [attr.x2]="previewLine()!.target.x"
1328
- [attr.y2]="previewLine()!.target.y"
1329
- stroke="#3b82f6"
1330
- stroke-width="2"
1331
- stroke-dasharray="6,4"
1332
- opacity="0.6"
1333
- />
1334
- }
1335
-
1336
- <!-- Layer 1: Edge paths (behind everything) -->
1337
- @for (edge of internalGraph().edges; track edge.id) {
1338
- <!-- Invisible wide hit-area for easier clicking (hand tool only) -->
1339
- <path
1340
- [attr.d]="getEdgePath(edge)"
1341
- stroke="transparent"
1342
- [attr.stroke-width]="16"
1343
- fill="none"
1344
- class="edge-hit-area"
1345
- [attr.pointer-events]="activeTool() === 'hand' ? 'stroke' : 'none'"
1346
- (click)="onEdgeClick($event, edge)"
1347
- (dblclick)="onEdgeDoubleClick($event, edge)"
1348
- />
1349
- <!-- Visible edge line -->
1350
- <path
1351
- [attr.d]="getEdgePath(edge)"
1352
- [attr.stroke]="getEdgeColor(edge)"
1353
- [attr.stroke-width]="2"
1354
- fill="none"
1355
- [class.selected]="selection().edges.includes(edge.id)"
1356
- [attr.marker-end]="getEdgeMarkerEnd(edge)"
1357
- [attr.marker-start]="getEdgeMarkerStart(edge)"
1358
- pointer-events="none"
1359
- />
1360
- }
1361
-
1362
- <!-- Layer 2: Nodes -->
1363
- @for (node of internalGraph().nodes; track node.id) {
1364
- <g
1365
- [attr.transform]="'translate(' + node.position.x + ',' + node.position.y + ')'"
1366
- class="graph-node"
1367
- [class.selected]="selection().nodes.includes(node.id)"
1368
- [attr.data-node-id]="node.id"
1369
- (mousedown)="onNodeMouseDown($event, node)"
1370
- (click)="onNodeClick($event, node)"
1371
- (dblclick)="nodeDoubleClick.emit(node)"
1372
- >
1373
- <!-- Node background -->
1374
- <rect
1375
- [attr.width]="getNodeSize(node).width"
1376
- [attr.height]="getNodeSize(node).height"
1377
- [attr.fill]="'white'"
1378
- [attr.stroke]="selection().nodes.includes(node.id) ? '#3b82f6' : '#cbd5e0'"
1379
- [attr.stroke-width]="selection().nodes.includes(node.id) ? 3 : 2"
1380
- rx="8"
1381
- />
1382
-
1383
- <!-- Node type icon badge (top-left, with padding from corner) -->
1384
- <g class="node-type-badge">
1385
- <circle
1386
- cx="28"
1387
- cy="28"
1388
- r="16"
1389
- fill="#f3f4f6"
1390
- stroke="#cbd5e0"
1391
- stroke-width="2"
1392
- />
1393
- <text
1394
- x="28"
1395
- y="28"
1396
- text-anchor="middle"
1397
- dominant-baseline="middle"
1398
- font-size="20"
1399
- fill="#374151"
1400
- >
1401
- {{ getNodeTypeIcon(node) }}
1402
- </text>
1403
- </g>
1404
-
1405
- <!-- Node label -->
1406
- <text
1407
- [attr.x]="getNodeSize(node).width / 2"
1408
- [attr.y]="getNodeSize(node).height / 2"
1409
- text-anchor="middle"
1410
- dominant-baseline="middle"
1411
- font-size="14"
1412
- >
1413
- {{ node.data['name'] || node.type }}
1414
- </text>
1415
- </g>
1416
- }
1417
-
1418
- <!-- Layer 3: Attachment points (on top of nodes) -->
1419
- @for (node of internalGraph().nodes; track node.id) {
1420
- @if (showAttachmentPoints() === node.id) {
1421
- <g [attr.transform]="'translate(' + node.position.x + ',' + node.position.y + ')'">
1422
- @for (port of getNodePorts(node); track port.position) {
1423
- <circle
1424
- [attr.cx]="port.x"
1425
- [attr.cy]="port.y"
1426
- [attr.r]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? 8 : 6"
1427
- [attr.fill]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? '#2563eb' : '#94a3b8'"
1428
- stroke="white"
1429
- stroke-width="2"
1430
- class="attachment-point"
1431
- [class.hovered]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position"
1432
- (mousedown)="$event.stopPropagation()"
1433
- (click)="onAttachmentPointClick($event, node, port.position)"
1434
- />
1435
- }
1436
- </g>
1437
- }
1438
- }
1439
-
1440
- <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->
1441
- @for (edge of internalGraph().edges; track edge.id) {
1442
- @if (selection().edges.includes(edge.id)) {
1443
- <g>
1444
- <!-- Source endpoint -->
1445
- <circle
1446
- [attr.cx]="getEdgeSourcePoint(edge).x"
1447
- [attr.cy]="getEdgeSourcePoint(edge).y"
1448
- r="6"
1449
- fill="#3b82f6"
1450
- stroke="white"
1451
- stroke-width="2"
1452
- class="edge-endpoint selected"
1453
- (mousedown)="onEdgeEndpointMouseDown($event, edge, 'source')"
1454
- />
1455
-
1456
- <!-- Target endpoint -->
1457
- <circle
1458
- [attr.cx]="getEdgeTargetPoint(edge).x"
1459
- [attr.cy]="getEdgeTargetPoint(edge).y"
1460
- r="6"
1461
- fill="#3b82f6"
1462
- stroke="white"
1463
- stroke-width="2"
1464
- class="edge-endpoint selected"
1465
- (mousedown)="onEdgeEndpointMouseDown($event, edge, 'target')"
1466
- />
1467
- </g>
1468
- }
1469
- }
1470
- </g>
1471
- </svg>
1472
- </div>
1473
-
1474
- <!-- Edge direction selector overlay -->
1475
- @if (selectedEdgeMidpoint()) {
1476
- <div
1477
- class="edge-direction-selector"
1478
- [style.left.px]="selectedEdgeMidpoint()!.x"
1479
- [style.top.px]="selectedEdgeMidpoint()!.y"
1480
- >
1481
- <button
1482
- class="direction-btn"
1483
- [class.active]="selectedEdgeMidpoint()!.edge.direction === 'backward'"
1484
- title="Backward"
1485
- (click)="setEdgeDirection('backward')"
1486
- >←</button>
1487
- <button
1488
- class="direction-btn"
1489
- [class.active]="selectedEdgeMidpoint()!.edge.direction === 'bidirectional'"
1490
- title="Bidirectional"
1491
- (click)="setEdgeDirection('bidirectional')"
1492
- >↔</button>
1493
- <button
1494
- class="direction-btn"
1495
- [class.active]="!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'"
1496
- title="Forward"
1497
- (click)="setEdgeDirection('forward')"
1498
- >→</button>
1499
- </div>
1500
- }
1501
- <!-- Validation errors -->
1502
- @if (validationResult() && !validationResult()!.valid) {
1503
- <div class="validation-panel">
1504
- <h4>Validation Errors</h4>
1505
- @for (error of validationResult()!.errors; track error.rule) {
1506
- <div class="error-item" [class.warning]="error.severity === 'warning'">
1507
- {{ error.message }}
1508
- </div>
1509
- }
1510
- </div>
1511
- }
1512
- </div>
1513
- `, 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}.graph-node text{pointer-events:none}.graph-node.selected rect{filter:drop-shadow(0 0 6px rgba(59,130,246,.4))}path.selected{stroke:#3b82f6!important;stroke-width:3!important}.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"] }]
1514
1447
  }], ctorParameters: () => [], propDecorators: { config: [{
1515
1448
  type: Input,
1516
1449
  args: [{ required: true }]