@utisha/graph-editor 1.0.0-beta.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.
- package/fesm2022/utisha-graph-editor.mjs +1564 -0
- package/fesm2022/utisha-graph-editor.mjs.map +1 -0
- package/index.d.ts +5 -0
- package/lib/graph-editor.component.d.ts +128 -0
- package/lib/graph-editor.config.d.ts +227 -0
- package/lib/graph.model.d.ts +76 -0
- package/package.json +51 -0
- package/public-api.d.ts +3 -0
|
@@ -0,0 +1,1564 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { EventEmitter, viewChild, signal, computed, Output, Input, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Main graph editor component.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* <graph-editor
|
|
9
|
+
* [config]="editorConfig"
|
|
10
|
+
* [graph]="currentGraph()"
|
|
11
|
+
* (graphChange)="onGraphChange($event)"
|
|
12
|
+
* />
|
|
13
|
+
*/
|
|
14
|
+
class GraphEditorComponent {
|
|
15
|
+
// Inputs
|
|
16
|
+
config;
|
|
17
|
+
graph = { nodes: [], edges: [] };
|
|
18
|
+
readonly = false;
|
|
19
|
+
visualizationMode = false;
|
|
20
|
+
overlayData;
|
|
21
|
+
// Outputs
|
|
22
|
+
graphChange = new EventEmitter();
|
|
23
|
+
nodeAdded = new EventEmitter();
|
|
24
|
+
nodeUpdated = new EventEmitter();
|
|
25
|
+
nodeRemoved = new EventEmitter();
|
|
26
|
+
edgeAdded = new EventEmitter();
|
|
27
|
+
edgeUpdated = new EventEmitter();
|
|
28
|
+
edgeRemoved = new EventEmitter();
|
|
29
|
+
selectionChange = new EventEmitter();
|
|
30
|
+
validationChange = new EventEmitter();
|
|
31
|
+
nodeClick = new EventEmitter();
|
|
32
|
+
nodeDoubleClick = new EventEmitter();
|
|
33
|
+
edgeClick = new EventEmitter();
|
|
34
|
+
edgeDoubleClick = new EventEmitter();
|
|
35
|
+
canvasClick = new EventEmitter();
|
|
36
|
+
contextMenu = new EventEmitter();
|
|
37
|
+
canvasSvgRef = viewChild('canvasSvg');
|
|
38
|
+
// Internal state
|
|
39
|
+
internalGraph = signal({ nodes: [], edges: [] });
|
|
40
|
+
selection = signal({ nodes: [], edges: [] });
|
|
41
|
+
validationResult = signal(null);
|
|
42
|
+
// Pan & Zoom state
|
|
43
|
+
panX = signal(0);
|
|
44
|
+
panY = signal(0);
|
|
45
|
+
scale = signal(1);
|
|
46
|
+
// Dragging state
|
|
47
|
+
draggedNode = null;
|
|
48
|
+
dragOffset = { x: 0, y: 0 };
|
|
49
|
+
isPanning = false;
|
|
50
|
+
lastMousePos = { x: 0, y: 0 };
|
|
51
|
+
draggedEdge = null;
|
|
52
|
+
hoveredNodeId = null;
|
|
53
|
+
hoveredPort = null;
|
|
54
|
+
// Attachment points visibility
|
|
55
|
+
showAttachmentPoints = signal(null); // nodeId to show ports for
|
|
56
|
+
// Active tool
|
|
57
|
+
activeTool = signal('hand');
|
|
58
|
+
// Line tool state
|
|
59
|
+
pendingEdge = null;
|
|
60
|
+
// Preview line for line tool (rubber-band from source to cursor)
|
|
61
|
+
previewLine = signal(null);
|
|
62
|
+
// Computed
|
|
63
|
+
transform = computed(() => `translate(${this.panX()}, ${this.panY()}) scale(${this.scale()})`);
|
|
64
|
+
gridBounds = computed(() => {
|
|
65
|
+
const gridSize = this.config.canvas?.grid?.size || 20;
|
|
66
|
+
const viewportWidth = 10000; // Large enough to cover any reasonable viewport
|
|
67
|
+
const viewportHeight = 10000;
|
|
68
|
+
// Calculate grid offset to align with pan
|
|
69
|
+
const x = Math.floor(-this.panX() / this.scale() / gridSize) * gridSize - viewportWidth / 2;
|
|
70
|
+
const y = Math.floor(-this.panY() / this.scale() / gridSize) * gridSize - viewportHeight / 2;
|
|
71
|
+
return {
|
|
72
|
+
x,
|
|
73
|
+
y,
|
|
74
|
+
width: viewportWidth * 2,
|
|
75
|
+
height: viewportHeight * 2
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
// Selected edge info for direction selector positioning
|
|
79
|
+
selectedEdgeMidpoint = computed(() => {
|
|
80
|
+
const sel = this.selection();
|
|
81
|
+
if (sel.edges.length !== 1)
|
|
82
|
+
return null;
|
|
83
|
+
const edge = this.internalGraph().edges.find(e => e.id === sel.edges[0]);
|
|
84
|
+
if (!edge)
|
|
85
|
+
return null;
|
|
86
|
+
const sourcePoint = this.getEdgeSourcePoint(edge);
|
|
87
|
+
const targetPoint = this.getEdgeTargetPoint(edge);
|
|
88
|
+
const midX = (sourcePoint.x + targetPoint.x) / 2;
|
|
89
|
+
const midY = (sourcePoint.y + targetPoint.y) / 2;
|
|
90
|
+
return {
|
|
91
|
+
edge,
|
|
92
|
+
x: midX * this.scale() + this.panX(),
|
|
93
|
+
y: midY * this.scale() + this.panY()
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
constructor() { }
|
|
97
|
+
ngOnChanges(changes) {
|
|
98
|
+
// Sync graph input to internal signal
|
|
99
|
+
if (changes['graph'] && changes['graph'].currentValue) {
|
|
100
|
+
this.internalGraph.set(structuredClone(changes['graph'].currentValue));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
ngOnInit() {
|
|
104
|
+
// Initialize with current graph value
|
|
105
|
+
if (this.graph) {
|
|
106
|
+
this.internalGraph.set(structuredClone(this.graph));
|
|
107
|
+
}
|
|
108
|
+
this.validate();
|
|
109
|
+
}
|
|
110
|
+
// Node operations
|
|
111
|
+
addNode(type, position) {
|
|
112
|
+
const nodeConfig = this.config.nodes.types.find(t => t.type === type);
|
|
113
|
+
if (!nodeConfig) {
|
|
114
|
+
throw new Error(`Unknown node type: ${type}`);
|
|
115
|
+
}
|
|
116
|
+
const newNode = {
|
|
117
|
+
id: this.generateId(),
|
|
118
|
+
type,
|
|
119
|
+
data: structuredClone(nodeConfig.defaultData),
|
|
120
|
+
position: position || { x: 100, y: 100 }
|
|
121
|
+
};
|
|
122
|
+
const graph = this.internalGraph();
|
|
123
|
+
this.internalGraph.set({
|
|
124
|
+
...graph,
|
|
125
|
+
nodes: [...graph.nodes, newNode]
|
|
126
|
+
});
|
|
127
|
+
this.emitGraphChange();
|
|
128
|
+
this.nodeAdded.emit(newNode);
|
|
129
|
+
this.switchTool('hand');
|
|
130
|
+
return newNode;
|
|
131
|
+
}
|
|
132
|
+
removeNode(nodeId, removeAttachedEdges = false) {
|
|
133
|
+
const graph = this.internalGraph();
|
|
134
|
+
const removedNode = graph.nodes.find(n => n.id === nodeId);
|
|
135
|
+
this.internalGraph.set({
|
|
136
|
+
...graph,
|
|
137
|
+
nodes: graph.nodes.filter(n => n.id !== nodeId),
|
|
138
|
+
edges: removeAttachedEdges
|
|
139
|
+
? graph.edges.filter(e => e.source !== nodeId && e.target !== nodeId)
|
|
140
|
+
: graph.edges
|
|
141
|
+
});
|
|
142
|
+
this.selection.set({ nodes: [], edges: [] });
|
|
143
|
+
this.emitGraphChange();
|
|
144
|
+
if (removedNode)
|
|
145
|
+
this.nodeRemoved.emit(removedNode);
|
|
146
|
+
}
|
|
147
|
+
removeEdge(edgeId) {
|
|
148
|
+
const graph = this.internalGraph();
|
|
149
|
+
const removedEdge = graph.edges.find(e => e.id === edgeId);
|
|
150
|
+
this.internalGraph.set({
|
|
151
|
+
...graph,
|
|
152
|
+
edges: graph.edges.filter(e => e.id !== edgeId)
|
|
153
|
+
});
|
|
154
|
+
this.selection.set({ nodes: [], edges: [] });
|
|
155
|
+
this.emitGraphChange();
|
|
156
|
+
if (removedEdge)
|
|
157
|
+
this.edgeRemoved.emit(removedEdge);
|
|
158
|
+
}
|
|
159
|
+
updateNode(nodeId, updates) {
|
|
160
|
+
const graph = this.internalGraph();
|
|
161
|
+
const nodeIndex = graph.nodes.findIndex(n => n.id === nodeId);
|
|
162
|
+
if (nodeIndex === -1)
|
|
163
|
+
return;
|
|
164
|
+
const updatedNodes = [...graph.nodes];
|
|
165
|
+
updatedNodes[nodeIndex] = { ...updatedNodes[nodeIndex], ...updates };
|
|
166
|
+
this.internalGraph.set({
|
|
167
|
+
...graph,
|
|
168
|
+
nodes: updatedNodes
|
|
169
|
+
});
|
|
170
|
+
this.emitGraphChange();
|
|
171
|
+
}
|
|
172
|
+
// Selection
|
|
173
|
+
selectNode(nodeId) {
|
|
174
|
+
if (nodeId === null) {
|
|
175
|
+
this.selection.set({ nodes: [], edges: [] });
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
this.selection.set({ nodes: [nodeId], edges: [] });
|
|
179
|
+
}
|
|
180
|
+
this.selectionChange.emit(this.selection());
|
|
181
|
+
}
|
|
182
|
+
selectEdge(edgeId) {
|
|
183
|
+
if (edgeId === null) {
|
|
184
|
+
this.selection.set({ nodes: [], edges: [] });
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
this.selection.set({ nodes: [], edges: [edgeId] });
|
|
188
|
+
}
|
|
189
|
+
this.selectionChange.emit(this.selection());
|
|
190
|
+
}
|
|
191
|
+
onKeyDown(event) {
|
|
192
|
+
if (this.readonly || this.config.interaction?.readonly)
|
|
193
|
+
return;
|
|
194
|
+
// Escape: cancel line drawing, clear selection
|
|
195
|
+
if (event.key === 'Escape') {
|
|
196
|
+
this.pendingEdge = null;
|
|
197
|
+
this.previewLine.set(null);
|
|
198
|
+
this.selection.set({ nodes: [], edges: [] });
|
|
199
|
+
this.selectionChange.emit(this.selection());
|
|
200
|
+
this.showAttachmentPoints.set(null);
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
205
|
+
const sel = this.selection();
|
|
206
|
+
// Delete selected edges
|
|
207
|
+
if (sel.edges.length > 0) {
|
|
208
|
+
for (const edgeId of sel.edges) {
|
|
209
|
+
this.removeEdge(edgeId);
|
|
210
|
+
}
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Delete selected nodes (keep attached edges)
|
|
215
|
+
if (sel.nodes.length > 0) {
|
|
216
|
+
for (const nodeId of sel.nodes) {
|
|
217
|
+
this.removeNode(nodeId, false);
|
|
218
|
+
}
|
|
219
|
+
event.preventDefault();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Arrow keys: nudge selected node(s) — 1px default, 10px with Shift
|
|
224
|
+
if (event.key.startsWith('Arrow')) {
|
|
225
|
+
const sel = this.selection();
|
|
226
|
+
if (sel.nodes.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
const step = event.shiftKey ? 10 : 1;
|
|
229
|
+
let dx = 0;
|
|
230
|
+
let dy = 0;
|
|
231
|
+
switch (event.key) {
|
|
232
|
+
case 'ArrowUp':
|
|
233
|
+
dy = -step;
|
|
234
|
+
break;
|
|
235
|
+
case 'ArrowDown':
|
|
236
|
+
dy = step;
|
|
237
|
+
break;
|
|
238
|
+
case 'ArrowLeft':
|
|
239
|
+
dx = -step;
|
|
240
|
+
break;
|
|
241
|
+
case 'ArrowRight':
|
|
242
|
+
dx = step;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
const graph = this.internalGraph();
|
|
247
|
+
const updatedNodes = [...graph.nodes];
|
|
248
|
+
for (const nodeId of sel.nodes) {
|
|
249
|
+
const idx = updatedNodes.findIndex(n => n.id === nodeId);
|
|
250
|
+
if (idx === -1)
|
|
251
|
+
continue;
|
|
252
|
+
const pos = updatedNodes[idx].position;
|
|
253
|
+
updatedNodes[idx] = { ...updatedNodes[idx], position: { x: pos.x + dx, y: pos.y + dy } };
|
|
254
|
+
}
|
|
255
|
+
// Recalculate edge ports for moved nodes (atomic update)
|
|
256
|
+
const movedIds = new Set(sel.nodes);
|
|
257
|
+
const updatedEdges = graph.edges.map(edge => {
|
|
258
|
+
if (!movedIds.has(edge.source) && !movedIds.has(edge.target))
|
|
259
|
+
return edge;
|
|
260
|
+
const sourceNode = updatedNodes.find(n => n.id === edge.source);
|
|
261
|
+
const targetNode = updatedNodes.find(n => n.id === edge.target);
|
|
262
|
+
if (!sourceNode || !targetNode)
|
|
263
|
+
return edge;
|
|
264
|
+
const newSourcePort = this.findClosestPortForEdge(sourceNode, targetNode, 'source');
|
|
265
|
+
const newTargetPort = this.findClosestPortForEdge(targetNode, sourceNode, 'target');
|
|
266
|
+
if (edge.sourcePort === newSourcePort && edge.targetPort === newTargetPort)
|
|
267
|
+
return edge;
|
|
268
|
+
return { ...edge, sourcePort: newSourcePort, targetPort: newTargetPort };
|
|
269
|
+
});
|
|
270
|
+
this.internalGraph.set({ ...graph, nodes: updatedNodes, edges: updatedEdges });
|
|
271
|
+
this.emitGraphChange();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
switchTool(tool) {
|
|
275
|
+
const previousTool = this.activeTool();
|
|
276
|
+
// Cancel any in-progress line drawing
|
|
277
|
+
this.pendingEdge = null;
|
|
278
|
+
this.previewLine.set(null);
|
|
279
|
+
this.showAttachmentPoints.set(null);
|
|
280
|
+
// Preserve node selection when switching hand → line
|
|
281
|
+
if (!(previousTool === 'hand' && tool === 'line')) {
|
|
282
|
+
this.selection.set({ nodes: [], edges: [] });
|
|
283
|
+
this.selectionChange.emit(this.selection());
|
|
284
|
+
}
|
|
285
|
+
this.activeTool.set(tool);
|
|
286
|
+
// Hand → line with a node selected: start edge from that node
|
|
287
|
+
if (previousTool === 'hand' && tool === 'line') {
|
|
288
|
+
const sel = this.selection();
|
|
289
|
+
if (sel.nodes.length === 1) {
|
|
290
|
+
this.pendingEdge = { sourceId: sel.nodes[0], sourcePort: 'bottom' };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** @deprecated Use switchTool('line') instead */
|
|
295
|
+
switchToLineTool() {
|
|
296
|
+
this.switchTool('line');
|
|
297
|
+
}
|
|
298
|
+
onEdgeClick(event, edge) {
|
|
299
|
+
if (this.activeTool() !== 'hand')
|
|
300
|
+
return;
|
|
301
|
+
event.stopPropagation();
|
|
302
|
+
this.selectEdge(edge.id);
|
|
303
|
+
this.edgeClick.emit(edge);
|
|
304
|
+
}
|
|
305
|
+
onEdgeDoubleClick(event, edge) {
|
|
306
|
+
if (this.activeTool() !== 'hand')
|
|
307
|
+
return;
|
|
308
|
+
event.stopPropagation();
|
|
309
|
+
this.selectEdge(edge.id);
|
|
310
|
+
this.edgeDoubleClick.emit(edge);
|
|
311
|
+
}
|
|
312
|
+
clearSelection() {
|
|
313
|
+
this.selection.set({ nodes: [], edges: [] });
|
|
314
|
+
this.selectionChange.emit(this.selection());
|
|
315
|
+
}
|
|
316
|
+
// Validation
|
|
317
|
+
validate() {
|
|
318
|
+
if (!this.config.validation) {
|
|
319
|
+
const result = { valid: true, errors: [] };
|
|
320
|
+
this.validationResult.set(result);
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
const errors = this.config.validation.validators.flatMap(rule => rule.validator(this.internalGraph(), this.config));
|
|
324
|
+
const result = {
|
|
325
|
+
valid: errors.filter(e => e.severity !== 'warning').length === 0,
|
|
326
|
+
errors
|
|
327
|
+
};
|
|
328
|
+
this.validationResult.set(result);
|
|
329
|
+
this.validationChange.emit(result);
|
|
330
|
+
return result;
|
|
331
|
+
}
|
|
332
|
+
// Layout
|
|
333
|
+
async applyLayout(direction = 'TB') {
|
|
334
|
+
const graph = this.internalGraph();
|
|
335
|
+
if (graph.nodes.length === 0)
|
|
336
|
+
return;
|
|
337
|
+
// Dynamic import to avoid compile-time module resolution issues
|
|
338
|
+
const dagreModule = await import('dagre');
|
|
339
|
+
const dagre = dagreModule.default ?? dagreModule;
|
|
340
|
+
const g = new dagre.graphlib.Graph();
|
|
341
|
+
g.setGraph({
|
|
342
|
+
rankdir: direction,
|
|
343
|
+
nodesep: 60,
|
|
344
|
+
ranksep: 80,
|
|
345
|
+
marginx: 40,
|
|
346
|
+
marginy: 40,
|
|
347
|
+
});
|
|
348
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
349
|
+
for (const node of graph.nodes) {
|
|
350
|
+
const size = this.getNodeSize(node);
|
|
351
|
+
g.setNode(node.id, { width: size.width, height: size.height });
|
|
352
|
+
}
|
|
353
|
+
for (const edge of graph.edges) {
|
|
354
|
+
g.setEdge(edge.source, edge.target);
|
|
355
|
+
}
|
|
356
|
+
dagre.layout(g);
|
|
357
|
+
const updatedNodes = graph.nodes.map(node => {
|
|
358
|
+
const dagreNode = g.node(node.id);
|
|
359
|
+
if (!dagreNode)
|
|
360
|
+
return node;
|
|
361
|
+
const size = this.getNodeSize(node);
|
|
362
|
+
return {
|
|
363
|
+
...node,
|
|
364
|
+
position: {
|
|
365
|
+
x: dagreNode.x - size.width / 2,
|
|
366
|
+
y: dagreNode.y - size.height / 2,
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
this.internalGraph.set({ ...graph, nodes: updatedNodes });
|
|
371
|
+
this.emitGraphChange();
|
|
372
|
+
setTimeout(() => this.fitToScreen());
|
|
373
|
+
}
|
|
374
|
+
fitToScreen(padding = 40) {
|
|
375
|
+
const nodes = this.internalGraph().nodes;
|
|
376
|
+
if (nodes.length === 0)
|
|
377
|
+
return;
|
|
378
|
+
// Get SVG element dimensions
|
|
379
|
+
const ref = this.canvasSvgRef();
|
|
380
|
+
const svgEl = ref?.nativeElement ?? ref ?? null;
|
|
381
|
+
if (!svgEl || typeof svgEl.getBoundingClientRect !== 'function')
|
|
382
|
+
return;
|
|
383
|
+
const rect = svgEl.getBoundingClientRect();
|
|
384
|
+
const viewW = rect.width;
|
|
385
|
+
const viewH = rect.height;
|
|
386
|
+
if (viewW === 0 || viewH === 0)
|
|
387
|
+
return;
|
|
388
|
+
// Calculate bounding box of all nodes
|
|
389
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
390
|
+
for (const node of nodes) {
|
|
391
|
+
const size = this.getNodeSize(node);
|
|
392
|
+
minX = Math.min(minX, node.position.x);
|
|
393
|
+
minY = Math.min(minY, node.position.y);
|
|
394
|
+
maxX = Math.max(maxX, node.position.x + size.width);
|
|
395
|
+
maxY = Math.max(maxY, node.position.y + size.height);
|
|
396
|
+
}
|
|
397
|
+
const contentW = maxX - minX;
|
|
398
|
+
const contentH = maxY - minY;
|
|
399
|
+
// Handle single node or all nodes stacked
|
|
400
|
+
if (contentW <= 0 && contentH <= 0) {
|
|
401
|
+
this.scale.set(1);
|
|
402
|
+
this.panX.set(viewW / 2 - (minX + 110) * 1);
|
|
403
|
+
this.panY.set(viewH / 2 - (minY + 50) * 1);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Calculate scale to fit content with padding (cap at 1 to avoid zooming in too much)
|
|
407
|
+
const zoomConfig = this.config.canvas?.zoom;
|
|
408
|
+
const minScale = zoomConfig?.min ?? 0.25;
|
|
409
|
+
const scaleX = contentW > 0 ? (viewW - padding * 2) / contentW : 1;
|
|
410
|
+
const scaleY = contentH > 0 ? (viewH - padding * 2) / contentH : 1;
|
|
411
|
+
const newScale = Math.max(minScale, Math.min(1, Math.min(scaleX, scaleY)));
|
|
412
|
+
// Center the content in the viewport
|
|
413
|
+
const centerX = (minX + maxX) / 2;
|
|
414
|
+
const centerY = (minY + maxY) / 2;
|
|
415
|
+
const newPanX = viewW / 2 - centerX * newScale;
|
|
416
|
+
const newPanY = viewH / 2 - centerY * newScale;
|
|
417
|
+
this.scale.set(newScale);
|
|
418
|
+
this.panX.set(newPanX);
|
|
419
|
+
this.panY.set(newPanY);
|
|
420
|
+
}
|
|
421
|
+
zoomTo(level) {
|
|
422
|
+
const zoomConfig = this.config.canvas?.zoom;
|
|
423
|
+
const min = zoomConfig?.min ?? 0.25;
|
|
424
|
+
const max = zoomConfig?.max ?? 2.0;
|
|
425
|
+
this.scale.set(Math.max(min, Math.min(max, level)));
|
|
426
|
+
}
|
|
427
|
+
getSelection() {
|
|
428
|
+
return this.selection();
|
|
429
|
+
}
|
|
430
|
+
// Event handlers
|
|
431
|
+
onCanvasMouseDown(event) {
|
|
432
|
+
if (this.readonly)
|
|
433
|
+
return;
|
|
434
|
+
// Cancel pending edge on empty space click
|
|
435
|
+
if (this.pendingEdge) {
|
|
436
|
+
this.pendingEdge = null;
|
|
437
|
+
this.previewLine.set(null);
|
|
438
|
+
this.showAttachmentPoints.set(null);
|
|
439
|
+
this.hoveredPort = null;
|
|
440
|
+
this.clearSelection();
|
|
441
|
+
}
|
|
442
|
+
const target = event.target;
|
|
443
|
+
const isNode = !!target.closest('.graph-node');
|
|
444
|
+
const isEdgeEndpoint = target.classList.contains('edge-endpoint');
|
|
445
|
+
const isAttachmentPoint = target.classList.contains('attachment-point');
|
|
446
|
+
const isHitArea = target.classList.contains('edge-hit-area');
|
|
447
|
+
const isInteractive = isNode || isEdgeEndpoint || isAttachmentPoint || isHitArea;
|
|
448
|
+
if (!isInteractive) {
|
|
449
|
+
this.isPanning = true;
|
|
450
|
+
this.lastMousePos = { x: event.clientX, y: event.clientY };
|
|
451
|
+
this.clearSelection();
|
|
452
|
+
event.preventDefault();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
onCanvasMouseMove(event) {
|
|
456
|
+
if (this.isPanning) {
|
|
457
|
+
const dx = event.clientX - this.lastMousePos.x;
|
|
458
|
+
const dy = event.clientY - this.lastMousePos.y;
|
|
459
|
+
this.panX.set(this.panX() + dx);
|
|
460
|
+
this.panY.set(this.panY() + dy);
|
|
461
|
+
this.lastMousePos = { x: event.clientX, y: event.clientY };
|
|
462
|
+
}
|
|
463
|
+
else if (this.draggedNode) {
|
|
464
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
465
|
+
const mouseX = (event.clientX - rect.left - this.panX()) / this.scale();
|
|
466
|
+
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
|
+
const graph = this.internalGraph();
|
|
482
|
+
const nodeIndex = graph.nodes.findIndex(n => n.id === this.draggedNode.id);
|
|
483
|
+
if (nodeIndex !== -1) {
|
|
484
|
+
const updatedNodes = [...graph.nodes];
|
|
485
|
+
updatedNodes[nodeIndex] = { ...updatedNodes[nodeIndex], position: { x, y } };
|
|
486
|
+
// Recalculate ports for all edges connected to this node
|
|
487
|
+
const draggedId = this.draggedNode.id;
|
|
488
|
+
const updatedEdges = graph.edges.map(edge => {
|
|
489
|
+
if (edge.source !== draggedId && edge.target !== draggedId)
|
|
490
|
+
return edge;
|
|
491
|
+
const sourceNode = updatedNodes.find(n => n.id === edge.source);
|
|
492
|
+
const targetNode = updatedNodes.find(n => n.id === edge.target);
|
|
493
|
+
if (!sourceNode || !targetNode)
|
|
494
|
+
return edge;
|
|
495
|
+
const newSourcePort = this.findClosestPortForEdge(sourceNode, targetNode, 'source');
|
|
496
|
+
const newTargetPort = this.findClosestPortForEdge(targetNode, sourceNode, 'target');
|
|
497
|
+
if (edge.sourcePort === newSourcePort && edge.targetPort === newTargetPort)
|
|
498
|
+
return edge;
|
|
499
|
+
return { ...edge, sourcePort: newSourcePort, targetPort: newTargetPort };
|
|
500
|
+
});
|
|
501
|
+
this.internalGraph.set({ ...graph, nodes: updatedNodes, edges: updatedEdges });
|
|
502
|
+
this.emitGraphChange();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
else if (this.draggedEdge) {
|
|
506
|
+
// Edge reconnection - find hovered node and closest port
|
|
507
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
508
|
+
const mouseX = (event.clientX - rect.left - this.panX()) / this.scale();
|
|
509
|
+
const mouseY = (event.clientY - rect.top - this.panY()) / this.scale();
|
|
510
|
+
// Find node under cursor
|
|
511
|
+
const nodeId = this.findNodeAtPosition({ x: mouseX, y: mouseY });
|
|
512
|
+
if (nodeId) {
|
|
513
|
+
// Show attachment points for this node
|
|
514
|
+
this.showAttachmentPoints.set(nodeId);
|
|
515
|
+
// Find closest port
|
|
516
|
+
const closestPort = this.findClosestPort(nodeId, { x: mouseX, y: mouseY });
|
|
517
|
+
// Highlight port if within snap distance (40px)
|
|
518
|
+
if (closestPort && closestPort.distance < 40) {
|
|
519
|
+
this.hoveredPort = { nodeId, port: closestPort.port };
|
|
520
|
+
this.hoveredNodeId = nodeId;
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
this.hoveredPort = null;
|
|
524
|
+
this.hoveredNodeId = null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
// No node nearby - hide attachment points
|
|
529
|
+
this.showAttachmentPoints.set(null);
|
|
530
|
+
this.hoveredPort = null;
|
|
531
|
+
this.hoveredNodeId = null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
else if (this.pendingEdge && this.activeTool() === 'line') {
|
|
535
|
+
// Line tool pending state - show rubber-band preview + attachment points on hovered node
|
|
536
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
537
|
+
const mouseX = (event.clientX - rect.left - this.panX()) / this.scale();
|
|
538
|
+
const mouseY = (event.clientY - rect.top - this.panY()) / this.scale();
|
|
539
|
+
// Get source port position
|
|
540
|
+
const sourceNode = this.internalGraph().nodes.find(n => n.id === this.pendingEdge.sourceId);
|
|
541
|
+
if (sourceNode) {
|
|
542
|
+
const sourcePoint = this.getPortWorldPosition(sourceNode, this.pendingEdge.sourcePort);
|
|
543
|
+
// Check if cursor is near a node - snap to its closest port
|
|
544
|
+
const hoveredNodeId = this.findNodeAtPosition({ x: mouseX, y: mouseY });
|
|
545
|
+
let targetPoint = { x: mouseX, y: mouseY };
|
|
546
|
+
if (hoveredNodeId && hoveredNodeId !== this.pendingEdge.sourceId) {
|
|
547
|
+
// Show attachment points on hovered node
|
|
548
|
+
this.showAttachmentPoints.set(hoveredNodeId);
|
|
549
|
+
// Find and highlight closest port
|
|
550
|
+
const closestPort = this.findClosestPort(hoveredNodeId, { x: mouseX, y: mouseY });
|
|
551
|
+
if (closestPort && closestPort.distance < 40) {
|
|
552
|
+
this.hoveredPort = { nodeId: hoveredNodeId, port: closestPort.port };
|
|
553
|
+
// Snap preview line to port
|
|
554
|
+
const hoveredNode = this.internalGraph().nodes.find(n => n.id === hoveredNodeId);
|
|
555
|
+
if (hoveredNode) {
|
|
556
|
+
targetPoint = this.getPortWorldPosition(hoveredNode, closestPort.port);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
this.hoveredPort = null;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
// Not over a valid target node - hide attachment points
|
|
565
|
+
this.showAttachmentPoints.set(null);
|
|
566
|
+
this.hoveredPort = null;
|
|
567
|
+
}
|
|
568
|
+
this.previewLine.set({ source: sourcePoint, target: targetPoint });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
onCanvasMouseUp(_event) {
|
|
573
|
+
// Handle edge reconnection with port snapping
|
|
574
|
+
if (this.draggedEdge && this.hoveredNodeId && this.hoveredPort) {
|
|
575
|
+
const graph = this.internalGraph();
|
|
576
|
+
const edgeIndex = graph.edges.findIndex(e => e.id === this.draggedEdge.edge.id);
|
|
577
|
+
if (edgeIndex !== -1) {
|
|
578
|
+
const updatedEdges = [...graph.edges];
|
|
579
|
+
const updatedEdge = { ...updatedEdges[edgeIndex] };
|
|
580
|
+
// Update node connection and store port information (non-null: guarded by if condition)
|
|
581
|
+
if (this.draggedEdge.endpoint === 'source') {
|
|
582
|
+
updatedEdge.source = this.hoveredNodeId;
|
|
583
|
+
updatedEdge.sourcePort = this.hoveredPort.port;
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
updatedEdge.target = this.hoveredNodeId;
|
|
587
|
+
updatedEdge.targetPort = this.hoveredPort.port;
|
|
588
|
+
}
|
|
589
|
+
updatedEdges[edgeIndex] = updatedEdge;
|
|
590
|
+
this.internalGraph.set({ ...graph, edges: updatedEdges });
|
|
591
|
+
this.emitGraphChange();
|
|
592
|
+
this.edgeUpdated.emit(updatedEdge);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
this.isPanning = false;
|
|
596
|
+
this.draggedNode = null;
|
|
597
|
+
this.draggedEdge = null;
|
|
598
|
+
this.hoveredNodeId = null;
|
|
599
|
+
this.hoveredPort = null;
|
|
600
|
+
this.showAttachmentPoints.set(null);
|
|
601
|
+
}
|
|
602
|
+
onNodeMouseDown(event, node) {
|
|
603
|
+
if (this.readonly)
|
|
604
|
+
return;
|
|
605
|
+
event.stopPropagation(); // Always prevent canvas from seeing node mousedowns
|
|
606
|
+
if (this.activeTool() !== 'hand')
|
|
607
|
+
return;
|
|
608
|
+
this.draggedNode = node;
|
|
609
|
+
// Calculate offset between mouse position and node origin to prevent jump
|
|
610
|
+
const svg = event.target.closest('svg');
|
|
611
|
+
const rect = svg.getBoundingClientRect();
|
|
612
|
+
const mouseX = (event.clientX - rect.left - this.panX()) / this.scale();
|
|
613
|
+
const mouseY = (event.clientY - rect.top - this.panY()) / this.scale();
|
|
614
|
+
this.dragOffset = {
|
|
615
|
+
x: mouseX - node.position.x,
|
|
616
|
+
y: mouseY - node.position.y
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
onNodeClick(event, node) {
|
|
620
|
+
if (this.activeTool() === 'line') {
|
|
621
|
+
event.stopPropagation();
|
|
622
|
+
if (!this.pendingEdge) {
|
|
623
|
+
// First click - start edge from this node
|
|
624
|
+
// Pick initial port based on geometry (will be recalculated on second click)
|
|
625
|
+
this.pendingEdge = { sourceId: node.id, sourcePort: 'bottom' };
|
|
626
|
+
this.selectNode(node.id);
|
|
627
|
+
}
|
|
628
|
+
else if (this.pendingEdge.sourceId !== node.id) {
|
|
629
|
+
// Second click on different node - complete the edge
|
|
630
|
+
const sourceNode = this.internalGraph().nodes.find(n => n.id === this.pendingEdge.sourceId);
|
|
631
|
+
if (sourceNode) {
|
|
632
|
+
const sourcePort = this.findClosestPortForEdge(sourceNode, node, 'source');
|
|
633
|
+
const targetPort = this.findClosestPortForEdge(node, sourceNode, 'target');
|
|
634
|
+
const newEdge = {
|
|
635
|
+
id: `edge_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
636
|
+
source: this.pendingEdge.sourceId,
|
|
637
|
+
target: node.id,
|
|
638
|
+
sourcePort,
|
|
639
|
+
targetPort
|
|
640
|
+
};
|
|
641
|
+
const graph = this.internalGraph();
|
|
642
|
+
this.internalGraph.set({
|
|
643
|
+
...graph,
|
|
644
|
+
edges: [...graph.edges, newEdge]
|
|
645
|
+
});
|
|
646
|
+
this.emitGraphChange();
|
|
647
|
+
this.edgeAdded.emit(newEdge);
|
|
648
|
+
}
|
|
649
|
+
this.pendingEdge = null;
|
|
650
|
+
this.previewLine.set(null);
|
|
651
|
+
this.showAttachmentPoints.set(null);
|
|
652
|
+
this.hoveredPort = null;
|
|
653
|
+
this.clearSelection();
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
// Clicked same node - cancel
|
|
657
|
+
this.pendingEdge = null;
|
|
658
|
+
this.previewLine.set(null);
|
|
659
|
+
this.showAttachmentPoints.set(null);
|
|
660
|
+
this.hoveredPort = null;
|
|
661
|
+
this.clearSelection();
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
// Hand tool - normal select
|
|
666
|
+
this.selectNode(node.id);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
onAttachmentPointClick(event, node, port) {
|
|
670
|
+
event.stopPropagation();
|
|
671
|
+
if (this.readonly)
|
|
672
|
+
return;
|
|
673
|
+
if (this.activeTool() === 'line') {
|
|
674
|
+
if (!this.pendingEdge) {
|
|
675
|
+
// First click on attachment point - start edge from this specific port
|
|
676
|
+
this.pendingEdge = { sourceId: node.id, sourcePort: port };
|
|
677
|
+
this.selectNode(node.id);
|
|
678
|
+
}
|
|
679
|
+
else if (this.pendingEdge.sourceId !== node.id) {
|
|
680
|
+
// Second click - complete edge to this specific port
|
|
681
|
+
const sourceNode = this.internalGraph().nodes.find(n => n.id === this.pendingEdge.sourceId);
|
|
682
|
+
if (sourceNode) {
|
|
683
|
+
const sourcePort = this.findClosestPortForEdge(sourceNode, node, 'source');
|
|
684
|
+
const newEdge = {
|
|
685
|
+
id: `edge_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
686
|
+
source: this.pendingEdge.sourceId,
|
|
687
|
+
target: node.id,
|
|
688
|
+
sourcePort,
|
|
689
|
+
targetPort: port
|
|
690
|
+
};
|
|
691
|
+
const graph = this.internalGraph();
|
|
692
|
+
this.internalGraph.set({
|
|
693
|
+
...graph,
|
|
694
|
+
edges: [...graph.edges, newEdge]
|
|
695
|
+
});
|
|
696
|
+
this.emitGraphChange();
|
|
697
|
+
this.edgeAdded.emit(newEdge);
|
|
698
|
+
}
|
|
699
|
+
this.pendingEdge = null;
|
|
700
|
+
this.previewLine.set(null);
|
|
701
|
+
this.showAttachmentPoints.set(null);
|
|
702
|
+
this.hoveredPort = null;
|
|
703
|
+
this.clearSelection();
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
// Clicked same node - cancel
|
|
707
|
+
this.pendingEdge = null;
|
|
708
|
+
this.previewLine.set(null);
|
|
709
|
+
this.showAttachmentPoints.set(null);
|
|
710
|
+
this.hoveredPort = null;
|
|
711
|
+
this.clearSelection();
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
onEdgeEndpointMouseDown(event, edge, endpoint) {
|
|
716
|
+
if (this.readonly)
|
|
717
|
+
return;
|
|
718
|
+
event.stopPropagation();
|
|
719
|
+
this.draggedEdge = { edge, endpoint };
|
|
720
|
+
}
|
|
721
|
+
onWheel(event) {
|
|
722
|
+
const zoomConfig = this.config.canvas?.zoom;
|
|
723
|
+
if (!zoomConfig?.wheelEnabled)
|
|
724
|
+
return;
|
|
725
|
+
event.preventDefault();
|
|
726
|
+
const delta = -event.deltaY;
|
|
727
|
+
const step = zoomConfig.step ?? 0.1;
|
|
728
|
+
const newScale = Math.max(zoomConfig.min ?? 0.25, Math.min(zoomConfig.max ?? 2.0, this.scale() + (delta > 0 ? step : -step)));
|
|
729
|
+
this.scale.set(newScale);
|
|
730
|
+
}
|
|
731
|
+
onContextMenu(event) {
|
|
732
|
+
event.preventDefault();
|
|
733
|
+
// TODO: Show context menu
|
|
734
|
+
}
|
|
735
|
+
// Helper methods
|
|
736
|
+
emitGraphChange() {
|
|
737
|
+
this.graphChange.emit(this.internalGraph());
|
|
738
|
+
if (this.config.validation?.validateOnChange) {
|
|
739
|
+
this.validate();
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
generateId() {
|
|
743
|
+
return `node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
744
|
+
}
|
|
745
|
+
recalculateEdgePorts(nodeId) {
|
|
746
|
+
const graph = this.internalGraph();
|
|
747
|
+
let changed = false;
|
|
748
|
+
const updatedEdges = graph.edges.map(edge => {
|
|
749
|
+
if (edge.source !== nodeId && edge.target !== nodeId)
|
|
750
|
+
return edge;
|
|
751
|
+
const sourceNode = graph.nodes.find(n => n.id === edge.source);
|
|
752
|
+
const targetNode = graph.nodes.find(n => n.id === edge.target);
|
|
753
|
+
if (!sourceNode || !targetNode)
|
|
754
|
+
return edge;
|
|
755
|
+
const newSourcePort = this.findClosestPortForEdge(sourceNode, targetNode, 'source');
|
|
756
|
+
const newTargetPort = this.findClosestPortForEdge(targetNode, sourceNode, 'target');
|
|
757
|
+
if (edge.sourcePort === newSourcePort && edge.targetPort === newTargetPort)
|
|
758
|
+
return edge;
|
|
759
|
+
changed = true;
|
|
760
|
+
return { ...edge, sourcePort: newSourcePort, targetPort: newTargetPort };
|
|
761
|
+
});
|
|
762
|
+
if (changed) {
|
|
763
|
+
this.internalGraph.set({ ...graph, edges: updatedEdges });
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
getNodeSize(node) {
|
|
767
|
+
const nodeConfig = this.config.nodes.types.find(t => t.type === node.type);
|
|
768
|
+
return nodeConfig?.size || this.config.nodes.defaultSize || { width: 220, height: 100 };
|
|
769
|
+
}
|
|
770
|
+
getEdgePath(edge) {
|
|
771
|
+
const sourceNode = this.internalGraph().nodes.find(n => n.id === edge.source);
|
|
772
|
+
const targetNode = this.internalGraph().nodes.find(n => n.id === edge.target);
|
|
773
|
+
if (!sourceNode || !targetNode)
|
|
774
|
+
return '';
|
|
775
|
+
// Get port positions from edge or calculate closest
|
|
776
|
+
const sourcePort = edge.sourcePort || this.findClosestPortForEdge(sourceNode, targetNode, 'source');
|
|
777
|
+
const targetPort = edge.targetPort || this.findClosestPortForEdge(targetNode, sourceNode, 'target');
|
|
778
|
+
const sourcePoint = this.getPortWorldPosition(sourceNode, sourcePort);
|
|
779
|
+
const targetPoint = this.getPortWorldPosition(targetNode, targetPort);
|
|
780
|
+
// Simple straight line
|
|
781
|
+
return `M ${sourcePoint.x},${sourcePoint.y} L ${targetPoint.x},${targetPoint.y}`;
|
|
782
|
+
}
|
|
783
|
+
getEdgeColor(edge) {
|
|
784
|
+
return edge.metadata?.style?.stroke || this.config.edges.style?.stroke || '#94a3b8';
|
|
785
|
+
}
|
|
786
|
+
getEdgeMarkerEnd(edge) {
|
|
787
|
+
const dir = edge.direction || 'forward';
|
|
788
|
+
const selected = this.selection().edges.includes(edge.id);
|
|
789
|
+
if (dir === 'forward' || dir === 'bidirectional') {
|
|
790
|
+
return selected ? 'url(#arrow-end-selected)' : 'url(#arrow-end)';
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
getEdgeMarkerStart(edge) {
|
|
795
|
+
const dir = edge.direction || 'forward';
|
|
796
|
+
const selected = this.selection().edges.includes(edge.id);
|
|
797
|
+
if (dir === 'backward' || dir === 'bidirectional') {
|
|
798
|
+
return selected ? 'url(#arrow-start-selected)' : 'url(#arrow-start)';
|
|
799
|
+
}
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
setEdgeDirection(direction) {
|
|
803
|
+
const sel = this.selection();
|
|
804
|
+
if (sel.edges.length !== 1)
|
|
805
|
+
return;
|
|
806
|
+
const graph = this.internalGraph();
|
|
807
|
+
const edgeIndex = graph.edges.findIndex(e => e.id === sel.edges[0]);
|
|
808
|
+
if (edgeIndex === -1)
|
|
809
|
+
return;
|
|
810
|
+
const updatedEdges = [...graph.edges];
|
|
811
|
+
updatedEdges[edgeIndex] = { ...updatedEdges[edgeIndex], direction };
|
|
812
|
+
this.internalGraph.set({ ...graph, edges: updatedEdges });
|
|
813
|
+
this.emitGraphChange();
|
|
814
|
+
this.edgeUpdated.emit(updatedEdges[edgeIndex]);
|
|
815
|
+
}
|
|
816
|
+
getEdgeSourcePoint(edge) {
|
|
817
|
+
const sourceNode = this.internalGraph().nodes.find(n => n.id === edge.source);
|
|
818
|
+
const targetNode = this.internalGraph().nodes.find(n => n.id === edge.target);
|
|
819
|
+
if (!sourceNode || !targetNode)
|
|
820
|
+
return { x: 0, y: 0 };
|
|
821
|
+
const sourcePort = edge.sourcePort || this.findClosestPortForEdge(sourceNode, targetNode, 'source');
|
|
822
|
+
return this.getPortWorldPosition(sourceNode, sourcePort);
|
|
823
|
+
}
|
|
824
|
+
getEdgeTargetPoint(edge) {
|
|
825
|
+
const sourceNode = this.internalGraph().nodes.find(n => n.id === edge.source);
|
|
826
|
+
const targetNode = this.internalGraph().nodes.find(n => n.id === edge.target);
|
|
827
|
+
if (!sourceNode || !targetNode)
|
|
828
|
+
return { x: 0, y: 0 };
|
|
829
|
+
const targetPort = edge.targetPort || this.findClosestPortForEdge(targetNode, sourceNode, 'target');
|
|
830
|
+
return this.getPortWorldPosition(targetNode, targetPort);
|
|
831
|
+
}
|
|
832
|
+
getNodeTypeIcon(node) {
|
|
833
|
+
const nodeConfig = this.config.nodes.types.find(t => t.type === node.type);
|
|
834
|
+
return nodeConfig?.icon || '●';
|
|
835
|
+
}
|
|
836
|
+
findNodeAtPosition(pos) {
|
|
837
|
+
for (const node of this.internalGraph().nodes) {
|
|
838
|
+
const size = this.getNodeSize(node);
|
|
839
|
+
if (pos.x >= node.position.x &&
|
|
840
|
+
pos.x <= node.position.x + size.width &&
|
|
841
|
+
pos.y >= node.position.y &&
|
|
842
|
+
pos.y <= node.position.y + size.height) {
|
|
843
|
+
return node.id;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
getNodePorts(node) {
|
|
849
|
+
const size = this.getNodeSize(node);
|
|
850
|
+
return [
|
|
851
|
+
{ position: 'top', x: size.width / 2, y: 0 },
|
|
852
|
+
{ position: 'bottom', x: size.width / 2, y: size.height },
|
|
853
|
+
{ position: 'left', x: 0, y: size.height / 2 },
|
|
854
|
+
{ position: 'right', x: size.width, y: size.height / 2 }
|
|
855
|
+
];
|
|
856
|
+
}
|
|
857
|
+
findClosestPort(nodeId, worldPos) {
|
|
858
|
+
const node = this.internalGraph().nodes.find(n => n.id === nodeId);
|
|
859
|
+
if (!node)
|
|
860
|
+
return null;
|
|
861
|
+
const ports = this.getNodePorts(node);
|
|
862
|
+
let closestPort = null;
|
|
863
|
+
let minDistance = Infinity;
|
|
864
|
+
for (const port of ports) {
|
|
865
|
+
const portWorldX = node.position.x + port.x;
|
|
866
|
+
const portWorldY = node.position.y + port.y;
|
|
867
|
+
const dx = worldPos.x - portWorldX;
|
|
868
|
+
const dy = worldPos.y - portWorldY;
|
|
869
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
870
|
+
if (distance < minDistance) {
|
|
871
|
+
minDistance = distance;
|
|
872
|
+
closestPort = port;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return closestPort ? { port: closestPort.position, distance: minDistance } : null;
|
|
876
|
+
}
|
|
877
|
+
getPortWorldPosition(node, port) {
|
|
878
|
+
const size = this.getNodeSize(node);
|
|
879
|
+
const portOffsets = {
|
|
880
|
+
top: { x: size.width / 2, y: 0 },
|
|
881
|
+
bottom: { x: size.width / 2, y: size.height },
|
|
882
|
+
left: { x: 0, y: size.height / 2 },
|
|
883
|
+
right: { x: size.width, y: size.height / 2 }
|
|
884
|
+
};
|
|
885
|
+
const offset = portOffsets[port];
|
|
886
|
+
return {
|
|
887
|
+
x: node.position.x + offset.x,
|
|
888
|
+
y: node.position.y + offset.y
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
findClosestPortForEdge(node, otherNode, endpoint) {
|
|
892
|
+
const size = this.getNodeSize(node);
|
|
893
|
+
const nodeCenter = {
|
|
894
|
+
x: node.position.x + size.width / 2,
|
|
895
|
+
y: node.position.y + size.height / 2
|
|
896
|
+
};
|
|
897
|
+
const otherSize = this.getNodeSize(otherNode);
|
|
898
|
+
const otherCenter = {
|
|
899
|
+
x: otherNode.position.x + otherSize.width / 2,
|
|
900
|
+
y: otherNode.position.y + otherSize.height / 2
|
|
901
|
+
};
|
|
902
|
+
const dx = otherCenter.x - nodeCenter.x;
|
|
903
|
+
const dy = otherCenter.y - nodeCenter.y;
|
|
904
|
+
// Determine which port is closest based on relative position
|
|
905
|
+
const absDx = Math.abs(dx);
|
|
906
|
+
const absDy = Math.abs(dy);
|
|
907
|
+
if (absDx > absDy) {
|
|
908
|
+
// Horizontal connection
|
|
909
|
+
return dx > 0 ? 'right' : 'left';
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
// Vertical connection
|
|
913
|
+
return dy > 0 ? 'bottom' : 'top';
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
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 });
|
|
1212
|
+
}
|
|
1213
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphEditorComponent, decorators: [{
|
|
1214
|
+
type: Component,
|
|
1215
|
+
args: [{ selector: 'graph-editor', standalone: true, imports: [], host: {
|
|
1216
|
+
'tabindex': '0',
|
|
1217
|
+
'style': 'outline: none;',
|
|
1218
|
+
'(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"] }]
|
|
1514
|
+
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
1515
|
+
type: Input,
|
|
1516
|
+
args: [{ required: true }]
|
|
1517
|
+
}], graph: [{
|
|
1518
|
+
type: Input
|
|
1519
|
+
}], readonly: [{
|
|
1520
|
+
type: Input
|
|
1521
|
+
}], visualizationMode: [{
|
|
1522
|
+
type: Input
|
|
1523
|
+
}], overlayData: [{
|
|
1524
|
+
type: Input
|
|
1525
|
+
}], graphChange: [{
|
|
1526
|
+
type: Output
|
|
1527
|
+
}], nodeAdded: [{
|
|
1528
|
+
type: Output
|
|
1529
|
+
}], nodeUpdated: [{
|
|
1530
|
+
type: Output
|
|
1531
|
+
}], nodeRemoved: [{
|
|
1532
|
+
type: Output
|
|
1533
|
+
}], edgeAdded: [{
|
|
1534
|
+
type: Output
|
|
1535
|
+
}], edgeUpdated: [{
|
|
1536
|
+
type: Output
|
|
1537
|
+
}], edgeRemoved: [{
|
|
1538
|
+
type: Output
|
|
1539
|
+
}], selectionChange: [{
|
|
1540
|
+
type: Output
|
|
1541
|
+
}], validationChange: [{
|
|
1542
|
+
type: Output
|
|
1543
|
+
}], nodeClick: [{
|
|
1544
|
+
type: Output
|
|
1545
|
+
}], nodeDoubleClick: [{
|
|
1546
|
+
type: Output
|
|
1547
|
+
}], edgeClick: [{
|
|
1548
|
+
type: Output
|
|
1549
|
+
}], edgeDoubleClick: [{
|
|
1550
|
+
type: Output
|
|
1551
|
+
}], canvasClick: [{
|
|
1552
|
+
type: Output
|
|
1553
|
+
}], contextMenu: [{
|
|
1554
|
+
type: Output
|
|
1555
|
+
}] } });
|
|
1556
|
+
|
|
1557
|
+
// Main component
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* Generated bundle index. Do not edit.
|
|
1561
|
+
*/
|
|
1562
|
+
|
|
1563
|
+
export { GraphEditorComponent };
|
|
1564
|
+
//# sourceMappingURL=utisha-graph-editor.mjs.map
|