@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,
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
//
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
if (
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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 -
|
|
666
|
-
|
|
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
|
-
|
|
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 }]
|