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