ngx-workflow 0.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.
- package/LICENSE +21 -0
- package/README.md +291 -0
- package/fesm2022/ngx-workflow.mjs +3016 -0
- package/fesm2022/ngx-workflow.mjs.map +1 -0
- package/package.json +47 -0
- package/types/ngx-workflow.d.ts +482 -0
- package/types/ngx-workflow.d.ts.map +1 -0
|
@@ -0,0 +1,3016 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, EventEmitter, Output, Input, ChangeDetectionStrategy, Component, signal, computed, Injectable, effect, inject, HostListener, ViewChild, Optional, Inject, NgModule } from '@angular/core';
|
|
3
|
+
import * as i2 from '@angular/common';
|
|
4
|
+
import { CommonModule } from '@angular/common';
|
|
5
|
+
import { toObservable } from '@angular/core/rxjs-interop';
|
|
6
|
+
import { Subject, animationFrameScheduler, Subscription } from 'rxjs';
|
|
7
|
+
import { v4 } from 'uuid';
|
|
8
|
+
import { throttleTime } from 'rxjs/operators';
|
|
9
|
+
import * as i2$1 from '@angular/forms';
|
|
10
|
+
import { FormsModule } from '@angular/forms';
|
|
11
|
+
import ELK from 'elkjs/lib/elk.bundled';
|
|
12
|
+
|
|
13
|
+
const NGX_WORKFLOW_NODE_TYPES = new InjectionToken('NGX_WORKFLOW_NODE_TYPES');
|
|
14
|
+
|
|
15
|
+
const NGX_WORKFLOW_EDGE_TYPES = new InjectionToken('NGX_WORKFLOW_EDGE_TYPES');
|
|
16
|
+
|
|
17
|
+
function getStraightPath(source, target) {
|
|
18
|
+
return `M ${source.x},${source.y} L ${target.x},${target.y}`;
|
|
19
|
+
}
|
|
20
|
+
function getBezierPath(source, target) {
|
|
21
|
+
const midX = (source.x + target.x) / 2;
|
|
22
|
+
return `M ${source.x},${source.y} C ${midX},${source.y} ${midX},${target.y} ${target.x},${target.y}`;
|
|
23
|
+
}
|
|
24
|
+
function getStepPath(source, target) {
|
|
25
|
+
const midY = (source.y + target.y) / 2;
|
|
26
|
+
return `M ${source.x},${source.y} L ${source.x},${midY} L ${target.x},${midY} L ${target.x},${target.y}`;
|
|
27
|
+
}
|
|
28
|
+
function getSelfLoopPath(source, handle = 'top', offset = 30) {
|
|
29
|
+
const { x, y } = source;
|
|
30
|
+
switch (handle) {
|
|
31
|
+
case 'top':
|
|
32
|
+
return `M ${x},${y} C ${x - offset},${y - offset * 2} ${x + offset},${y - offset * 2} ${x},${y}`;
|
|
33
|
+
case 'right':
|
|
34
|
+
return `M ${x},${y} C ${x + offset * 2},${y - offset} ${x + offset * 2},${y + offset} ${x},${y}`;
|
|
35
|
+
case 'bottom':
|
|
36
|
+
return `M ${x},${y} C ${x + offset},${y + offset * 2} ${x - offset},${y + offset * 2} ${x},${y}`;
|
|
37
|
+
case 'left':
|
|
38
|
+
return `M ${x},${y} C ${x - offset * 2},${y + offset} ${x - offset * 2},${y - offset} ${x},${y}`;
|
|
39
|
+
default:
|
|
40
|
+
return `M ${x},${y} C ${x - offset},${y - offset * 2} ${x + offset},${y - offset * 2} ${x},${y}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function getSmartEdgePath(path) {
|
|
44
|
+
if (path.length === 0)
|
|
45
|
+
return '';
|
|
46
|
+
let d = `M ${path[0].x},${path[0].y}`;
|
|
47
|
+
for (let i = 1; i < path.length; i++) {
|
|
48
|
+
d += ` L ${path[i].x},${path[i].y}`;
|
|
49
|
+
}
|
|
50
|
+
return d;
|
|
51
|
+
}
|
|
52
|
+
function getPolylineMidpoint(points) {
|
|
53
|
+
if (points.length < 2)
|
|
54
|
+
return points[0] || { x: 0, y: 0 };
|
|
55
|
+
// Calculate total length
|
|
56
|
+
let totalLength = 0;
|
|
57
|
+
const segments = [];
|
|
58
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
59
|
+
const start = points[i];
|
|
60
|
+
const end = points[i + 1];
|
|
61
|
+
const length = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
|
|
62
|
+
segments.push({ length, start, end });
|
|
63
|
+
totalLength += length;
|
|
64
|
+
}
|
|
65
|
+
// Find midpoint
|
|
66
|
+
let remainingLength = totalLength / 2;
|
|
67
|
+
for (const segment of segments) {
|
|
68
|
+
if (remainingLength <= segment.length) {
|
|
69
|
+
// Midpoint is on this segment
|
|
70
|
+
const ratio = remainingLength / segment.length;
|
|
71
|
+
return {
|
|
72
|
+
x: segment.start.x + (segment.end.x - segment.start.x) * ratio,
|
|
73
|
+
y: segment.start.y + (segment.end.y - segment.start.y) * ratio
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
remainingLength -= segment.length;
|
|
77
|
+
}
|
|
78
|
+
return points[points.length - 1];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class MinHeap {
|
|
82
|
+
heap = [];
|
|
83
|
+
push(node) {
|
|
84
|
+
this.heap.push(node);
|
|
85
|
+
this.bubbleUp(this.heap.length - 1);
|
|
86
|
+
}
|
|
87
|
+
pop() {
|
|
88
|
+
if (this.heap.length === 0)
|
|
89
|
+
return undefined;
|
|
90
|
+
const top = this.heap[0];
|
|
91
|
+
const bottom = this.heap.pop();
|
|
92
|
+
if (this.heap.length > 0 && bottom) {
|
|
93
|
+
this.heap[0] = bottom;
|
|
94
|
+
this.sinkDown(0);
|
|
95
|
+
}
|
|
96
|
+
return top;
|
|
97
|
+
}
|
|
98
|
+
size() {
|
|
99
|
+
return this.heap.length;
|
|
100
|
+
}
|
|
101
|
+
bubbleUp(index) {
|
|
102
|
+
const element = this.heap[index];
|
|
103
|
+
while (index > 0) {
|
|
104
|
+
const parentIndex = Math.floor((index - 1) / 2);
|
|
105
|
+
const parent = this.heap[parentIndex];
|
|
106
|
+
if (element.f >= parent.f)
|
|
107
|
+
break;
|
|
108
|
+
this.heap[parentIndex] = element;
|
|
109
|
+
this.heap[index] = parent;
|
|
110
|
+
index = parentIndex;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
sinkDown(index) {
|
|
114
|
+
const length = this.heap.length;
|
|
115
|
+
const element = this.heap[index];
|
|
116
|
+
while (true) {
|
|
117
|
+
const leftChildIndex = 2 * index + 1;
|
|
118
|
+
const rightChildIndex = 2 * index + 2;
|
|
119
|
+
let leftChild, rightChild;
|
|
120
|
+
let swap = null;
|
|
121
|
+
if (leftChildIndex < length) {
|
|
122
|
+
leftChild = this.heap[leftChildIndex];
|
|
123
|
+
if (leftChild.f < element.f) {
|
|
124
|
+
swap = leftChildIndex;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (rightChildIndex < length) {
|
|
128
|
+
rightChild = this.heap[rightChildIndex];
|
|
129
|
+
if ((swap === null && rightChild.f < element.f) ||
|
|
130
|
+
(swap !== null && rightChild.f < leftChild.f)) {
|
|
131
|
+
swap = rightChildIndex;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (swap === null)
|
|
135
|
+
break;
|
|
136
|
+
this.heap[index] = this.heap[swap];
|
|
137
|
+
this.heap[swap] = element;
|
|
138
|
+
index = swap;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
rescoreElement(node) {
|
|
142
|
+
const index = this.heap.indexOf(node);
|
|
143
|
+
if (index !== -1) {
|
|
144
|
+
// Since f usually decreases in A*, we bubble up
|
|
145
|
+
this.bubbleUp(index);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
class PathFinder {
|
|
150
|
+
nodes;
|
|
151
|
+
graphWidth;
|
|
152
|
+
graphHeight;
|
|
153
|
+
gridSize = 20; // Size of each grid cell
|
|
154
|
+
grid = [];
|
|
155
|
+
width = 0;
|
|
156
|
+
height = 0;
|
|
157
|
+
bounds = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
158
|
+
constructor(nodes, graphWidth = 2000, graphHeight = 2000) {
|
|
159
|
+
this.nodes = nodes;
|
|
160
|
+
this.graphWidth = graphWidth;
|
|
161
|
+
this.graphHeight = graphHeight;
|
|
162
|
+
this.initializeGrid();
|
|
163
|
+
}
|
|
164
|
+
initializeGrid() {
|
|
165
|
+
// Determine bounds based on nodes, with some padding
|
|
166
|
+
if (this.nodes.length === 0)
|
|
167
|
+
return;
|
|
168
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
169
|
+
this.nodes.forEach(node => {
|
|
170
|
+
minX = Math.min(minX, node.x);
|
|
171
|
+
minY = Math.min(minY, node.y);
|
|
172
|
+
maxX = Math.max(maxX, node.x + node.width);
|
|
173
|
+
maxY = Math.max(maxY, node.y + node.height);
|
|
174
|
+
});
|
|
175
|
+
// Add padding
|
|
176
|
+
const padding = 100;
|
|
177
|
+
this.bounds = {
|
|
178
|
+
minX: Math.floor((minX - padding) / this.gridSize) * this.gridSize,
|
|
179
|
+
minY: Math.floor((minY - padding) / this.gridSize) * this.gridSize,
|
|
180
|
+
maxX: Math.ceil((maxX + padding) / this.gridSize) * this.gridSize,
|
|
181
|
+
maxY: Math.ceil((maxY + padding) / this.gridSize) * this.gridSize
|
|
182
|
+
};
|
|
183
|
+
this.width = Math.ceil((this.bounds.maxX - this.bounds.minX) / this.gridSize);
|
|
184
|
+
this.height = Math.ceil((this.bounds.maxY - this.bounds.minY) / this.gridSize);
|
|
185
|
+
// Initialize grid
|
|
186
|
+
this.grid = [];
|
|
187
|
+
for (let y = 0; y < this.height; y++) {
|
|
188
|
+
const row = [];
|
|
189
|
+
for (let x = 0; x < this.width; x++) {
|
|
190
|
+
row.push({
|
|
191
|
+
x,
|
|
192
|
+
y,
|
|
193
|
+
walkable: true,
|
|
194
|
+
g: 0,
|
|
195
|
+
h: 0,
|
|
196
|
+
f: 0,
|
|
197
|
+
parent: null,
|
|
198
|
+
opened: false,
|
|
199
|
+
closed: false
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
this.grid.push(row);
|
|
203
|
+
}
|
|
204
|
+
// Mark obstacles
|
|
205
|
+
this.nodes.forEach(node => {
|
|
206
|
+
const startX = Math.floor((node.x - this.bounds.minX) / this.gridSize);
|
|
207
|
+
const startY = Math.floor((node.y - this.bounds.minY) / this.gridSize);
|
|
208
|
+
const endX = Math.ceil((node.x + node.width - this.bounds.minX) / this.gridSize);
|
|
209
|
+
const endY = Math.ceil((node.y + node.height - this.bounds.minY) / this.gridSize);
|
|
210
|
+
for (let y = Math.max(0, startY); y < Math.min(this.height, endY); y++) {
|
|
211
|
+
for (let x = Math.max(0, startX); x < Math.min(this.width, endX); x++) {
|
|
212
|
+
this.grid[y][x].walkable = false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
findPath(start, end) {
|
|
218
|
+
const startGridX = Math.floor((start.x - this.bounds.minX) / this.gridSize);
|
|
219
|
+
const startGridY = Math.floor((start.y - this.bounds.minY) / this.gridSize);
|
|
220
|
+
const endGridX = Math.floor((end.x - this.bounds.minX) / this.gridSize);
|
|
221
|
+
const endGridY = Math.floor((end.y - this.bounds.minY) / this.gridSize);
|
|
222
|
+
// Check if start or end are out of bounds
|
|
223
|
+
if (!this.isValid(startGridX, startGridY) || !this.isValid(endGridX, endGridY)) {
|
|
224
|
+
return [start, end]; // Fallback to straight line
|
|
225
|
+
}
|
|
226
|
+
// Reset grid state for new search (optimization: use a search ID instead of clearing all?)
|
|
227
|
+
// For now, simple reset is safer, but O(W*H).
|
|
228
|
+
// Optimization: Only reset nodes we touch?
|
|
229
|
+
// Let's iterate over the grid to reset. This is slow if grid is huge.
|
|
230
|
+
// Better: Use a 'visitedToken' on GridNode and increment it per search.
|
|
231
|
+
// But I can't easily change GridNode interface without touching everything.
|
|
232
|
+
// Let's stick to resetting for now, but maybe optimize later.
|
|
233
|
+
// Actually, since we create a NEW PathFinder on drag stop, the grid is fresh.
|
|
234
|
+
// But findPath is called multiple times (once per edge).
|
|
235
|
+
// So we MUST reset.
|
|
236
|
+
// Optimization: Keep a list of visited nodes and reset only them.
|
|
237
|
+
// We'll use a `resetList` to track modified nodes and reset them at the end.
|
|
238
|
+
const resetList = [];
|
|
239
|
+
const startNode = this.grid[startGridY][startGridX];
|
|
240
|
+
const endNode = this.grid[endGridY][endGridX];
|
|
241
|
+
// Temporarily make start and end walkable
|
|
242
|
+
const startWasWalkable = startNode.walkable;
|
|
243
|
+
const endWasWalkable = endNode.walkable;
|
|
244
|
+
startNode.walkable = true;
|
|
245
|
+
endNode.walkable = true;
|
|
246
|
+
resetList.push(startNode, endNode);
|
|
247
|
+
const openList = new MinHeap();
|
|
248
|
+
startNode.g = 0;
|
|
249
|
+
startNode.h = Math.abs(startNode.x - endNode.x) + Math.abs(startNode.y - endNode.y);
|
|
250
|
+
startNode.f = startNode.g + startNode.h;
|
|
251
|
+
startNode.opened = true;
|
|
252
|
+
openList.push(startNode);
|
|
253
|
+
resetList.push(startNode);
|
|
254
|
+
let pathFound = false;
|
|
255
|
+
while (openList.size() > 0) {
|
|
256
|
+
const currentNode = openList.pop();
|
|
257
|
+
currentNode.closed = true;
|
|
258
|
+
if (currentNode === endNode) {
|
|
259
|
+
pathFound = true;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
const neighbors = this.getNeighbors(currentNode);
|
|
263
|
+
for (const neighbor of neighbors) {
|
|
264
|
+
if (neighbor.closed || !neighbor.walkable) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const gScore = currentNode.g + 1;
|
|
268
|
+
if (!neighbor.opened || gScore < neighbor.g) {
|
|
269
|
+
neighbor.g = gScore;
|
|
270
|
+
neighbor.h = Math.abs(neighbor.x - endNode.x) + Math.abs(neighbor.y - endNode.y);
|
|
271
|
+
neighbor.f = neighbor.g + neighbor.h;
|
|
272
|
+
neighbor.parent = currentNode;
|
|
273
|
+
if (!neighbor.opened) {
|
|
274
|
+
neighbor.opened = true;
|
|
275
|
+
openList.push(neighbor);
|
|
276
|
+
resetList.push(neighbor);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
openList.rescoreElement(neighbor);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
let resultPath = [start, end];
|
|
285
|
+
if (pathFound) {
|
|
286
|
+
const path = [];
|
|
287
|
+
let curr = endNode;
|
|
288
|
+
while (curr) {
|
|
289
|
+
path.unshift({
|
|
290
|
+
x: curr.x * this.gridSize + this.bounds.minX + this.gridSize / 2,
|
|
291
|
+
y: curr.y * this.gridSize + this.bounds.minY + this.gridSize / 2
|
|
292
|
+
});
|
|
293
|
+
curr = curr.parent;
|
|
294
|
+
}
|
|
295
|
+
path[0] = start;
|
|
296
|
+
path[path.length - 1] = end;
|
|
297
|
+
resultPath = this.simplifyPath(path);
|
|
298
|
+
}
|
|
299
|
+
// Cleanup
|
|
300
|
+
startNode.walkable = startWasWalkable;
|
|
301
|
+
endNode.walkable = endWasWalkable;
|
|
302
|
+
// Reset modified nodes
|
|
303
|
+
for (const node of resetList) {
|
|
304
|
+
node.g = 0;
|
|
305
|
+
node.h = 0;
|
|
306
|
+
node.f = 0;
|
|
307
|
+
node.parent = null;
|
|
308
|
+
node.opened = false;
|
|
309
|
+
node.closed = false;
|
|
310
|
+
}
|
|
311
|
+
return resultPath;
|
|
312
|
+
}
|
|
313
|
+
isValid(x, y) {
|
|
314
|
+
return x >= 0 && x < this.width && y >= 0 && y < this.height;
|
|
315
|
+
}
|
|
316
|
+
getNeighbors(node) {
|
|
317
|
+
const neighbors = [];
|
|
318
|
+
const dirs = [[0, 1], [1, 0], [0, -1], [-1, 0]]; // 4-directional
|
|
319
|
+
for (const [dx, dy] of dirs) {
|
|
320
|
+
const newX = node.x + dx;
|
|
321
|
+
const newY = node.y + dy;
|
|
322
|
+
if (this.isValid(newX, newY)) {
|
|
323
|
+
neighbors.push(this.grid[newY][newX]);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return neighbors;
|
|
327
|
+
}
|
|
328
|
+
simplifyPath(path) {
|
|
329
|
+
if (path.length <= 2)
|
|
330
|
+
return path;
|
|
331
|
+
const simplified = [path[0]];
|
|
332
|
+
let direction = {
|
|
333
|
+
x: Math.sign(path[1].x - path[0].x),
|
|
334
|
+
y: Math.sign(path[1].y - path[0].y)
|
|
335
|
+
};
|
|
336
|
+
for (let i = 1; i < path.length - 1; i++) {
|
|
337
|
+
const nextPoint = path[i + 1];
|
|
338
|
+
const currentPoint = path[i];
|
|
339
|
+
const newDirection = {
|
|
340
|
+
x: Math.sign(nextPoint.x - currentPoint.x),
|
|
341
|
+
y: Math.sign(nextPoint.y - currentPoint.y)
|
|
342
|
+
};
|
|
343
|
+
// If direction changes, add the turning point
|
|
344
|
+
if (newDirection.x !== direction.x || newDirection.y !== direction.y) {
|
|
345
|
+
simplified.push(currentPoint);
|
|
346
|
+
direction = newDirection;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
simplified.push(path[path.length - 1]);
|
|
350
|
+
return simplified;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
class ZoomControlsComponent {
|
|
355
|
+
zoom = 1;
|
|
356
|
+
minZoom = 0.1;
|
|
357
|
+
maxZoom = 10;
|
|
358
|
+
zoomIn = new EventEmitter();
|
|
359
|
+
zoomOut = new EventEmitter();
|
|
360
|
+
fitView = new EventEmitter();
|
|
361
|
+
resetZoom = new EventEmitter();
|
|
362
|
+
get zoomPercent() {
|
|
363
|
+
return Math.round(this.zoom * 100);
|
|
364
|
+
}
|
|
365
|
+
onZoomIn() {
|
|
366
|
+
this.zoomIn.emit();
|
|
367
|
+
}
|
|
368
|
+
onZoomOut() {
|
|
369
|
+
this.zoomOut.emit();
|
|
370
|
+
}
|
|
371
|
+
onFitView() {
|
|
372
|
+
this.fitView.emit();
|
|
373
|
+
}
|
|
374
|
+
onResetZoom() {
|
|
375
|
+
this.resetZoom.emit();
|
|
376
|
+
}
|
|
377
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: ZoomControlsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
378
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.13", type: ZoomControlsComponent, isStandalone: true, selector: "ngx-workflow-zoom-controls", inputs: { zoom: "zoom", minZoom: "minZoom", maxZoom: "maxZoom" }, outputs: { zoomIn: "zoomIn", zoomOut: "zoomOut", fitView: "fitView", resetZoom: "resetZoom" }, ngImport: i0, template: "<div class=\"ngx-workflow__zoom-controls\">\r\n <button class=\"ngx-workflow__zoom-button\" (click)=\"onZoomIn()\" [disabled]=\"zoom >= maxZoom\" title=\"Zoom In\">\r\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\r\n <path d=\"M8 3v10M3 8h10\" stroke=\"currentColor\" stroke-width=\"2\" fill=\"none\" />\r\n </svg>\r\n </button>\r\n\r\n <button class=\"ngx-workflow__zoom-button\" (click)=\"onZoomOut()\" [disabled]=\"zoom <= minZoom\" title=\"Zoom Out\">\r\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\r\n <path d=\"M3 8h10\" stroke=\"currentColor\" stroke-width=\"2\" fill=\"none\" />\r\n </svg>\r\n </button>\r\n\r\n <button class=\"ngx-workflow__zoom-button\" (click)=\"onFitView()\" title=\"Fit View\">\r\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\r\n <rect x=\"2\" y=\"2\" width=\"12\" height=\"12\" stroke=\"currentColor\" stroke-width=\"2\" fill=\"none\" />\r\n <path d=\"M5 5L11 11M11 5L5 11\" stroke=\"currentColor\" stroke-width=\"1.5\" />\r\n </svg>\r\n </button>\r\n\r\n <button class=\"ngx-workflow__zoom-button\" (click)=\"onResetZoom()\" title=\"Reset Zoom (100%)\">\r\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\r\n <text x=\"8\" y=\"12\" text-anchor=\"middle\" font-size=\"10\" fill=\"currentColor\">1:1</text>\r\n </svg>\r\n </button>\r\n\r\n <div class=\"ngx-workflow__zoom-level\">{{ zoomPercent }}%</div>\r\n</div>", styles: [".ngx-workflow__zoom-controls{position:absolute;bottom:20px;right:20px;display:flex;flex-direction:column;gap:4px;background:var(--ngx-workflow-zoom-controls-bg, #fff);border:1px solid var(--ngx-workflow-zoom-controls-border, #ddd);border-radius:8px;padding:8px;box-shadow:0 2px 8px #0000001a;z-index:10;-webkit-user-select:none;user-select:none}.ngx-workflow__zoom-button{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:var(--ngx-workflow-zoom-button-bg, #f5f5f5);border:1px solid var(--ngx-workflow-zoom-button-border, #ddd);border-radius:4px;cursor:pointer;transition:all .2s ease;color:var(--ngx-workflow-zoom-button-color, #333);padding:0}.ngx-workflow__zoom-button:hover:not(:disabled){background:var(--ngx-workflow-zoom-button-hover-bg, #e8e8e8);border-color:var(--ngx-workflow-zoom-button-hover-border, #bbb)}.ngx-workflow__zoom-button:active:not(:disabled){background:var(--ngx-workflow-zoom-button-active-bg, #ddd);transform:scale(.95)}.ngx-workflow__zoom-button:disabled{opacity:.4;cursor:not-allowed}.ngx-workflow__zoom-button svg{display:block}.ngx-workflow__zoom-level{text-align:center;font-size:11px;font-weight:600;color:var(--ngx-workflow-zoom-level-color, #666);padding:4px 0;border-top:1px solid var(--ngx-workflow-zoom-controls-border, #ddd);margin-top:4px;min-width:40px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
379
|
+
}
|
|
380
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: ZoomControlsComponent, decorators: [{
|
|
381
|
+
type: Component,
|
|
382
|
+
args: [{ selector: 'ngx-workflow-zoom-controls', imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"ngx-workflow__zoom-controls\">\r\n <button class=\"ngx-workflow__zoom-button\" (click)=\"onZoomIn()\" [disabled]=\"zoom >= maxZoom\" title=\"Zoom In\">\r\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\r\n <path d=\"M8 3v10M3 8h10\" stroke=\"currentColor\" stroke-width=\"2\" fill=\"none\" />\r\n </svg>\r\n </button>\r\n\r\n <button class=\"ngx-workflow__zoom-button\" (click)=\"onZoomOut()\" [disabled]=\"zoom <= minZoom\" title=\"Zoom Out\">\r\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\r\n <path d=\"M3 8h10\" stroke=\"currentColor\" stroke-width=\"2\" fill=\"none\" />\r\n </svg>\r\n </button>\r\n\r\n <button class=\"ngx-workflow__zoom-button\" (click)=\"onFitView()\" title=\"Fit View\">\r\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\r\n <rect x=\"2\" y=\"2\" width=\"12\" height=\"12\" stroke=\"currentColor\" stroke-width=\"2\" fill=\"none\" />\r\n <path d=\"M5 5L11 11M11 5L5 11\" stroke=\"currentColor\" stroke-width=\"1.5\" />\r\n </svg>\r\n </button>\r\n\r\n <button class=\"ngx-workflow__zoom-button\" (click)=\"onResetZoom()\" title=\"Reset Zoom (100%)\">\r\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\r\n <text x=\"8\" y=\"12\" text-anchor=\"middle\" font-size=\"10\" fill=\"currentColor\">1:1</text>\r\n </svg>\r\n </button>\r\n\r\n <div class=\"ngx-workflow__zoom-level\">{{ zoomPercent }}%</div>\r\n</div>", styles: [".ngx-workflow__zoom-controls{position:absolute;bottom:20px;right:20px;display:flex;flex-direction:column;gap:4px;background:var(--ngx-workflow-zoom-controls-bg, #fff);border:1px solid var(--ngx-workflow-zoom-controls-border, #ddd);border-radius:8px;padding:8px;box-shadow:0 2px 8px #0000001a;z-index:10;-webkit-user-select:none;user-select:none}.ngx-workflow__zoom-button{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:var(--ngx-workflow-zoom-button-bg, #f5f5f5);border:1px solid var(--ngx-workflow-zoom-button-border, #ddd);border-radius:4px;cursor:pointer;transition:all .2s ease;color:var(--ngx-workflow-zoom-button-color, #333);padding:0}.ngx-workflow__zoom-button:hover:not(:disabled){background:var(--ngx-workflow-zoom-button-hover-bg, #e8e8e8);border-color:var(--ngx-workflow-zoom-button-hover-border, #bbb)}.ngx-workflow__zoom-button:active:not(:disabled){background:var(--ngx-workflow-zoom-button-active-bg, #ddd);transform:scale(.95)}.ngx-workflow__zoom-button:disabled{opacity:.4;cursor:not-allowed}.ngx-workflow__zoom-button svg{display:block}.ngx-workflow__zoom-level{text-align:center;font-size:11px;font-weight:600;color:var(--ngx-workflow-zoom-level-color, #666);padding:4px 0;border-top:1px solid var(--ngx-workflow-zoom-controls-border, #ddd);margin-top:4px;min-width:40px}\n"] }]
|
|
383
|
+
}], propDecorators: { zoom: [{
|
|
384
|
+
type: Input
|
|
385
|
+
}], minZoom: [{
|
|
386
|
+
type: Input
|
|
387
|
+
}], maxZoom: [{
|
|
388
|
+
type: Input
|
|
389
|
+
}], zoomIn: [{
|
|
390
|
+
type: Output
|
|
391
|
+
}], zoomOut: [{
|
|
392
|
+
type: Output
|
|
393
|
+
}], fitView: [{
|
|
394
|
+
type: Output
|
|
395
|
+
}], resetZoom: [{
|
|
396
|
+
type: Output
|
|
397
|
+
}] } });
|
|
398
|
+
|
|
399
|
+
class UndoRedoService {
|
|
400
|
+
undoStack = signal([], ...(ngDevMode ? [{ debugName: "undoStack" }] : []));
|
|
401
|
+
redoStack = signal([], ...(ngDevMode ? [{ debugName: "redoStack" }] : []));
|
|
402
|
+
constructor() { }
|
|
403
|
+
// Saves the current state of the diagram to the undo stack
|
|
404
|
+
saveState(currentState) {
|
|
405
|
+
this.undoStack.update((stack) => [...stack, currentState]);
|
|
406
|
+
this.redoStack.set([]); // Clear redo stack on new action
|
|
407
|
+
// console.log('State saved');
|
|
408
|
+
// console.log('Undo Stack size:', this.undoStack().length);
|
|
409
|
+
}
|
|
410
|
+
// Undoes the last action
|
|
411
|
+
undo(currentState) {
|
|
412
|
+
const previousState = this.undoStack().pop();
|
|
413
|
+
if (previousState) {
|
|
414
|
+
this.redoStack.update((stack) => [...stack, currentState]);
|
|
415
|
+
console.log('Undo performed. Undo Stack size:', this.undoStack().length);
|
|
416
|
+
console.log('Redo Stack size:', this.redoStack().length);
|
|
417
|
+
return previousState;
|
|
418
|
+
}
|
|
419
|
+
console.log('Nothing to undo.');
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
// Redoes the last undone action
|
|
423
|
+
redo(currentState) {
|
|
424
|
+
const nextState = this.redoStack().pop();
|
|
425
|
+
if (nextState) {
|
|
426
|
+
this.undoStack.update((stack) => [...stack, currentState]);
|
|
427
|
+
console.log('Redo performed. Undo Stack size:', this.undoStack().length);
|
|
428
|
+
console.log('Redo Stack size:', this.redoStack().length);
|
|
429
|
+
return nextState;
|
|
430
|
+
}
|
|
431
|
+
console.log('Nothing to redo.');
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
canUndo = computed(() => this.undoStack().length > 0, ...(ngDevMode ? [{ debugName: "canUndo" }] : []));
|
|
435
|
+
canRedo = computed(() => this.redoStack().length > 0, ...(ngDevMode ? [{ debugName: "canRedo" }] : []));
|
|
436
|
+
clearStacks() {
|
|
437
|
+
this.undoStack.set([]);
|
|
438
|
+
this.redoStack.set([]);
|
|
439
|
+
}
|
|
440
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: UndoRedoService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
441
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: UndoRedoService, providedIn: 'root' });
|
|
442
|
+
}
|
|
443
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: UndoRedoService, decorators: [{
|
|
444
|
+
type: Injectable,
|
|
445
|
+
args: [{
|
|
446
|
+
providedIn: 'root',
|
|
447
|
+
}]
|
|
448
|
+
}], ctorParameters: () => [] });
|
|
449
|
+
|
|
450
|
+
class DiagramStateService {
|
|
451
|
+
undoRedoService;
|
|
452
|
+
nodes = signal([], ...(ngDevMode ? [{ debugName: "nodes" }] : []));
|
|
453
|
+
edges = signal([], ...(ngDevMode ? [{ debugName: "edges" }] : []));
|
|
454
|
+
tempEdges = signal([], ...(ngDevMode ? [{ debugName: "tempEdges" }] : []));
|
|
455
|
+
viewport = signal({ x: 0, y: 0, zoom: 1 }, ...(ngDevMode ? [{ debugName: "viewport" }] : []));
|
|
456
|
+
alignmentGuides = signal([], ...(ngDevMode ? [{ debugName: "alignmentGuides" }] : []));
|
|
457
|
+
searchQuery = signal('', ...(ngDevMode ? [{ debugName: "searchQuery" }] : []));
|
|
458
|
+
filterType = signal(null, ...(ngDevMode ? [{ debugName: "filterType" }] : []));
|
|
459
|
+
containerDimensions = signal({ width: 0, height: 0 }, ...(ngDevMode ? [{ debugName: "containerDimensions" }] : []));
|
|
460
|
+
// Computed signals
|
|
461
|
+
selectedNodes = computed(() => this.nodes().filter((n) => n.selected), ...(ngDevMode ? [{ debugName: "selectedNodes" }] : []));
|
|
462
|
+
selectedEdges = computed(() => this.edges().filter((e) => e.selected), ...(ngDevMode ? [{ debugName: "selectedEdges" }] : []));
|
|
463
|
+
viewNodes = computed(() => {
|
|
464
|
+
const nodes = this.nodes();
|
|
465
|
+
const query = this.searchQuery().toLowerCase();
|
|
466
|
+
const type = this.filterType();
|
|
467
|
+
return nodes.map(node => {
|
|
468
|
+
const matchesSearch = !query ||
|
|
469
|
+
(node.label && node.label.toLowerCase().includes(query)) ||
|
|
470
|
+
(node.data && JSON.stringify(node.data).toLowerCase().includes(query));
|
|
471
|
+
const matchesType = !type || node.type === type;
|
|
472
|
+
const isMatch = matchesSearch && matchesType;
|
|
473
|
+
// If there is a search/filter active, highlight matches and dim non-matches
|
|
474
|
+
// If no search/filter, reset (no highlight, no dim)
|
|
475
|
+
const isActive = !!query || !!type;
|
|
476
|
+
return {
|
|
477
|
+
...node,
|
|
478
|
+
highlighted: isActive && isMatch,
|
|
479
|
+
dimmed: isActive && !isMatch
|
|
480
|
+
};
|
|
481
|
+
});
|
|
482
|
+
}, ...(ngDevMode ? [{ debugName: "viewNodes" }] : []));
|
|
483
|
+
visibleNodes = computed(() => {
|
|
484
|
+
const nodes = this.viewNodes();
|
|
485
|
+
const viewport = this.viewport();
|
|
486
|
+
const dimensions = this.containerDimensions();
|
|
487
|
+
const buffer = 500; // Buffer in pixels
|
|
488
|
+
if (dimensions.width === 0 || dimensions.height === 0) {
|
|
489
|
+
return nodes; // Render all if dimensions not set
|
|
490
|
+
}
|
|
491
|
+
const minX = -viewport.x / viewport.zoom - buffer;
|
|
492
|
+
const maxX = (-viewport.x + dimensions.width) / viewport.zoom + buffer;
|
|
493
|
+
const minY = -viewport.y / viewport.zoom - buffer;
|
|
494
|
+
const maxY = (-viewport.y + dimensions.height) / viewport.zoom + buffer;
|
|
495
|
+
return nodes.filter(node => {
|
|
496
|
+
const nodeX = node.position.x;
|
|
497
|
+
const nodeY = node.position.y;
|
|
498
|
+
const nodeWidth = node.width || 150; // Default width
|
|
499
|
+
const nodeHeight = node.height || 60; // Default height
|
|
500
|
+
return (nodeX + nodeWidth >= minX &&
|
|
501
|
+
nodeX <= maxX &&
|
|
502
|
+
nodeY + nodeHeight >= minY &&
|
|
503
|
+
nodeY <= maxY);
|
|
504
|
+
});
|
|
505
|
+
}, ...(ngDevMode ? [{ debugName: "visibleNodes" }] : []));
|
|
506
|
+
lodLevel = computed(() => {
|
|
507
|
+
const zoom = this.viewport().zoom;
|
|
508
|
+
if (zoom < 0.4)
|
|
509
|
+
return 'low';
|
|
510
|
+
if (zoom < 0.8)
|
|
511
|
+
return 'medium';
|
|
512
|
+
return 'high';
|
|
513
|
+
}, ...(ngDevMode ? [{ debugName: "lodLevel" }] : []));
|
|
514
|
+
// Reference to the main SVG element, set by DiagramComponent
|
|
515
|
+
el;
|
|
516
|
+
dragEnd = new EventEmitter();
|
|
517
|
+
nodesChange = new EventEmitter();
|
|
518
|
+
edgesChange = new EventEmitter();
|
|
519
|
+
viewportChange = new EventEmitter();
|
|
520
|
+
nodeClick = new EventEmitter();
|
|
521
|
+
edgeClick = new EventEmitter();
|
|
522
|
+
connect = new EventEmitter();
|
|
523
|
+
dragStart = new EventEmitter();
|
|
524
|
+
// Internal subjects for batched updates
|
|
525
|
+
nodeUpdates$ = new Subject();
|
|
526
|
+
edgeUpdates$ = new Subject();
|
|
527
|
+
viewportUpdates$ = new Subject();
|
|
528
|
+
constructor(undoRedoService) {
|
|
529
|
+
this.undoRedoService = undoRedoService;
|
|
530
|
+
// Sync internal subjects to signals (for batched updates)
|
|
531
|
+
this.nodeUpdates$
|
|
532
|
+
.pipe(throttleTime(0, animationFrameScheduler, { leading: true, trailing: true }))
|
|
533
|
+
.subscribe((nodes) => {
|
|
534
|
+
this.nodes.set(nodes);
|
|
535
|
+
});
|
|
536
|
+
this.edgeUpdates$
|
|
537
|
+
.pipe(throttleTime(0, animationFrameScheduler, { leading: true, trailing: true }))
|
|
538
|
+
.subscribe((edges) => {
|
|
539
|
+
this.edges.set(edges);
|
|
540
|
+
});
|
|
541
|
+
this.viewportUpdates$
|
|
542
|
+
.pipe(throttleTime(0, animationFrameScheduler, { leading: true, trailing: true }))
|
|
543
|
+
.subscribe((viewport) => {
|
|
544
|
+
this.viewport.set(viewport);
|
|
545
|
+
});
|
|
546
|
+
// Emit changes whenever signals update
|
|
547
|
+
effect(() => {
|
|
548
|
+
this.nodesChange.emit(this.nodes());
|
|
549
|
+
});
|
|
550
|
+
effect(() => {
|
|
551
|
+
this.edgesChange.emit(this.edges());
|
|
552
|
+
});
|
|
553
|
+
effect(() => {
|
|
554
|
+
this.viewportChange.emit(this.viewport());
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
// Helper to get current state for undo/redo
|
|
558
|
+
getCurrentState() {
|
|
559
|
+
return {
|
|
560
|
+
nodes: [...this.nodes()],
|
|
561
|
+
edges: [...this.edges()],
|
|
562
|
+
viewport: { ...this.viewport() },
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
// Public method to retrieve the current diagram state (for export)
|
|
566
|
+
getDiagramState() {
|
|
567
|
+
return this.getCurrentState();
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Replace the entire diagram state (nodes, edges, viewport) with the given state.
|
|
571
|
+
* Used for importing diagram JSON.
|
|
572
|
+
*/
|
|
573
|
+
setDiagramState(state) {
|
|
574
|
+
// Save current state for undo
|
|
575
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
576
|
+
// Replace signals
|
|
577
|
+
this.nodes.set(state.nodes.map(n => ({ ...n, selected: false, dragging: false, draggable: true })));
|
|
578
|
+
this.edges.set(state.edges.map(e => ({ ...e, selected: false })));
|
|
579
|
+
this.viewport.set(state.viewport);
|
|
580
|
+
// Emit changes
|
|
581
|
+
this.nodesChange.emit(this.nodes());
|
|
582
|
+
this.edgesChange.emit(this.edges());
|
|
583
|
+
this.viewportChange.emit(this.viewport());
|
|
584
|
+
}
|
|
585
|
+
// Apply state from undo/redo service
|
|
586
|
+
applyState(state) {
|
|
587
|
+
this.nodes.set(state.nodes);
|
|
588
|
+
this.edges.set(state.edges);
|
|
589
|
+
this.viewport.set(state.viewport);
|
|
590
|
+
}
|
|
591
|
+
undo() {
|
|
592
|
+
const currentState = this.getCurrentState();
|
|
593
|
+
const previousState = this.undoRedoService.undo(currentState);
|
|
594
|
+
if (previousState) {
|
|
595
|
+
this.applyState(previousState);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
redo() {
|
|
599
|
+
const currentState = this.getCurrentState();
|
|
600
|
+
const nextState = this.undoRedoService.redo(currentState);
|
|
601
|
+
if (nextState) {
|
|
602
|
+
this.applyState(nextState);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
onResizeStart(node) {
|
|
606
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
607
|
+
}
|
|
608
|
+
onResizeEnd(node) {
|
|
609
|
+
// Optional: emit resize end event if needed in the future
|
|
610
|
+
}
|
|
611
|
+
// --- Edge Management ---
|
|
612
|
+
addEdge(edge) {
|
|
613
|
+
console.log('DiagramStateService.addEdge: start', edge);
|
|
614
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
615
|
+
this.edges.update((currentEdges) => [...currentEdges, { ...edge, selected: false }]);
|
|
616
|
+
this.connect.emit({
|
|
617
|
+
source: edge.source,
|
|
618
|
+
sourceHandle: edge.sourceHandle,
|
|
619
|
+
target: edge.target,
|
|
620
|
+
targetHandle: edge.targetHandle,
|
|
621
|
+
});
|
|
622
|
+
console.log('DiagramStateService.addEdge: end');
|
|
623
|
+
}
|
|
624
|
+
updateEdge(id, changes) {
|
|
625
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
626
|
+
this.edges.update((currentEdges) => currentEdges.map((edge) => {
|
|
627
|
+
if (edge.id === id) {
|
|
628
|
+
return { ...edge, ...changes };
|
|
629
|
+
}
|
|
630
|
+
return edge;
|
|
631
|
+
}));
|
|
632
|
+
}
|
|
633
|
+
removeEdge(id) {
|
|
634
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
635
|
+
this.edges.update((currentEdges) => currentEdges.filter((edge) => edge.id !== id));
|
|
636
|
+
this.tempEdges.update((currentTempEdges) => currentTempEdges.filter((edge) => edge.id !== id)); // Remove from tempEdges as well
|
|
637
|
+
}
|
|
638
|
+
// --- Temporary Edge (Preview Edge) Management ---
|
|
639
|
+
addTempEdge(edge) {
|
|
640
|
+
this.tempEdges.update((currentTempEdges) => [...currentTempEdges, edge]);
|
|
641
|
+
}
|
|
642
|
+
updateTempEdgeTarget(id, targetPosition) {
|
|
643
|
+
this.tempEdges.update((currentTempEdges) => currentTempEdges.map((edge) => edge.id === id ? { ...edge, targetX: targetPosition.x, targetY: targetPosition.y } : edge));
|
|
644
|
+
}
|
|
645
|
+
// --- Viewport Management ---
|
|
646
|
+
setViewport(viewport) {
|
|
647
|
+
this.viewport.update((currentViewport) => ({ ...currentViewport, ...viewport }));
|
|
648
|
+
}
|
|
649
|
+
// --- Selection Management ---
|
|
650
|
+
selectNodes(nodeIds, multi = false) {
|
|
651
|
+
this.nodes.update((currentNodes) => currentNodes.map((node) => ({
|
|
652
|
+
...node,
|
|
653
|
+
selected: multi
|
|
654
|
+
? nodeIds.includes(node.id)
|
|
655
|
+
? !node.selected
|
|
656
|
+
: node.selected
|
|
657
|
+
: nodeIds.includes(node.id),
|
|
658
|
+
})));
|
|
659
|
+
}
|
|
660
|
+
clearSelection() {
|
|
661
|
+
this.nodes.update((currentNodes) => currentNodes.map((node) => ({ ...node, selected: false })));
|
|
662
|
+
this.edges.update((currentEdges) => currentEdges.map((edge) => ({ ...edge, selected: false })));
|
|
663
|
+
}
|
|
664
|
+
selectAll() {
|
|
665
|
+
this.nodes.update((currentNodes) => currentNodes.map((node) => ({ ...node, selected: true })));
|
|
666
|
+
}
|
|
667
|
+
multiSelect(nodeId) {
|
|
668
|
+
this.nodes.update((currentNodes) => currentNodes.map((node) => ({
|
|
669
|
+
...node,
|
|
670
|
+
selected: node.id === nodeId ? !node.selected : node.selected,
|
|
671
|
+
})));
|
|
672
|
+
}
|
|
673
|
+
deleteSelectedElements() {
|
|
674
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
675
|
+
const nodesToDelete = this.selectedNodes().map((node) => node.id);
|
|
676
|
+
const edgesToDelete = this.selectedEdges().map((edge) => edge.id);
|
|
677
|
+
// Remove nodes and associated edges
|
|
678
|
+
this.nodes.update((currentNodes) => currentNodes.filter((node) => !nodesToDelete.includes(node.id)));
|
|
679
|
+
this.edges.update((currentEdges) => currentEdges.filter((edge) => !edgesToDelete.includes(edge.id) &&
|
|
680
|
+
!nodesToDelete.includes(edge.source) &&
|
|
681
|
+
!nodesToDelete.includes(edge.target)));
|
|
682
|
+
// Clear selection after deletion
|
|
683
|
+
this.clearSelection();
|
|
684
|
+
console.log('Selected elements deleted');
|
|
685
|
+
}
|
|
686
|
+
// --- Alignment & Distribution ---
|
|
687
|
+
alignNodes(alignment) {
|
|
688
|
+
const selectedNodes = this.selectedNodes();
|
|
689
|
+
if (selectedNodes.length < 2)
|
|
690
|
+
return;
|
|
691
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
692
|
+
let targetValue;
|
|
693
|
+
switch (alignment) {
|
|
694
|
+
case 'left':
|
|
695
|
+
targetValue = Math.min(...selectedNodes.map((n) => n.position.x));
|
|
696
|
+
break;
|
|
697
|
+
case 'center': {
|
|
698
|
+
const minX = Math.min(...selectedNodes.map((n) => n.position.x));
|
|
699
|
+
const maxX = Math.max(...selectedNodes.map((n) => n.position.x + (n.width || 170)));
|
|
700
|
+
targetValue = minX + (maxX - minX) / 2;
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
case 'right':
|
|
704
|
+
targetValue = Math.max(...selectedNodes.map((n) => n.position.x + (n.width || 170)));
|
|
705
|
+
break;
|
|
706
|
+
case 'top':
|
|
707
|
+
targetValue = Math.min(...selectedNodes.map((n) => n.position.y));
|
|
708
|
+
break;
|
|
709
|
+
case 'middle': {
|
|
710
|
+
const minY = Math.min(...selectedNodes.map((n) => n.position.y));
|
|
711
|
+
const maxY = Math.max(...selectedNodes.map((n) => n.position.y + (n.height || 60)));
|
|
712
|
+
targetValue = minY + (maxY - minY) / 2;
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
case 'bottom':
|
|
716
|
+
targetValue = Math.max(...selectedNodes.map((n) => n.position.y + (n.height || 60)));
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
this.nodes.update((currentNodes) => currentNodes.map((node) => {
|
|
720
|
+
if (!node.selected)
|
|
721
|
+
return node;
|
|
722
|
+
const newPos = { ...node.position };
|
|
723
|
+
const width = node.width || 170;
|
|
724
|
+
const height = node.height || 60;
|
|
725
|
+
switch (alignment) {
|
|
726
|
+
case 'left':
|
|
727
|
+
newPos.x = targetValue;
|
|
728
|
+
break;
|
|
729
|
+
case 'center':
|
|
730
|
+
newPos.x = targetValue - width / 2;
|
|
731
|
+
break;
|
|
732
|
+
case 'right':
|
|
733
|
+
newPos.x = targetValue - width;
|
|
734
|
+
break;
|
|
735
|
+
case 'top':
|
|
736
|
+
newPos.y = targetValue;
|
|
737
|
+
break;
|
|
738
|
+
case 'middle':
|
|
739
|
+
newPos.y = targetValue - height / 2;
|
|
740
|
+
break;
|
|
741
|
+
case 'bottom':
|
|
742
|
+
newPos.y = targetValue - height;
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
return { ...node, position: newPos };
|
|
746
|
+
}));
|
|
747
|
+
}
|
|
748
|
+
distributeNodes(distribution) {
|
|
749
|
+
const selectedNodes = this.selectedNodes();
|
|
750
|
+
if (selectedNodes.length < 3)
|
|
751
|
+
return;
|
|
752
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
753
|
+
// Sort nodes by position
|
|
754
|
+
const sortedNodes = [...selectedNodes].sort((a, b) => {
|
|
755
|
+
return distribution === 'horizontal' ? a.position.x - b.position.x : a.position.y - b.position.y;
|
|
756
|
+
});
|
|
757
|
+
const firstNode = sortedNodes[0];
|
|
758
|
+
const lastNode = sortedNodes[sortedNodes.length - 1];
|
|
759
|
+
if (distribution === 'horizontal') {
|
|
760
|
+
const totalSpan = lastNode.position.x - firstNode.position.x;
|
|
761
|
+
const step = totalSpan / (sortedNodes.length - 1);
|
|
762
|
+
this.nodes.update(nodes => nodes.map(node => {
|
|
763
|
+
const index = sortedNodes.findIndex(n => n.id === node.id);
|
|
764
|
+
if (index === -1)
|
|
765
|
+
return node;
|
|
766
|
+
return {
|
|
767
|
+
...node,
|
|
768
|
+
position: {
|
|
769
|
+
...node.position,
|
|
770
|
+
x: firstNode.position.x + (step * index)
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
}));
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
const totalSpan = lastNode.position.y - firstNode.position.y;
|
|
777
|
+
const step = totalSpan / (sortedNodes.length - 1);
|
|
778
|
+
this.nodes.update(nodes => nodes.map(node => {
|
|
779
|
+
const index = sortedNodes.findIndex(n => n.id === node.id);
|
|
780
|
+
if (index === -1)
|
|
781
|
+
return node;
|
|
782
|
+
return {
|
|
783
|
+
...node,
|
|
784
|
+
position: {
|
|
785
|
+
...node.position,
|
|
786
|
+
y: firstNode.position.y + (step * index)
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
}));
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// --- Internal batching methods for components to use ---
|
|
793
|
+
batchUpdateNodes(updatedNodes) {
|
|
794
|
+
this.nodeUpdates$.next(updatedNodes);
|
|
795
|
+
}
|
|
796
|
+
batchUpdateEdges(updatedEdges) {
|
|
797
|
+
this.edgeUpdates$.next(updatedEdges);
|
|
798
|
+
}
|
|
799
|
+
batchUpdateViewport(updatedViewport) {
|
|
800
|
+
this.viewportUpdates$.next(updatedViewport);
|
|
801
|
+
}
|
|
802
|
+
// --- Event Emitters triggered by components ---
|
|
803
|
+
onNodeClick(node) {
|
|
804
|
+
this.nodeClick.emit(node);
|
|
805
|
+
}
|
|
806
|
+
onEdgeClick(edge) {
|
|
807
|
+
this.edgeClick.emit(edge);
|
|
808
|
+
}
|
|
809
|
+
onDragStart(node) {
|
|
810
|
+
this.undoRedoService.saveState(this.getCurrentState()); // Save state before drag starts
|
|
811
|
+
this.dragStart.emit(node);
|
|
812
|
+
this.updateNode(node.id, { dragging: true });
|
|
813
|
+
}
|
|
814
|
+
onDragEnd(node) {
|
|
815
|
+
this.dragEnd.emit(node);
|
|
816
|
+
this.updateNode(node.id, { dragging: false });
|
|
817
|
+
this.alignmentGuides.set([]); // Clear alignment guides
|
|
818
|
+
// State is already saved at dragStart, so no need to save again here unless
|
|
819
|
+
// a single drag operation is considered a single undoable action.
|
|
820
|
+
// If multiple small state changes during drag need to be undone as one,
|
|
821
|
+
// then the saveState logic here would be different (e.g., debounced save).
|
|
822
|
+
}
|
|
823
|
+
// --- Clipboard Operations ---
|
|
824
|
+
clipboard = { nodes: [], edges: [] };
|
|
825
|
+
copy() {
|
|
826
|
+
const selectedNodes = this.selectedNodes();
|
|
827
|
+
if (selectedNodes.length === 0)
|
|
828
|
+
return;
|
|
829
|
+
const selectedNodeIds = new Set(selectedNodes.map((n) => n.id));
|
|
830
|
+
// Find edges that are connected ONLY to selected nodes
|
|
831
|
+
const internalEdges = this.edges().filter((edge) => selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target));
|
|
832
|
+
// Deep copy to avoid reference issues
|
|
833
|
+
this.clipboard = {
|
|
834
|
+
nodes: JSON.parse(JSON.stringify(selectedNodes)),
|
|
835
|
+
edges: JSON.parse(JSON.stringify(internalEdges)),
|
|
836
|
+
};
|
|
837
|
+
console.log('Copied to clipboard:', this.clipboard);
|
|
838
|
+
}
|
|
839
|
+
paste() {
|
|
840
|
+
if (this.clipboard.nodes.length === 0)
|
|
841
|
+
return;
|
|
842
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
843
|
+
const idMap = new Map();
|
|
844
|
+
const newNodes = [];
|
|
845
|
+
const newEdges = [];
|
|
846
|
+
// Create new nodes with new IDs and offset position
|
|
847
|
+
this.clipboard.nodes.forEach((node) => {
|
|
848
|
+
const newId = v4();
|
|
849
|
+
idMap.set(node.id, newId);
|
|
850
|
+
newNodes.push({
|
|
851
|
+
...node,
|
|
852
|
+
id: newId,
|
|
853
|
+
position: { x: node.position.x + 20, y: node.position.y + 20 },
|
|
854
|
+
selected: true, // Select pasted nodes
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
// Create new edges with updated source/target IDs
|
|
858
|
+
this.clipboard.edges.forEach((edge) => {
|
|
859
|
+
const newSource = idMap.get(edge.source);
|
|
860
|
+
const newTarget = idMap.get(edge.target);
|
|
861
|
+
if (newSource && newTarget) {
|
|
862
|
+
newEdges.push({
|
|
863
|
+
...edge,
|
|
864
|
+
id: v4(),
|
|
865
|
+
source: newSource,
|
|
866
|
+
target: newTarget,
|
|
867
|
+
selected: true, // Select pasted edges
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
// Deselect existing elements
|
|
872
|
+
this.clearSelection();
|
|
873
|
+
// Add new elements
|
|
874
|
+
this.nodes.update((nodes) => [...nodes, ...newNodes]);
|
|
875
|
+
this.edges.update((edges) => [...edges, ...newEdges]);
|
|
876
|
+
}
|
|
877
|
+
cut() {
|
|
878
|
+
this.copy();
|
|
879
|
+
this.deleteSelectedElements();
|
|
880
|
+
}
|
|
881
|
+
duplicate() {
|
|
882
|
+
this.copy();
|
|
883
|
+
this.paste();
|
|
884
|
+
}
|
|
885
|
+
// --- Grouping Operations ---
|
|
886
|
+
groupNodes(nodeIds) {
|
|
887
|
+
if (nodeIds.length < 2)
|
|
888
|
+
return;
|
|
889
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
890
|
+
const nodesToGroup = this.nodes().filter((n) => nodeIds.includes(n.id));
|
|
891
|
+
if (nodesToGroup.length === 0)
|
|
892
|
+
return;
|
|
893
|
+
// Calculate bounding box
|
|
894
|
+
const minX = Math.min(...nodesToGroup.map((n) => n.position.x));
|
|
895
|
+
const minY = Math.min(...nodesToGroup.map((n) => n.position.y));
|
|
896
|
+
const maxX = Math.max(...nodesToGroup.map((n) => n.position.x + (n.width || 150)));
|
|
897
|
+
const maxY = Math.max(...nodesToGroup.map((n) => n.position.y + (n.height || 60)));
|
|
898
|
+
const padding = 20;
|
|
899
|
+
const groupNode = {
|
|
900
|
+
id: v4(),
|
|
901
|
+
type: 'group',
|
|
902
|
+
position: { x: minX - padding, y: minY - padding },
|
|
903
|
+
width: maxX - minX + padding * 2,
|
|
904
|
+
height: maxY - minY + padding * 2,
|
|
905
|
+
data: { label: 'Group' },
|
|
906
|
+
expanded: true,
|
|
907
|
+
selected: true,
|
|
908
|
+
};
|
|
909
|
+
// Update children to point to parent
|
|
910
|
+
// Note: We are keeping absolute coordinates for now, so no position update needed for children
|
|
911
|
+
const updatedChildren = nodesToGroup.map((n) => ({ ...n, parentId: groupNode.id, selected: false }));
|
|
912
|
+
const otherNodes = this.nodes().filter((n) => !nodeIds.includes(n.id));
|
|
913
|
+
this.nodes.set([...otherNodes, groupNode, ...updatedChildren]);
|
|
914
|
+
this.clearSelection();
|
|
915
|
+
this.selectNodes([groupNode.id]);
|
|
916
|
+
}
|
|
917
|
+
ungroupNodes(groupId) {
|
|
918
|
+
const groupNode = this.nodes().find((n) => n.id === groupId);
|
|
919
|
+
if (!groupNode || groupNode.type !== 'group')
|
|
920
|
+
return;
|
|
921
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
922
|
+
// Remove parentId from children
|
|
923
|
+
const updatedNodes = this.nodes().map((n) => {
|
|
924
|
+
if (n.parentId === groupId) {
|
|
925
|
+
const { parentId, ...rest } = n;
|
|
926
|
+
return { ...rest, selected: true };
|
|
927
|
+
}
|
|
928
|
+
return n;
|
|
929
|
+
});
|
|
930
|
+
// Remove group node
|
|
931
|
+
this.nodes.set(updatedNodes.filter((n) => n.id !== groupId));
|
|
932
|
+
}
|
|
933
|
+
addNode(node) {
|
|
934
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
935
|
+
this.nodes.update((nodes) => [...nodes, { ...node, selected: false }]);
|
|
936
|
+
}
|
|
937
|
+
removeNode(id) {
|
|
938
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
939
|
+
this.nodes.update((nodes) => nodes.filter((n) => n.id !== id));
|
|
940
|
+
this.edges.update((edges) => edges.filter((e) => e.source !== id && e.target !== id));
|
|
941
|
+
}
|
|
942
|
+
updateNode(id, changes) {
|
|
943
|
+
if (!changes.dragging && !changes.position) {
|
|
944
|
+
this.undoRedoService.saveState(this.getCurrentState());
|
|
945
|
+
}
|
|
946
|
+
this.nodes.update((nodes) => nodes.map((n) => {
|
|
947
|
+
if (n.id === id) {
|
|
948
|
+
return { ...n, ...changes };
|
|
949
|
+
}
|
|
950
|
+
return n;
|
|
951
|
+
}));
|
|
952
|
+
}
|
|
953
|
+
resizeNode(id, width, height, position) {
|
|
954
|
+
this.nodes.update((nodes) => nodes.map((n) => {
|
|
955
|
+
if (n.id === id) {
|
|
956
|
+
return { ...n, width, height, ...(position ? { position } : {}) };
|
|
957
|
+
}
|
|
958
|
+
return n;
|
|
959
|
+
}));
|
|
960
|
+
}
|
|
961
|
+
toggleGroup(groupId) {
|
|
962
|
+
this.nodes.update((nodes) => nodes.map((n) => {
|
|
963
|
+
if (n.id === groupId) {
|
|
964
|
+
return { ...n, expanded: !n.expanded };
|
|
965
|
+
}
|
|
966
|
+
return n;
|
|
967
|
+
}));
|
|
968
|
+
}
|
|
969
|
+
// Move node and handle group children
|
|
970
|
+
moveNode(id, newPosition) {
|
|
971
|
+
// console.log('moveNode', id, newPosition);
|
|
972
|
+
const node = this.nodes().find((n) => n.id === id);
|
|
973
|
+
if (!node)
|
|
974
|
+
return;
|
|
975
|
+
// Snapping Logic
|
|
976
|
+
let snappedX = newPosition.x;
|
|
977
|
+
let snappedY = newPosition.y;
|
|
978
|
+
const guides = [];
|
|
979
|
+
const SNAP_DISTANCE = 5;
|
|
980
|
+
const GRID_SIZE = 20;
|
|
981
|
+
// 1. Snap to Grid (lower priority)
|
|
982
|
+
snappedX = Math.round(snappedX / GRID_SIZE) * GRID_SIZE;
|
|
983
|
+
snappedY = Math.round(snappedY / GRID_SIZE) * GRID_SIZE;
|
|
984
|
+
// 2. Snap to Nodes (higher priority)
|
|
985
|
+
// Only snap if dragging a single node (for simplicity for now)
|
|
986
|
+
const otherNodes = this.nodes().filter(n => n.id !== id && n.parentId === node.parentId && !n.selected);
|
|
987
|
+
let snappedXNode = false;
|
|
988
|
+
let snappedYNode = false;
|
|
989
|
+
// Horizontal Alignment (aligning Y coordinates)
|
|
990
|
+
// Check Top, Center, Bottom
|
|
991
|
+
const myHeight = node.height || 150; // Default height
|
|
992
|
+
const myCenterY = newPosition.y + myHeight / 2;
|
|
993
|
+
const myBottomY = newPosition.y + myHeight;
|
|
994
|
+
for (const other of otherNodes) {
|
|
995
|
+
const otherHeight = other.height || 150;
|
|
996
|
+
const otherY = other.position.y;
|
|
997
|
+
const otherCenterY = otherY + otherHeight / 2;
|
|
998
|
+
const otherBottomY = otherY + otherHeight;
|
|
999
|
+
// Top to Top
|
|
1000
|
+
if (Math.abs(newPosition.y - otherY) < SNAP_DISTANCE) {
|
|
1001
|
+
snappedY = otherY;
|
|
1002
|
+
snappedYNode = true;
|
|
1003
|
+
guides.push({ type: 'horizontal', position: otherY, start: Math.min(newPosition.x, other.position.x), end: Math.max(newPosition.x + (node.width || 150), other.position.x + (other.width || 150)) });
|
|
1004
|
+
}
|
|
1005
|
+
// Top to Bottom
|
|
1006
|
+
else if (Math.abs(newPosition.y - otherBottomY) < SNAP_DISTANCE) {
|
|
1007
|
+
snappedY = otherBottomY;
|
|
1008
|
+
snappedYNode = true;
|
|
1009
|
+
guides.push({ type: 'horizontal', position: otherBottomY, start: Math.min(newPosition.x, other.position.x), end: Math.max(newPosition.x + (node.width || 150), other.position.x + (other.width || 150)) });
|
|
1010
|
+
}
|
|
1011
|
+
// Center to Center
|
|
1012
|
+
else if (Math.abs(myCenterY - otherCenterY) < SNAP_DISTANCE) {
|
|
1013
|
+
snappedY = otherCenterY - myHeight / 2;
|
|
1014
|
+
snappedYNode = true;
|
|
1015
|
+
guides.push({ type: 'horizontal', position: otherCenterY, start: Math.min(newPosition.x, other.position.x), end: Math.max(newPosition.x + (node.width || 150), other.position.x + (other.width || 150)) });
|
|
1016
|
+
}
|
|
1017
|
+
// Bottom to Top
|
|
1018
|
+
else if (Math.abs(myBottomY - otherY) < SNAP_DISTANCE) {
|
|
1019
|
+
snappedY = otherY - myHeight;
|
|
1020
|
+
snappedYNode = true;
|
|
1021
|
+
guides.push({ type: 'horizontal', position: otherY, start: Math.min(newPosition.x, other.position.x), end: Math.max(newPosition.x + (node.width || 150), other.position.x + (other.width || 150)) });
|
|
1022
|
+
}
|
|
1023
|
+
// Bottom to Bottom
|
|
1024
|
+
else if (Math.abs(myBottomY - otherBottomY) < SNAP_DISTANCE) {
|
|
1025
|
+
snappedY = otherBottomY - myHeight;
|
|
1026
|
+
snappedYNode = true;
|
|
1027
|
+
guides.push({ type: 'horizontal', position: otherBottomY, start: Math.min(newPosition.x, other.position.x), end: Math.max(newPosition.x + (node.width || 150), other.position.x + (other.width || 150)) });
|
|
1028
|
+
}
|
|
1029
|
+
if (snappedYNode)
|
|
1030
|
+
break; // Snap to first match
|
|
1031
|
+
}
|
|
1032
|
+
// Vertical Alignment (aligning X coordinates)
|
|
1033
|
+
// Check Left, Center, Right
|
|
1034
|
+
const myWidth = node.width || 150; // Default width
|
|
1035
|
+
const myCenterX = newPosition.x + myWidth / 2;
|
|
1036
|
+
const myRightX = newPosition.x + myWidth;
|
|
1037
|
+
for (const other of otherNodes) {
|
|
1038
|
+
const otherWidth = other.width || 150;
|
|
1039
|
+
const otherX = other.position.x;
|
|
1040
|
+
const otherCenterX = otherX + otherWidth / 2;
|
|
1041
|
+
const otherRightX = otherX + otherWidth;
|
|
1042
|
+
// Left to Left
|
|
1043
|
+
if (Math.abs(newPosition.x - otherX) < SNAP_DISTANCE) {
|
|
1044
|
+
snappedX = otherX;
|
|
1045
|
+
snappedXNode = true;
|
|
1046
|
+
guides.push({ type: 'vertical', position: otherX, start: Math.min(newPosition.y, other.position.y), end: Math.max(newPosition.y + (node.height || 60), other.position.y + (other.height || 60)) });
|
|
1047
|
+
}
|
|
1048
|
+
// Left to Right
|
|
1049
|
+
else if (Math.abs(newPosition.x - otherRightX) < SNAP_DISTANCE) {
|
|
1050
|
+
snappedX = otherRightX;
|
|
1051
|
+
snappedXNode = true;
|
|
1052
|
+
guides.push({ type: 'vertical', position: otherRightX, start: Math.min(newPosition.y, other.position.y), end: Math.max(newPosition.y + (node.height || 60), other.position.y + (other.height || 60)) });
|
|
1053
|
+
}
|
|
1054
|
+
// Center to Center
|
|
1055
|
+
else if (Math.abs(myCenterX - otherCenterX) < SNAP_DISTANCE) {
|
|
1056
|
+
snappedX = otherCenterX - myWidth / 2;
|
|
1057
|
+
snappedXNode = true;
|
|
1058
|
+
guides.push({ type: 'vertical', position: otherCenterX, start: Math.min(newPosition.y, other.position.y), end: Math.max(newPosition.y + (node.height || 60), other.position.y + (other.height || 60)) });
|
|
1059
|
+
}
|
|
1060
|
+
// Right to Left
|
|
1061
|
+
else if (Math.abs(myRightX - otherX) < SNAP_DISTANCE) {
|
|
1062
|
+
snappedX = otherX - myWidth;
|
|
1063
|
+
snappedXNode = true;
|
|
1064
|
+
guides.push({ type: 'vertical', position: otherX, start: Math.min(newPosition.y, other.position.y), end: Math.max(newPosition.y + (node.height || 60), other.position.y + (other.height || 60)) });
|
|
1065
|
+
}
|
|
1066
|
+
// Right to Right
|
|
1067
|
+
else if (Math.abs(myRightX - otherRightX) < SNAP_DISTANCE) {
|
|
1068
|
+
snappedX = otherRightX - myWidth;
|
|
1069
|
+
snappedXNode = true;
|
|
1070
|
+
guides.push({ type: 'vertical', position: otherRightX, start: Math.min(newPosition.y, other.position.y), end: Math.max(newPosition.y + (node.height || 60), other.position.y + (other.height || 60)) });
|
|
1071
|
+
}
|
|
1072
|
+
if (snappedXNode)
|
|
1073
|
+
break;
|
|
1074
|
+
}
|
|
1075
|
+
this.alignmentGuides.set(guides);
|
|
1076
|
+
const finalPosition = { x: snappedX, y: snappedY };
|
|
1077
|
+
const dx = finalPosition.x - node.position.x;
|
|
1078
|
+
const dy = finalPosition.y - node.position.y;
|
|
1079
|
+
if (dx === 0 && dy === 0)
|
|
1080
|
+
return;
|
|
1081
|
+
// Update the moved node
|
|
1082
|
+
const updatedNodes = this.nodes().map((n) => {
|
|
1083
|
+
if (n.id === id) {
|
|
1084
|
+
return { ...n, position: finalPosition };
|
|
1085
|
+
}
|
|
1086
|
+
return n;
|
|
1087
|
+
});
|
|
1088
|
+
// If it's a group, move all children recursively
|
|
1089
|
+
if (node.type === 'group') {
|
|
1090
|
+
this.moveChildren(id, dx, dy, updatedNodes);
|
|
1091
|
+
}
|
|
1092
|
+
this.nodes.set(updatedNodes);
|
|
1093
|
+
}
|
|
1094
|
+
moveChildren(parentId, dx, dy, nodes, visited = new Set()) {
|
|
1095
|
+
if (visited.has(parentId)) {
|
|
1096
|
+
console.warn('Cycle detected in group hierarchy, stopping recursion for', parentId);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
visited.add(parentId);
|
|
1100
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1101
|
+
if (nodes[i].parentId === parentId) {
|
|
1102
|
+
nodes[i] = {
|
|
1103
|
+
...nodes[i],
|
|
1104
|
+
position: {
|
|
1105
|
+
x: nodes[i].position.x + dx,
|
|
1106
|
+
y: nodes[i].position.y + dy,
|
|
1107
|
+
},
|
|
1108
|
+
};
|
|
1109
|
+
// Recursively move children of children (nested groups)
|
|
1110
|
+
if (nodes[i].type === 'group') {
|
|
1111
|
+
this.moveChildren(nodes[i].id, dx, dy, nodes, visited);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
// Move multiple nodes (batch movement)
|
|
1117
|
+
moveNodes(moves) {
|
|
1118
|
+
console.log('moveNodes', moves.length);
|
|
1119
|
+
const GRID_SIZE = 20;
|
|
1120
|
+
let currentNodes = [...this.nodes()];
|
|
1121
|
+
this.alignmentGuides.set([]); // Clear guides during batch move for now
|
|
1122
|
+
moves.forEach(move => {
|
|
1123
|
+
const node = currentNodes.find(n => n.id === move.id);
|
|
1124
|
+
if (!node)
|
|
1125
|
+
return;
|
|
1126
|
+
// Simple grid snap for batch move
|
|
1127
|
+
const snappedX = Math.round(move.position.x / GRID_SIZE) * GRID_SIZE;
|
|
1128
|
+
const snappedY = Math.round(move.position.y / GRID_SIZE) * GRID_SIZE;
|
|
1129
|
+
const finalPosition = { x: snappedX, y: snappedY };
|
|
1130
|
+
const dx = finalPosition.x - node.position.x;
|
|
1131
|
+
const dy = finalPosition.y - node.position.y;
|
|
1132
|
+
// Update parent node
|
|
1133
|
+
currentNodes = currentNodes.map(n => n.id === move.id ? { ...n, position: finalPosition } : n);
|
|
1134
|
+
// If group, move children
|
|
1135
|
+
if (node.type === 'group') {
|
|
1136
|
+
this.moveChildren(node.id, dx, dy, currentNodes);
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
this.nodes.set(currentNodes);
|
|
1140
|
+
}
|
|
1141
|
+
setSearchQuery(query) {
|
|
1142
|
+
this.searchQuery.set(query);
|
|
1143
|
+
}
|
|
1144
|
+
setFilterType(type) {
|
|
1145
|
+
this.filterType.set(type);
|
|
1146
|
+
}
|
|
1147
|
+
setZoom(zoom) {
|
|
1148
|
+
this.viewport.update(v => ({ ...v, zoom }));
|
|
1149
|
+
}
|
|
1150
|
+
setContainerDimensions(dimensions) {
|
|
1151
|
+
this.containerDimensions.set(dimensions);
|
|
1152
|
+
}
|
|
1153
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: DiagramStateService, deps: [{ token: UndoRedoService }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1154
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: DiagramStateService, providedIn: 'root' });
|
|
1155
|
+
}
|
|
1156
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: DiagramStateService, decorators: [{
|
|
1157
|
+
type: Injectable,
|
|
1158
|
+
args: [{
|
|
1159
|
+
providedIn: 'root',
|
|
1160
|
+
}]
|
|
1161
|
+
}], ctorParameters: () => [{ type: UndoRedoService }] });
|
|
1162
|
+
|
|
1163
|
+
class MinimapComponent {
|
|
1164
|
+
diagramStateService;
|
|
1165
|
+
nodeColor = '#e2e2e2';
|
|
1166
|
+
nodeClass = '';
|
|
1167
|
+
// Initialize signals from service
|
|
1168
|
+
nodes;
|
|
1169
|
+
viewport;
|
|
1170
|
+
// Minimap dimensions
|
|
1171
|
+
width = 200;
|
|
1172
|
+
height = 150;
|
|
1173
|
+
constructor(diagramStateService) {
|
|
1174
|
+
this.diagramStateService = diagramStateService;
|
|
1175
|
+
this.nodes = this.diagramStateService.nodes;
|
|
1176
|
+
this.viewport = this.diagramStateService.viewport;
|
|
1177
|
+
}
|
|
1178
|
+
// Computed properties for rendering
|
|
1179
|
+
viewBox = computed(() => {
|
|
1180
|
+
const nodes = this.nodes();
|
|
1181
|
+
if (nodes.length === 0)
|
|
1182
|
+
return '0 0 100 100';
|
|
1183
|
+
const bounds = this.getBounds(nodes);
|
|
1184
|
+
const padding = 50;
|
|
1185
|
+
return `${bounds.minX - padding} ${bounds.minY - padding} ${bounds.width + padding * 2} ${bounds.height + padding * 2}`;
|
|
1186
|
+
}, ...(ngDevMode ? [{ debugName: "viewBox" }] : []));
|
|
1187
|
+
viewportIndicator = computed(() => {
|
|
1188
|
+
const v = this.viewport();
|
|
1189
|
+
const nodes = this.nodes();
|
|
1190
|
+
if (nodes.length === 0)
|
|
1191
|
+
return { x: 0, y: 0, width: 0, height: 0 };
|
|
1192
|
+
// We need to map the current viewport to the minimap coordinate system
|
|
1193
|
+
// This is a bit complex because the viewport transform is applied to the main diagram
|
|
1194
|
+
// and we need to show which part of the diagram is currently visible.
|
|
1195
|
+
// For now, let's just return a placeholder.
|
|
1196
|
+
// Real implementation requires knowing the diagram container size which we might need to get from service
|
|
1197
|
+
return { x: -v.x / v.zoom, y: -v.y / v.zoom, width: 1000 / v.zoom, height: 800 / v.zoom };
|
|
1198
|
+
}, ...(ngDevMode ? [{ debugName: "viewportIndicator" }] : []));
|
|
1199
|
+
getBounds(nodes) {
|
|
1200
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1201
|
+
nodes.forEach(node => {
|
|
1202
|
+
minX = Math.min(minX, node.position.x);
|
|
1203
|
+
minY = Math.min(minY, node.position.y);
|
|
1204
|
+
maxX = Math.max(maxX, node.position.x + (node.width || 170));
|
|
1205
|
+
maxY = Math.max(maxY, node.position.y + (node.height || 60));
|
|
1206
|
+
});
|
|
1207
|
+
return {
|
|
1208
|
+
minX,
|
|
1209
|
+
minY,
|
|
1210
|
+
width: maxX - minX,
|
|
1211
|
+
height: maxY - minY
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
onMinimapClick(event) {
|
|
1215
|
+
const svg = event.currentTarget;
|
|
1216
|
+
const rect = svg.getBoundingClientRect();
|
|
1217
|
+
// Click position relative to minimap
|
|
1218
|
+
const clickX = event.clientX - rect.left;
|
|
1219
|
+
const clickY = event.clientY - rect.top;
|
|
1220
|
+
// Convert to SVG coordinates
|
|
1221
|
+
// We need to parse the viewBox to know the scale
|
|
1222
|
+
const vb = this.viewBox().split(' ').map(parseFloat);
|
|
1223
|
+
const vbX = vb[0];
|
|
1224
|
+
const vbY = vb[1];
|
|
1225
|
+
const vbW = vb[2];
|
|
1226
|
+
const vbH = vb[3];
|
|
1227
|
+
const scaleX = vbW / rect.width;
|
|
1228
|
+
const scaleY = vbH / rect.height;
|
|
1229
|
+
const svgX = vbX + clickX * scaleX;
|
|
1230
|
+
const svgY = vbY + clickY * scaleY;
|
|
1231
|
+
// Center the viewport on this point
|
|
1232
|
+
// New viewport x = -svgX * zoom + viewportWidth / 2
|
|
1233
|
+
// We need the diagram dimensions to center perfectly, but for now we can approximate
|
|
1234
|
+
// or just move the top-left to this point.
|
|
1235
|
+
// Let's try to center.
|
|
1236
|
+
const currentZoom = this.viewport().zoom;
|
|
1237
|
+
// Assuming a default diagram size or getting it from service would be better
|
|
1238
|
+
// For now, let's just move to that position
|
|
1239
|
+
const newX = -svgX * currentZoom + 400; // 400 is approx half width
|
|
1240
|
+
const newY = -svgY * currentZoom + 300; // 300 is approx half height
|
|
1241
|
+
this.diagramStateService.setViewport({
|
|
1242
|
+
x: newX,
|
|
1243
|
+
y: newY,
|
|
1244
|
+
zoom: currentZoom
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: MinimapComponent, deps: [{ token: DiagramStateService }], target: i0.ɵɵFactoryTarget.Component });
|
|
1248
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.13", type: MinimapComponent, isStandalone: true, selector: "ngx-workflow-minimap", inputs: { nodeColor: "nodeColor", nodeClass: "nodeClass" }, ngImport: i0, template: "<div class=\"ngx-workflow__minimap\">\r\n <svg [attr.viewBox]=\"viewBox()\" class=\"ngx-workflow__minimap-svg\" (click)=\"onMinimapClick($event)\">\r\n <rect *ngFor=\"let node of nodes()\" [attr.x]=\"node.position.x\" [attr.y]=\"node.position.y\"\r\n [attr.width]=\"node.width || 170\" [attr.height]=\"node.height || 60\" [attr.fill]=\"nodeColor\" [class]=\"nodeClass\"\r\n class=\"ngx-workflow__minimap-node\" />\r\n <rect class=\"ngx-workflow__minimap-viewport\" [attr.x]=\"viewportIndicator().x\" [attr.y]=\"viewportIndicator().y\"\r\n [attr.width]=\"viewportIndicator().width\" [attr.height]=\"viewportIndicator().height\" />\r\n </svg>\r\n</div>", styles: [".ngx-workflow__minimap{position:absolute;bottom:20px;right:20px;width:200px;height:150px;background-color:#fff;border:1px solid #e2e2e2;border-radius:4px;box-shadow:0 2px 4px #0000001a;overflow:hidden;z-index:5}.ngx-workflow__minimap-svg{width:100%;height:100%;display:block}.ngx-workflow__minimap-node{stroke:none}.ngx-workflow__minimap-viewport{fill:#0000ff1a;stroke:#00f;stroke-width:1px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1249
|
+
}
|
|
1250
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: MinimapComponent, decorators: [{
|
|
1251
|
+
type: Component,
|
|
1252
|
+
args: [{ selector: 'ngx-workflow-minimap', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"ngx-workflow__minimap\">\r\n <svg [attr.viewBox]=\"viewBox()\" class=\"ngx-workflow__minimap-svg\" (click)=\"onMinimapClick($event)\">\r\n <rect *ngFor=\"let node of nodes()\" [attr.x]=\"node.position.x\" [attr.y]=\"node.position.y\"\r\n [attr.width]=\"node.width || 170\" [attr.height]=\"node.height || 60\" [attr.fill]=\"nodeColor\" [class]=\"nodeClass\"\r\n class=\"ngx-workflow__minimap-node\" />\r\n <rect class=\"ngx-workflow__minimap-viewport\" [attr.x]=\"viewportIndicator().x\" [attr.y]=\"viewportIndicator().y\"\r\n [attr.width]=\"viewportIndicator().width\" [attr.height]=\"viewportIndicator().height\" />\r\n </svg>\r\n</div>", styles: [".ngx-workflow__minimap{position:absolute;bottom:20px;right:20px;width:200px;height:150px;background-color:#fff;border:1px solid #e2e2e2;border-radius:4px;box-shadow:0 2px 4px #0000001a;overflow:hidden;z-index:5}.ngx-workflow__minimap-svg{width:100%;height:100%;display:block}.ngx-workflow__minimap-node{stroke:none}.ngx-workflow__minimap-viewport{fill:#0000ff1a;stroke:#00f;stroke-width:1px}\n"] }]
|
|
1253
|
+
}], ctorParameters: () => [{ type: DiagramStateService }], propDecorators: { nodeColor: [{
|
|
1254
|
+
type: Input
|
|
1255
|
+
}], nodeClass: [{
|
|
1256
|
+
type: Input
|
|
1257
|
+
}] } });
|
|
1258
|
+
|
|
1259
|
+
class BackgroundComponent {
|
|
1260
|
+
variant = 'dots';
|
|
1261
|
+
gap = 20;
|
|
1262
|
+
size = 1;
|
|
1263
|
+
color = '#81818a';
|
|
1264
|
+
backgroundColor = '#f0f0f0';
|
|
1265
|
+
diagramStateService = inject(DiagramStateService);
|
|
1266
|
+
// Computed property for pattern transform based on viewport
|
|
1267
|
+
patternTransform = computed(() => {
|
|
1268
|
+
const viewport = this.diagramStateService.viewport();
|
|
1269
|
+
return `translate(${viewport.x}, ${viewport.y}) scale(${viewport.zoom})`;
|
|
1270
|
+
}, ...(ngDevMode ? [{ debugName: "patternTransform" }] : []));
|
|
1271
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: BackgroundComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1272
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.13", type: BackgroundComponent, isStandalone: true, selector: "ngx-workflow-background", inputs: { variant: "variant", gap: "gap", size: "size", color: "color", backgroundColor: "backgroundColor" }, ngImport: i0, template: "<svg class=\"ngx-workflow__background\" [attr.width]=\"'100%'\" [attr.height]=\"'100%'\">\r\n <defs>\r\n <!-- Dots Pattern -->\r\n <pattern *ngIf=\"variant === 'dots'\" id=\"ngx-workflow__pattern-dots\" [attr.x]=\"0\" [attr.y]=\"0\" [attr.width]=\"gap\"\r\n [attr.height]=\"gap\" patternUnits=\"userSpaceOnUse\" [attr.patternTransform]=\"patternTransform()\">\r\n <circle [attr.cx]=\"size\" [attr.cy]=\"size\" [attr.r]=\"size\" [attr.fill]=\"color\" />\r\n </pattern>\r\n\r\n <!-- Lines Pattern -->\r\n <pattern *ngIf=\"variant === 'lines'\" id=\"ngx-workflow__pattern-lines\" [attr.x]=\"0\" [attr.y]=\"0\" [attr.width]=\"gap\"\r\n [attr.height]=\"gap\" patternUnits=\"userSpaceOnUse\" [attr.patternTransform]=\"patternTransform()\">\r\n <path [attr.d]=\"'M ' + gap + ' 0 L 0 0 0 ' + gap\" [attr.fill]=\"'none'\" [attr.stroke]=\"color\"\r\n [attr.stroke-width]=\"size\" />\r\n </pattern>\r\n\r\n <!-- Cross Pattern -->\r\n <pattern *ngIf=\"variant === 'cross'\" id=\"ngx-workflow__pattern-cross\" [attr.x]=\"0\" [attr.y]=\"0\" [attr.width]=\"gap\"\r\n [attr.height]=\"gap\" patternUnits=\"userSpaceOnUse\" [attr.patternTransform]=\"patternTransform()\">\r\n <path [attr.d]=\"'M ' + gap + ' 0 L 0 0 0 ' + gap\" [attr.fill]=\"'none'\" [attr.stroke]=\"color\"\r\n [attr.stroke-width]=\"size\" />\r\n <path [attr.d]=\"'M 0 ' + (gap/2) + ' L ' + gap + ' ' + (gap/2) + ' M ' + (gap/2) + ' 0 L ' + (gap/2) + ' ' + gap\"\r\n [attr.fill]=\"'none'\" [attr.stroke]=\"color\" [attr.stroke-width]=\"size\" />\r\n </pattern>\r\n </defs>\r\n\r\n <rect width=\"100%\" height=\"100%\" [attr.fill]=\"backgroundColor\" />\r\n <rect width=\"100%\" height=\"100%\" [attr.fill]=\"'url(#ngx-workflow__pattern-' + variant + ')'\" />\r\n</svg>", styles: [".ngx-workflow__background{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
|
|
1273
|
+
}
|
|
1274
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: BackgroundComponent, decorators: [{
|
|
1275
|
+
type: Component,
|
|
1276
|
+
args: [{ selector: 'ngx-workflow-background', standalone: true, imports: [CommonModule], template: "<svg class=\"ngx-workflow__background\" [attr.width]=\"'100%'\" [attr.height]=\"'100%'\">\r\n <defs>\r\n <!-- Dots Pattern -->\r\n <pattern *ngIf=\"variant === 'dots'\" id=\"ngx-workflow__pattern-dots\" [attr.x]=\"0\" [attr.y]=\"0\" [attr.width]=\"gap\"\r\n [attr.height]=\"gap\" patternUnits=\"userSpaceOnUse\" [attr.patternTransform]=\"patternTransform()\">\r\n <circle [attr.cx]=\"size\" [attr.cy]=\"size\" [attr.r]=\"size\" [attr.fill]=\"color\" />\r\n </pattern>\r\n\r\n <!-- Lines Pattern -->\r\n <pattern *ngIf=\"variant === 'lines'\" id=\"ngx-workflow__pattern-lines\" [attr.x]=\"0\" [attr.y]=\"0\" [attr.width]=\"gap\"\r\n [attr.height]=\"gap\" patternUnits=\"userSpaceOnUse\" [attr.patternTransform]=\"patternTransform()\">\r\n <path [attr.d]=\"'M ' + gap + ' 0 L 0 0 0 ' + gap\" [attr.fill]=\"'none'\" [attr.stroke]=\"color\"\r\n [attr.stroke-width]=\"size\" />\r\n </pattern>\r\n\r\n <!-- Cross Pattern -->\r\n <pattern *ngIf=\"variant === 'cross'\" id=\"ngx-workflow__pattern-cross\" [attr.x]=\"0\" [attr.y]=\"0\" [attr.width]=\"gap\"\r\n [attr.height]=\"gap\" patternUnits=\"userSpaceOnUse\" [attr.patternTransform]=\"patternTransform()\">\r\n <path [attr.d]=\"'M ' + gap + ' 0 L 0 0 0 ' + gap\" [attr.fill]=\"'none'\" [attr.stroke]=\"color\"\r\n [attr.stroke-width]=\"size\" />\r\n <path [attr.d]=\"'M 0 ' + (gap/2) + ' L ' + gap + ' ' + (gap/2) + ' M ' + (gap/2) + ' 0 L ' + (gap/2) + ' ' + gap\"\r\n [attr.fill]=\"'none'\" [attr.stroke]=\"color\" [attr.stroke-width]=\"size\" />\r\n </pattern>\r\n </defs>\r\n\r\n <rect width=\"100%\" height=\"100%\" [attr.fill]=\"backgroundColor\" />\r\n <rect width=\"100%\" height=\"100%\" [attr.fill]=\"'url(#ngx-workflow__pattern-' + variant + ')'\" />\r\n</svg>", styles: [".ngx-workflow__background{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0}\n"] }]
|
|
1277
|
+
}], propDecorators: { variant: [{
|
|
1278
|
+
type: Input
|
|
1279
|
+
}], gap: [{
|
|
1280
|
+
type: Input
|
|
1281
|
+
}], size: [{
|
|
1282
|
+
type: Input
|
|
1283
|
+
}], color: [{
|
|
1284
|
+
type: Input
|
|
1285
|
+
}], backgroundColor: [{
|
|
1286
|
+
type: Input
|
|
1287
|
+
}] } });
|
|
1288
|
+
|
|
1289
|
+
class AlignmentControlsComponent {
|
|
1290
|
+
diagramStateService;
|
|
1291
|
+
// Only show controls if more than 1 node is selected
|
|
1292
|
+
showControls = computed(() => this.diagramStateService.selectedNodes().length > 1, ...(ngDevMode ? [{ debugName: "showControls" }] : []));
|
|
1293
|
+
// Only show distribution controls if more than 2 nodes are selected
|
|
1294
|
+
showDistribution = computed(() => this.diagramStateService.selectedNodes().length > 2, ...(ngDevMode ? [{ debugName: "showDistribution" }] : []));
|
|
1295
|
+
constructor(diagramStateService) {
|
|
1296
|
+
this.diagramStateService = diagramStateService;
|
|
1297
|
+
}
|
|
1298
|
+
align(alignment) {
|
|
1299
|
+
this.diagramStateService.alignNodes(alignment);
|
|
1300
|
+
}
|
|
1301
|
+
distribute(distribution) {
|
|
1302
|
+
this.diagramStateService.distributeNodes(distribution);
|
|
1303
|
+
}
|
|
1304
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: AlignmentControlsComponent, deps: [{ token: DiagramStateService }], target: i0.ɵɵFactoryTarget.Component });
|
|
1305
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.13", type: AlignmentControlsComponent, isStandalone: true, selector: "ngx-workflow-alignment-controls", ngImport: i0, template: "<div class=\"ngx-workflow__alignment-controls\" *ngIf=\"showControls()\">\r\n <div class=\"ngx-workflow__alignment-group\">\r\n <button (click)=\"align('left')\" title=\"Align Left\">Left</button>\r\n <button (click)=\"align('center')\" title=\"Align Center\">Center</button>\r\n <button (click)=\"align('right')\" title=\"Align Right\">Right</button>\r\n </div>\r\n <div class=\"ngx-workflow__alignment-group\">\r\n <button (click)=\"align('top')\" title=\"Align Top\">Top</button>\r\n <button (click)=\"align('middle')\" title=\"Align Middle\">Middle</button>\r\n <button (click)=\"align('bottom')\" title=\"Align Bottom\">Bottom</button>\r\n </div>\r\n <div class=\"ngx-workflow__alignment-group\" *ngIf=\"showDistribution()\">\r\n <button (click)=\"distribute('horizontal')\" title=\"Distribute Horizontally\">Dist H</button>\r\n <button (click)=\"distribute('vertical')\" title=\"Distribute Vertically\">Dist V</button>\r\n </div>\r\n</div>", styles: [".ngx-workflow__alignment-controls{position:absolute;top:20px;left:50%;transform:translate(-50%);background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:8px;display:flex;gap:12px;box-shadow:0 4px 6px #0000001a;z-index:10}.ngx-workflow__alignment-group{display:flex;gap:4px;align-items:center}.ngx-workflow__alignment-group:not(:last-child){padding-right:12px;border-right:1px solid #e0e0e0}button{background:none;border:1px solid transparent;border-radius:4px;padding:4px 8px;font-size:12px;cursor:pointer;color:#555;transition:all .2s}button:hover{background:#f5f5f5;border-color:#ddd;color:#333}button:active{background:#e0e0e0}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1306
|
+
}
|
|
1307
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: AlignmentControlsComponent, decorators: [{
|
|
1308
|
+
type: Component,
|
|
1309
|
+
args: [{ selector: 'ngx-workflow-alignment-controls', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"ngx-workflow__alignment-controls\" *ngIf=\"showControls()\">\r\n <div class=\"ngx-workflow__alignment-group\">\r\n <button (click)=\"align('left')\" title=\"Align Left\">Left</button>\r\n <button (click)=\"align('center')\" title=\"Align Center\">Center</button>\r\n <button (click)=\"align('right')\" title=\"Align Right\">Right</button>\r\n </div>\r\n <div class=\"ngx-workflow__alignment-group\">\r\n <button (click)=\"align('top')\" title=\"Align Top\">Top</button>\r\n <button (click)=\"align('middle')\" title=\"Align Middle\">Middle</button>\r\n <button (click)=\"align('bottom')\" title=\"Align Bottom\">Bottom</button>\r\n </div>\r\n <div class=\"ngx-workflow__alignment-group\" *ngIf=\"showDistribution()\">\r\n <button (click)=\"distribute('horizontal')\" title=\"Distribute Horizontally\">Dist H</button>\r\n <button (click)=\"distribute('vertical')\" title=\"Distribute Vertically\">Dist V</button>\r\n </div>\r\n</div>", styles: [".ngx-workflow__alignment-controls{position:absolute;top:20px;left:50%;transform:translate(-50%);background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:8px;display:flex;gap:12px;box-shadow:0 4px 6px #0000001a;z-index:10}.ngx-workflow__alignment-group{display:flex;gap:4px;align-items:center}.ngx-workflow__alignment-group:not(:last-child){padding-right:12px;border-right:1px solid #e0e0e0}button{background:none;border:1px solid transparent;border-radius:4px;padding:4px 8px;font-size:12px;cursor:pointer;color:#555;transition:all .2s}button:hover{background:#f5f5f5;border-color:#ddd;color:#333}button:active{background:#e0e0e0}\n"] }]
|
|
1310
|
+
}], ctorParameters: () => [{ type: DiagramStateService }] });
|
|
1311
|
+
|
|
1312
|
+
class PropertiesSidebarComponent {
|
|
1313
|
+
node = null;
|
|
1314
|
+
close = new EventEmitter();
|
|
1315
|
+
change = new EventEmitter();
|
|
1316
|
+
updateLabel(label) {
|
|
1317
|
+
if (!this.node)
|
|
1318
|
+
return;
|
|
1319
|
+
this.change.emit({ label });
|
|
1320
|
+
}
|
|
1321
|
+
updateWidth(width) {
|
|
1322
|
+
this.change.emit({ width });
|
|
1323
|
+
}
|
|
1324
|
+
updateHeight(height) {
|
|
1325
|
+
this.change.emit({ height });
|
|
1326
|
+
}
|
|
1327
|
+
updateX(x) {
|
|
1328
|
+
if (!this.node)
|
|
1329
|
+
return;
|
|
1330
|
+
this.change.emit({ position: { x, y: this.node.position.y } });
|
|
1331
|
+
}
|
|
1332
|
+
updateY(y) {
|
|
1333
|
+
if (!this.node)
|
|
1334
|
+
return;
|
|
1335
|
+
this.change.emit({ position: { x: this.node.position.x, y } });
|
|
1336
|
+
}
|
|
1337
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: PropertiesSidebarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1338
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.13", type: PropertiesSidebarComponent, isStandalone: true, selector: "ngx-workflow-properties-sidebar", inputs: { node: "node" }, outputs: { close: "close", change: "change" }, ngImport: i0, template: "<div class=\"ngx-workflow__sidebar\" [class.open]=\"!!node\">\r\n <div class=\"ngx-workflow__sidebar-header\">\r\n <h3>Node Properties</h3>\r\n <button class=\"ngx-workflow__close-btn\" (click)=\"close.emit()\">\u00D7</button>\r\n </div>\r\n\r\n <div class=\"ngx-workflow__sidebar-content\" *ngIf=\"node\">\r\n <div class=\"ngx-workflow__form-group\">\r\n <label>ID</label>\r\n <input type=\"text\" [value]=\"node.id\" disabled class=\"ngx-workflow__input disabled\">\r\n </div>\r\n\r\n <div class=\"ngx-workflow__form-group\">\r\n <label>Label</label>\r\n <input type=\"text\" [ngModel]=\"node.label\" (ngModelChange)=\"updateLabel($event)\" class=\"ngx-workflow__input\">\r\n </div>\r\n\r\n <div class=\"ngx-workflow__form-row\">\r\n <div class=\"ngx-workflow__form-group\">\r\n <label>X</label>\r\n <input type=\"number\" [ngModel]=\"node.position.x\" (ngModelChange)=\"updateX($event)\"\r\n class=\"ngx-workflow__input\">\r\n </div>\r\n <div class=\"ngx-workflow__form-group\">\r\n <label>Y</label>\r\n <input type=\"number\" [ngModel]=\"node.position.y\" (ngModelChange)=\"updateY($event)\"\r\n class=\"ngx-workflow__input\">\r\n </div>\r\n </div>\r\n\r\n <div class=\"ngx-workflow__form-row\">\r\n <div class=\"ngx-workflow__form-group\">\r\n <label>Width</label>\r\n <input type=\"number\" [ngModel]=\"node.width || 170\" (ngModelChange)=\"updateWidth($event)\"\r\n class=\"ngx-workflow__input\">\r\n </div>\r\n <div class=\"ngx-workflow__form-group\">\r\n <label>Height</label>\r\n <input type=\"number\" [ngModel]=\"node.height || 60\" (ngModelChange)=\"updateHeight($event)\"\r\n class=\"ngx-workflow__input\">\r\n </div>\r\n </div>\r\n </div>\r\n</div>", styles: [".ngx-workflow__sidebar{position:absolute;top:20px;right:20px;width:300px;background:var(--ngx-workflow-glass-bg);-webkit-backdrop-filter:var(--ngx-workflow-glass-blur);backdrop-filter:var(--ngx-workflow-glass-blur);border:1px solid var(--ngx-workflow-border);border-radius:12px;box-shadow:var(--ngx-workflow-shadow-lg);padding:0;transform:translate(120%);transition:transform .3s cubic-bezier(.4,0,.2,1);z-index:50;overflow:hidden}.ngx-workflow__sidebar.open{transform:translate(0)}.ngx-workflow__sidebar-header{display:flex;justify-content:space-between;align-items:center;padding:16px;border-bottom:1px solid var(--ngx-workflow-border);background:#ffffff80}.ngx-workflow__sidebar-header h3{margin:0;font-size:16px;font-weight:600;color:var(--ngx-workflow-text-primary)}.ngx-workflow__sidebar-content{padding:16px;display:flex;flex-direction:column;gap:16px}.ngx-workflow__close-btn{background:none;border:none;font-size:20px;color:var(--ngx-workflow-text-secondary);cursor:pointer;padding:4px;border-radius:4px;line-height:1}.ngx-workflow__close-btn:hover{background:#0000000d;color:var(--ngx-workflow-text-primary)}.ngx-workflow__form-group{display:flex;flex-direction:column;gap:6px}.ngx-workflow__form-group label{font-size:12px;font-weight:500;color:var(--ngx-workflow-text-secondary)}.ngx-workflow__form-row{display:flex;gap:12px}.ngx-workflow__form-row .ngx-workflow__form-group{flex:1}.ngx-workflow__input{padding:8px 12px;border-radius:6px;border:1px solid var(--ngx-workflow-border);background:var(--ngx-workflow-surface);color:var(--ngx-workflow-text-primary);font-size:14px;outline:none;transition:all .2s ease;width:100%;box-sizing:border-box}.ngx-workflow__input:focus{border-color:var(--ngx-workflow-primary);box-shadow:0 0 0 2px #3b82f61a}.ngx-workflow__input.disabled{background:#00000005;color:var(--ngx-workflow-text-secondary);cursor:not-allowed}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2$1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i2$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }] });
|
|
1339
|
+
}
|
|
1340
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: PropertiesSidebarComponent, decorators: [{
|
|
1341
|
+
type: Component,
|
|
1342
|
+
args: [{ selector: 'ngx-workflow-properties-sidebar', standalone: true, imports: [CommonModule, FormsModule], template: "<div class=\"ngx-workflow__sidebar\" [class.open]=\"!!node\">\r\n <div class=\"ngx-workflow__sidebar-header\">\r\n <h3>Node Properties</h3>\r\n <button class=\"ngx-workflow__close-btn\" (click)=\"close.emit()\">\u00D7</button>\r\n </div>\r\n\r\n <div class=\"ngx-workflow__sidebar-content\" *ngIf=\"node\">\r\n <div class=\"ngx-workflow__form-group\">\r\n <label>ID</label>\r\n <input type=\"text\" [value]=\"node.id\" disabled class=\"ngx-workflow__input disabled\">\r\n </div>\r\n\r\n <div class=\"ngx-workflow__form-group\">\r\n <label>Label</label>\r\n <input type=\"text\" [ngModel]=\"node.label\" (ngModelChange)=\"updateLabel($event)\" class=\"ngx-workflow__input\">\r\n </div>\r\n\r\n <div class=\"ngx-workflow__form-row\">\r\n <div class=\"ngx-workflow__form-group\">\r\n <label>X</label>\r\n <input type=\"number\" [ngModel]=\"node.position.x\" (ngModelChange)=\"updateX($event)\"\r\n class=\"ngx-workflow__input\">\r\n </div>\r\n <div class=\"ngx-workflow__form-group\">\r\n <label>Y</label>\r\n <input type=\"number\" [ngModel]=\"node.position.y\" (ngModelChange)=\"updateY($event)\"\r\n class=\"ngx-workflow__input\">\r\n </div>\r\n </div>\r\n\r\n <div class=\"ngx-workflow__form-row\">\r\n <div class=\"ngx-workflow__form-group\">\r\n <label>Width</label>\r\n <input type=\"number\" [ngModel]=\"node.width || 170\" (ngModelChange)=\"updateWidth($event)\"\r\n class=\"ngx-workflow__input\">\r\n </div>\r\n <div class=\"ngx-workflow__form-group\">\r\n <label>Height</label>\r\n <input type=\"number\" [ngModel]=\"node.height || 60\" (ngModelChange)=\"updateHeight($event)\"\r\n class=\"ngx-workflow__input\">\r\n </div>\r\n </div>\r\n </div>\r\n</div>", styles: [".ngx-workflow__sidebar{position:absolute;top:20px;right:20px;width:300px;background:var(--ngx-workflow-glass-bg);-webkit-backdrop-filter:var(--ngx-workflow-glass-blur);backdrop-filter:var(--ngx-workflow-glass-blur);border:1px solid var(--ngx-workflow-border);border-radius:12px;box-shadow:var(--ngx-workflow-shadow-lg);padding:0;transform:translate(120%);transition:transform .3s cubic-bezier(.4,0,.2,1);z-index:50;overflow:hidden}.ngx-workflow__sidebar.open{transform:translate(0)}.ngx-workflow__sidebar-header{display:flex;justify-content:space-between;align-items:center;padding:16px;border-bottom:1px solid var(--ngx-workflow-border);background:#ffffff80}.ngx-workflow__sidebar-header h3{margin:0;font-size:16px;font-weight:600;color:var(--ngx-workflow-text-primary)}.ngx-workflow__sidebar-content{padding:16px;display:flex;flex-direction:column;gap:16px}.ngx-workflow__close-btn{background:none;border:none;font-size:20px;color:var(--ngx-workflow-text-secondary);cursor:pointer;padding:4px;border-radius:4px;line-height:1}.ngx-workflow__close-btn:hover{background:#0000000d;color:var(--ngx-workflow-text-primary)}.ngx-workflow__form-group{display:flex;flex-direction:column;gap:6px}.ngx-workflow__form-group label{font-size:12px;font-weight:500;color:var(--ngx-workflow-text-secondary)}.ngx-workflow__form-row{display:flex;gap:12px}.ngx-workflow__form-row .ngx-workflow__form-group{flex:1}.ngx-workflow__input{padding:8px 12px;border-radius:6px;border:1px solid var(--ngx-workflow-border);background:var(--ngx-workflow-surface);color:var(--ngx-workflow-text-primary);font-size:14px;outline:none;transition:all .2s ease;width:100%;box-sizing:border-box}.ngx-workflow__input:focus{border-color:var(--ngx-workflow-primary);box-shadow:0 0 0 2px #3b82f61a}.ngx-workflow__input.disabled{background:#00000005;color:var(--ngx-workflow-text-secondary);cursor:not-allowed}\n"] }]
|
|
1343
|
+
}], propDecorators: { node: [{
|
|
1344
|
+
type: Input
|
|
1345
|
+
}], close: [{
|
|
1346
|
+
type: Output
|
|
1347
|
+
}], change: [{
|
|
1348
|
+
type: Output
|
|
1349
|
+
}] } });
|
|
1350
|
+
|
|
1351
|
+
// Helper function to get a node from the array
|
|
1352
|
+
function getNode(id, nodes) {
|
|
1353
|
+
return nodes.find(n => n.id === id);
|
|
1354
|
+
}
|
|
1355
|
+
// Helper function to determine handle position based on node and handle id/type
|
|
1356
|
+
function getHandleAbsolutePosition(node, handleId) {
|
|
1357
|
+
const nodeWidth = node.width || 170;
|
|
1358
|
+
const nodeHeight = node.height || 60;
|
|
1359
|
+
let offsetX = 0;
|
|
1360
|
+
let offsetY = 0;
|
|
1361
|
+
switch (handleId) {
|
|
1362
|
+
case 'top':
|
|
1363
|
+
offsetX = nodeWidth / 2;
|
|
1364
|
+
offsetY = 0;
|
|
1365
|
+
break;
|
|
1366
|
+
case 'right':
|
|
1367
|
+
offsetX = nodeWidth;
|
|
1368
|
+
offsetY = nodeHeight / 2;
|
|
1369
|
+
break;
|
|
1370
|
+
case 'bottom':
|
|
1371
|
+
offsetX = nodeWidth / 2;
|
|
1372
|
+
offsetY = nodeHeight;
|
|
1373
|
+
break;
|
|
1374
|
+
case 'left':
|
|
1375
|
+
offsetX = 0;
|
|
1376
|
+
offsetY = nodeHeight / 2;
|
|
1377
|
+
break;
|
|
1378
|
+
default: // Center of the node if no specific handle
|
|
1379
|
+
offsetX = nodeWidth / 2;
|
|
1380
|
+
offsetY = nodeHeight / 2;
|
|
1381
|
+
}
|
|
1382
|
+
return {
|
|
1383
|
+
x: node.position.x + offsetX,
|
|
1384
|
+
y: node.position.y + offsetY
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
class DiagramComponent {
|
|
1388
|
+
el;
|
|
1389
|
+
renderer;
|
|
1390
|
+
ngZone;
|
|
1391
|
+
cdRef;
|
|
1392
|
+
diagramStateService;
|
|
1393
|
+
nodeTypes;
|
|
1394
|
+
// Trigger rebuild
|
|
1395
|
+
svgRef;
|
|
1396
|
+
// Input properties for declarative usage
|
|
1397
|
+
initialNodes = [];
|
|
1398
|
+
initialEdges = [];
|
|
1399
|
+
initialViewport;
|
|
1400
|
+
showZoomControls = true;
|
|
1401
|
+
// Input for showing/hiding minimap
|
|
1402
|
+
showMinimap = true;
|
|
1403
|
+
// Input for background configuration
|
|
1404
|
+
showBackground = true;
|
|
1405
|
+
backgroundVariant = 'dots';
|
|
1406
|
+
backgroundGap = 20;
|
|
1407
|
+
backgroundSize = 1;
|
|
1408
|
+
backgroundColor = '#81818a';
|
|
1409
|
+
backgroundBgColor = '#f0f0f0';
|
|
1410
|
+
// Output events
|
|
1411
|
+
nodeClick = new EventEmitter();
|
|
1412
|
+
edgeClick = new EventEmitter();
|
|
1413
|
+
connect = new EventEmitter();
|
|
1414
|
+
nodesChange = new EventEmitter();
|
|
1415
|
+
edgesChange = new EventEmitter();
|
|
1416
|
+
nodeDoubleClick = new EventEmitter();
|
|
1417
|
+
// Sidebar State
|
|
1418
|
+
selectedNodeForEditing = null;
|
|
1419
|
+
viewport;
|
|
1420
|
+
nodes;
|
|
1421
|
+
viewNodes;
|
|
1422
|
+
filteredNodes;
|
|
1423
|
+
edges;
|
|
1424
|
+
tempEdges;
|
|
1425
|
+
alignmentGuides;
|
|
1426
|
+
// Expose Math to the template
|
|
1427
|
+
Math = Math;
|
|
1428
|
+
_pathFinder = null;
|
|
1429
|
+
pathCache = new Map();
|
|
1430
|
+
dragAnimationFrameId = null;
|
|
1431
|
+
unlistenPointerMove = null;
|
|
1432
|
+
unlistenPointerUp = null;
|
|
1433
|
+
unlistenPointerLeave = null;
|
|
1434
|
+
pathPointsCache = new Map();
|
|
1435
|
+
isPanning = false;
|
|
1436
|
+
lastPanPosition = { x: 0, y: 0 };
|
|
1437
|
+
subscriptions = new Subscription();
|
|
1438
|
+
// Lasso selection properties
|
|
1439
|
+
isSelecting = false;
|
|
1440
|
+
selectionStart = { x: 0, y: 0 };
|
|
1441
|
+
selectionEnd = { x: 0, y: 0 };
|
|
1442
|
+
// Node Dragging
|
|
1443
|
+
isDraggingNode = false;
|
|
1444
|
+
draggingNode = null;
|
|
1445
|
+
draggingNodes = []; // All nodes being dragged (for multi-select)
|
|
1446
|
+
startNodePosition = { x: 0, y: 0 };
|
|
1447
|
+
startNodePositions = new Map(); // Initial positions for multi-drag
|
|
1448
|
+
startPointerPosition = { x: 0, y: 0 };
|
|
1449
|
+
// Connection (Handle)
|
|
1450
|
+
isConnecting = false;
|
|
1451
|
+
currentPreviewEdgeId = null;
|
|
1452
|
+
currentTargetHandle = null;
|
|
1453
|
+
connectingSourceNodeId = null;
|
|
1454
|
+
connectingSourceHandleId = undefined;
|
|
1455
|
+
// Resizing
|
|
1456
|
+
isResizing = false;
|
|
1457
|
+
resizingNode = null;
|
|
1458
|
+
resizeHandle = null;
|
|
1459
|
+
startResizePosition = { x: 0, y: 0 };
|
|
1460
|
+
startNodeDimensions = { width: 0, height: 0, x: 0, y: 0 };
|
|
1461
|
+
// Edge Updating
|
|
1462
|
+
isUpdatingEdge = false;
|
|
1463
|
+
updatingEdge = null;
|
|
1464
|
+
updatingEdgeHandle = null;
|
|
1465
|
+
// Edge Label Editing
|
|
1466
|
+
editingEdgeId = null;
|
|
1467
|
+
updatePathFinder(nodes) {
|
|
1468
|
+
this.pathCache.clear();
|
|
1469
|
+
this.pathPointsCache.clear();
|
|
1470
|
+
this._pathFinder = new PathFinder(nodes.map(n => ({
|
|
1471
|
+
id: n.id,
|
|
1472
|
+
x: n.position.x,
|
|
1473
|
+
y: n.position.y,
|
|
1474
|
+
width: n.width || this.defaultNodeWidth,
|
|
1475
|
+
height: n.height || this.defaultNodeHeight
|
|
1476
|
+
})));
|
|
1477
|
+
}
|
|
1478
|
+
onEdgeDoubleClick(event, edge) {
|
|
1479
|
+
event.stopPropagation();
|
|
1480
|
+
event.preventDefault();
|
|
1481
|
+
this.editingEdgeId = edge.id;
|
|
1482
|
+
}
|
|
1483
|
+
onNodeDoubleClick(event, node) {
|
|
1484
|
+
console.log('Node double clicked:', node);
|
|
1485
|
+
event.stopPropagation();
|
|
1486
|
+
this.selectedNodeForEditing = node;
|
|
1487
|
+
this.nodeDoubleClick.emit(node);
|
|
1488
|
+
this.cdRef.detectChanges();
|
|
1489
|
+
}
|
|
1490
|
+
onDiagramDoubleClick(event) {
|
|
1491
|
+
let target = event.target;
|
|
1492
|
+
// If target is SVG (likely due to capture), find the actual element under cursor
|
|
1493
|
+
if (target === this.svgRef.nativeElement || target.classList.contains('ngx-workflow__background')) {
|
|
1494
|
+
const element = document.elementFromPoint(event.clientX, event.clientY);
|
|
1495
|
+
if (element) {
|
|
1496
|
+
target = element;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
const nodeElement = target.closest('.ngx-workflow__node');
|
|
1500
|
+
if (nodeElement) {
|
|
1501
|
+
const nodeId = nodeElement.dataset['id'];
|
|
1502
|
+
const node = this.nodes().find(n => n.id === nodeId);
|
|
1503
|
+
if (node) {
|
|
1504
|
+
this.onNodeDoubleClick(event, node);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
closeSidebar() {
|
|
1509
|
+
this.selectedNodeForEditing = null;
|
|
1510
|
+
}
|
|
1511
|
+
onPropertiesChange(changes) {
|
|
1512
|
+
if (this.selectedNodeForEditing) {
|
|
1513
|
+
this.diagramStateService.updateNode(this.selectedNodeForEditing.id, changes);
|
|
1514
|
+
// Update local reference to keep sidebar in sync
|
|
1515
|
+
this.selectedNodeForEditing = { ...this.selectedNodeForEditing, ...changes };
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
updateEdgeLabel(edge, newLabel) {
|
|
1519
|
+
this.diagramStateService.updateEdge(edge.id, { label: newLabel });
|
|
1520
|
+
this.editingEdgeId = null;
|
|
1521
|
+
}
|
|
1522
|
+
onEdgeLabelBlur() {
|
|
1523
|
+
this.editingEdgeId = null;
|
|
1524
|
+
}
|
|
1525
|
+
// Default node dimensions
|
|
1526
|
+
defaultNodeWidth = 170;
|
|
1527
|
+
defaultNodeHeight = 60;
|
|
1528
|
+
// Input for custom connection validation (optional)
|
|
1529
|
+
connectionValidator;
|
|
1530
|
+
// Input for node resizing (global toggle)
|
|
1531
|
+
nodesResizable = true;
|
|
1532
|
+
// Helper to check if a connection is allowed
|
|
1533
|
+
isValidConnection(sourceId, targetId) {
|
|
1534
|
+
// console.log('isValidConnection checking:', sourceId, targetId);
|
|
1535
|
+
// Prevent duplicate edges between same source and target
|
|
1536
|
+
const existing = this.edges().some(e => e.source === sourceId && e.target === targetId);
|
|
1537
|
+
if (existing) {
|
|
1538
|
+
// console.log('isValidConnection: existing edge found');
|
|
1539
|
+
return false;
|
|
1540
|
+
}
|
|
1541
|
+
// Use custom validator if provided
|
|
1542
|
+
if (this.connectionValidator) {
|
|
1543
|
+
return this.connectionValidator(sourceId, targetId);
|
|
1544
|
+
}
|
|
1545
|
+
return true;
|
|
1546
|
+
}
|
|
1547
|
+
constructor(el, // Host element
|
|
1548
|
+
renderer, ngZone, cdRef, diagramStateService, nodeTypes) {
|
|
1549
|
+
this.el = el;
|
|
1550
|
+
this.renderer = renderer;
|
|
1551
|
+
this.ngZone = ngZone;
|
|
1552
|
+
this.cdRef = cdRef;
|
|
1553
|
+
this.diagramStateService = diagramStateService;
|
|
1554
|
+
this.nodeTypes = nodeTypes;
|
|
1555
|
+
this.nodes$ = toObservable(this.diagramStateService.nodes);
|
|
1556
|
+
}
|
|
1557
|
+
get nodeTypeKeys() {
|
|
1558
|
+
return this.nodeTypes ? Object.keys(this.nodeTypes) : [];
|
|
1559
|
+
}
|
|
1560
|
+
nodes$;
|
|
1561
|
+
resizeObserver;
|
|
1562
|
+
ngOnInit() {
|
|
1563
|
+
this.diagramStateService.el = this.svgRef;
|
|
1564
|
+
this.viewport = this.diagramStateService.viewport;
|
|
1565
|
+
this.nodes = this.diagramStateService.nodes;
|
|
1566
|
+
this.filteredNodes = this.diagramStateService.visibleNodes; // Use visibleNodes for rendering
|
|
1567
|
+
this.edges = this.diagramStateService.edges;
|
|
1568
|
+
this.tempEdges = this.diagramStateService.tempEdges;
|
|
1569
|
+
this.alignmentGuides = this.diagramStateService.alignmentGuides;
|
|
1570
|
+
if (this.initialNodes.length > 0) {
|
|
1571
|
+
this.initialNodes.forEach(node => this.diagramStateService.addNode(node));
|
|
1572
|
+
}
|
|
1573
|
+
if (this.initialEdges.length > 0) {
|
|
1574
|
+
// Add initial edges directly to the signal without triggering connect events
|
|
1575
|
+
this.diagramStateService.edges.set([...this.initialEdges]);
|
|
1576
|
+
}
|
|
1577
|
+
if (this.initialViewport) {
|
|
1578
|
+
this.diagramStateService.setViewport(this.initialViewport);
|
|
1579
|
+
}
|
|
1580
|
+
// Subscribe to state changes and emit events
|
|
1581
|
+
this.subscriptions.add(this.diagramStateService.nodeClick.subscribe((node) => this.nodeClick.emit(node)));
|
|
1582
|
+
this.subscriptions.add(this.diagramStateService.edgeClick.subscribe((edge) => this.edgeClick.emit(edge)));
|
|
1583
|
+
this.subscriptions.add(this.diagramStateService.connect.subscribe((connection) => this.connect.emit(connection)));
|
|
1584
|
+
this.subscriptions.add(this.nodes$.subscribe(nodes => {
|
|
1585
|
+
this.nodes.set(nodes);
|
|
1586
|
+
if (!this.isDraggingNode) {
|
|
1587
|
+
this.updatePathFinder(nodes);
|
|
1588
|
+
this.nodesChange.emit(nodes);
|
|
1589
|
+
}
|
|
1590
|
+
}));
|
|
1591
|
+
this.subscriptions.add(this.diagramStateService.edgesChange.subscribe((edges) => this.edgesChange.emit(edges)));
|
|
1592
|
+
// Initialize ResizeObserver
|
|
1593
|
+
this.resizeObserver = new ResizeObserver(entries => {
|
|
1594
|
+
for (const entry of entries) {
|
|
1595
|
+
const { width, height } = entry.contentRect;
|
|
1596
|
+
this.diagramStateService.setContainerDimensions({ width, height });
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
// We need to observe the container, but we only have el (host) or svgRef.
|
|
1600
|
+
// Let's observe the host element.
|
|
1601
|
+
this.resizeObserver.observe(this.el.nativeElement);
|
|
1602
|
+
this.ngZone.runOutsideAngular(() => {
|
|
1603
|
+
this.unlistenPointerMove = this.renderer.listen(this.svgRef.nativeElement, 'pointermove', (event) => {
|
|
1604
|
+
this.onPointerMove(event);
|
|
1605
|
+
});
|
|
1606
|
+
this.unlistenPointerUp = this.renderer.listen(this.svgRef.nativeElement, 'pointerup', (event) => {
|
|
1607
|
+
this.onPointerUp(event);
|
|
1608
|
+
});
|
|
1609
|
+
this.unlistenPointerLeave = this.renderer.listen(this.svgRef.nativeElement, 'pointerleave', (event) => {
|
|
1610
|
+
this.onPointerLeave(event);
|
|
1611
|
+
});
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
ngOnDestroy() {
|
|
1615
|
+
this.subscriptions.unsubscribe();
|
|
1616
|
+
if (this.resizeObserver) {
|
|
1617
|
+
this.resizeObserver.disconnect();
|
|
1618
|
+
}
|
|
1619
|
+
if (this.unlistenPointerMove)
|
|
1620
|
+
this.unlistenPointerMove();
|
|
1621
|
+
if (this.unlistenPointerUp)
|
|
1622
|
+
this.unlistenPointerUp();
|
|
1623
|
+
if (this.unlistenPointerLeave)
|
|
1624
|
+
this.unlistenPointerLeave();
|
|
1625
|
+
}
|
|
1626
|
+
get lodLevel() {
|
|
1627
|
+
return this.diagramStateService.lodLevel();
|
|
1628
|
+
}
|
|
1629
|
+
ngOnChanges(changes) {
|
|
1630
|
+
// Handle changes to input properties after initialization
|
|
1631
|
+
if (changes['initialNodes'] && !changes['initialNodes'].firstChange) {
|
|
1632
|
+
if (this.isDraggingNode || this.draggingNode) {
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
const currentNodes = this.nodes();
|
|
1636
|
+
if (this.initialNodes === currentNodes) {
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
const currentNodeIds = new Set(currentNodes.map(n => n.id));
|
|
1640
|
+
const newNodeIds = new Set(this.initialNodes.map(n => n.id));
|
|
1641
|
+
currentNodes.forEach(node => {
|
|
1642
|
+
if (!newNodeIds.has(node.id)) {
|
|
1643
|
+
this.diagramStateService.removeNode(node.id);
|
|
1644
|
+
}
|
|
1645
|
+
});
|
|
1646
|
+
this.initialNodes.forEach(node => {
|
|
1647
|
+
if (!currentNodeIds.has(node.id)) {
|
|
1648
|
+
this.diagramStateService.addNode(node);
|
|
1649
|
+
}
|
|
1650
|
+
else {
|
|
1651
|
+
const currentNode = currentNodes.find(n => n.id === node.id);
|
|
1652
|
+
if (currentNode && JSON.stringify(currentNode) !== JSON.stringify(node)) {
|
|
1653
|
+
this.diagramStateService.updateNode(node.id, node);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
if (changes['initialEdges'] && !changes['initialEdges'].firstChange) {
|
|
1659
|
+
const currentEdges = this.edges();
|
|
1660
|
+
if (this.initialEdges === currentEdges)
|
|
1661
|
+
return;
|
|
1662
|
+
if (JSON.stringify(this.initialEdges) !== JSON.stringify(currentEdges)) {
|
|
1663
|
+
this.diagramStateService.edges.set([...this.initialEdges]);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
if (changes['initialViewport'] && !changes['initialViewport'].firstChange && this.initialViewport) {
|
|
1667
|
+
const currentViewport = this.viewport();
|
|
1668
|
+
if (JSON.stringify(this.initialViewport) !== JSON.stringify(currentViewport)) {
|
|
1669
|
+
this.diagramStateService.setViewport(this.initialViewport);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
get transform() {
|
|
1674
|
+
const v = this.viewport();
|
|
1675
|
+
return `translate(${v.x}, ${v.y}) scale(${v.zoom})`;
|
|
1676
|
+
}
|
|
1677
|
+
trackByNodeId(index, node) {
|
|
1678
|
+
return node.id;
|
|
1679
|
+
}
|
|
1680
|
+
trackByEdgeId(index, edge) {
|
|
1681
|
+
return edge.id;
|
|
1682
|
+
}
|
|
1683
|
+
onDeleteKeyPress(event) {
|
|
1684
|
+
this.diagramStateService.deleteSelectedElements();
|
|
1685
|
+
}
|
|
1686
|
+
onUndoKeyPress(event) {
|
|
1687
|
+
event.preventDefault(); // Prevent browser undo
|
|
1688
|
+
this.diagramStateService.undo();
|
|
1689
|
+
}
|
|
1690
|
+
onRedoKeyPress(event) {
|
|
1691
|
+
event.preventDefault(); // Prevent browser redo
|
|
1692
|
+
this.diagramStateService.redo();
|
|
1693
|
+
}
|
|
1694
|
+
onCopyKeyPress(event) {
|
|
1695
|
+
// Don't prevent default if user is typing in an input
|
|
1696
|
+
if (this.isInputActive(event))
|
|
1697
|
+
return;
|
|
1698
|
+
this.diagramStateService.copy();
|
|
1699
|
+
}
|
|
1700
|
+
onPasteKeyPress(event) {
|
|
1701
|
+
if (this.isInputActive(event))
|
|
1702
|
+
return;
|
|
1703
|
+
this.diagramStateService.paste();
|
|
1704
|
+
}
|
|
1705
|
+
onCutKeyPress(event) {
|
|
1706
|
+
if (this.isInputActive(event))
|
|
1707
|
+
return;
|
|
1708
|
+
this.diagramStateService.cut();
|
|
1709
|
+
}
|
|
1710
|
+
onDuplicateKeyPress(event) {
|
|
1711
|
+
if (this.isInputActive(event))
|
|
1712
|
+
return;
|
|
1713
|
+
event.preventDefault(); // Prevent browser bookmark
|
|
1714
|
+
this.diagramStateService.duplicate();
|
|
1715
|
+
}
|
|
1716
|
+
onGroupKeyPress(event) {
|
|
1717
|
+
if (this.isInputActive(event))
|
|
1718
|
+
return;
|
|
1719
|
+
event.preventDefault(); // Prevent browser find
|
|
1720
|
+
const selectedNodes = this.diagramStateService.selectedNodes();
|
|
1721
|
+
if (selectedNodes.length > 1) {
|
|
1722
|
+
this.diagramStateService.groupNodes(selectedNodes.map(n => n.id));
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
onUngroupKeyPress(event) {
|
|
1726
|
+
if (this.isInputActive(event))
|
|
1727
|
+
return;
|
|
1728
|
+
event.preventDefault();
|
|
1729
|
+
const selectedNodes = this.diagramStateService.selectedNodes();
|
|
1730
|
+
if (selectedNodes.length === 1 && selectedNodes[0].type === 'group') {
|
|
1731
|
+
this.diagramStateService.ungroupNodes(selectedNodes[0].id);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
toggleGroup(event, node) {
|
|
1735
|
+
event.stopPropagation();
|
|
1736
|
+
this.diagramStateService.toggleGroup(node.id);
|
|
1737
|
+
}
|
|
1738
|
+
isInputActive(event) {
|
|
1739
|
+
const target = event.target;
|
|
1740
|
+
return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
|
1741
|
+
}
|
|
1742
|
+
onWheel(event) {
|
|
1743
|
+
event.preventDefault();
|
|
1744
|
+
this.ngZone.runOutsideAngular(() => {
|
|
1745
|
+
const svgRect = this.svgRef.nativeElement.getBoundingClientRect();
|
|
1746
|
+
const clientX = event.clientX;
|
|
1747
|
+
const clientY = event.clientY;
|
|
1748
|
+
const viewportBefore = this.viewport();
|
|
1749
|
+
const pointX = (clientX - svgRect.left - viewportBefore.x) / viewportBefore.zoom;
|
|
1750
|
+
const pointY = (clientY - svgRect.top - viewportBefore.y) / viewportBefore.zoom;
|
|
1751
|
+
const scaleFactor = 1.05;
|
|
1752
|
+
const newZoom = event.deltaY < 0 ? viewportBefore.zoom * scaleFactor : viewportBefore.zoom / scaleFactor;
|
|
1753
|
+
const clampedZoom = Math.max(0.1, Math.min(10, newZoom));
|
|
1754
|
+
const newX = clientX - svgRect.left - pointX * clampedZoom;
|
|
1755
|
+
const newY = clientY - svgRect.top - pointY * clampedZoom;
|
|
1756
|
+
this.diagramStateService.setViewport({ x: newX, y: newY, zoom: clampedZoom });
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
onPointerDown(event) {
|
|
1760
|
+
let targetElement = event.target;
|
|
1761
|
+
// Check for resize handle first (highest priority)
|
|
1762
|
+
const resizeHandleElement = targetElement.closest('.ngx-workflow__resize-handle');
|
|
1763
|
+
if (resizeHandleElement) {
|
|
1764
|
+
const nodeElement = resizeHandleElement.closest('.ngx-workflow__node');
|
|
1765
|
+
if (nodeElement) {
|
|
1766
|
+
const nodeId = nodeElement.dataset['id'];
|
|
1767
|
+
const node = this.nodes().find(n => n.id === nodeId);
|
|
1768
|
+
const handle = resizeHandleElement.dataset['handle'];
|
|
1769
|
+
if (node && handle && (node.resizable !== false) && this.nodesResizable) {
|
|
1770
|
+
this.startResizing(event, node, handle);
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
let handleElement = targetElement.closest('.ngx-workflow__handle');
|
|
1776
|
+
const nodeElement = targetElement.closest('.ngx-workflow__node');
|
|
1777
|
+
if (event.button !== 0)
|
|
1778
|
+
return;
|
|
1779
|
+
if (handleElement && handleElement.dataset['type'] === 'source') {
|
|
1780
|
+
// Start Connecting
|
|
1781
|
+
this.startConnecting(event, handleElement);
|
|
1782
|
+
}
|
|
1783
|
+
else if (nodeElement) {
|
|
1784
|
+
// Start Dragging Node
|
|
1785
|
+
const nodeId = nodeElement.dataset['id'];
|
|
1786
|
+
const node = this.nodes().find(n => n.id === nodeId);
|
|
1787
|
+
if (node && node.draggable) {
|
|
1788
|
+
this.startDraggingNode(event, node);
|
|
1789
|
+
}
|
|
1790
|
+
// Select Node
|
|
1791
|
+
if (node) {
|
|
1792
|
+
this.diagramStateService.onNodeClick(node);
|
|
1793
|
+
this.diagramStateService.selectNodes([node.id], event.ctrlKey || event.metaKey || event.shiftKey);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
else {
|
|
1797
|
+
// Pan or Select
|
|
1798
|
+
const isClickingOnCanvas = targetElement === this.svgRef.nativeElement || targetElement.classList.contains('ngx-workflow__background');
|
|
1799
|
+
if (isClickingOnCanvas) {
|
|
1800
|
+
if (event.shiftKey) {
|
|
1801
|
+
this.startSelecting(event);
|
|
1802
|
+
}
|
|
1803
|
+
else {
|
|
1804
|
+
this.startPanning(event);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
onPointerMove(event) {
|
|
1810
|
+
// console.log('onPointerMove', { dragging: this.isDraggingNode, connecting: this.isConnecting, resizing: this.isResizing });
|
|
1811
|
+
if (this.isResizing) {
|
|
1812
|
+
this.resize(event);
|
|
1813
|
+
}
|
|
1814
|
+
else if (this.isUpdatingEdge) {
|
|
1815
|
+
this.updateEdge(event);
|
|
1816
|
+
}
|
|
1817
|
+
else if (this.isConnecting) {
|
|
1818
|
+
this.updateConnection(event);
|
|
1819
|
+
}
|
|
1820
|
+
else if (this.isDraggingNode) {
|
|
1821
|
+
this.dragNode(event);
|
|
1822
|
+
}
|
|
1823
|
+
else if (this.isPanning) {
|
|
1824
|
+
this.pan(event);
|
|
1825
|
+
}
|
|
1826
|
+
else if (this.isSelecting) {
|
|
1827
|
+
this.updateSelection(event);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
onPointerUp(event) {
|
|
1831
|
+
if (this.isResizing) {
|
|
1832
|
+
this.stopResizing(event);
|
|
1833
|
+
}
|
|
1834
|
+
else if (this.isUpdatingEdge) {
|
|
1835
|
+
this.stopUpdatingEdge(event);
|
|
1836
|
+
}
|
|
1837
|
+
else if (this.isConnecting) {
|
|
1838
|
+
this.finishConnecting(event);
|
|
1839
|
+
}
|
|
1840
|
+
else if (this.isDraggingNode) {
|
|
1841
|
+
this.stopDraggingNode(event);
|
|
1842
|
+
}
|
|
1843
|
+
else if (this.isPanning) {
|
|
1844
|
+
this.stopPanning(event);
|
|
1845
|
+
}
|
|
1846
|
+
else if (this.isSelecting) {
|
|
1847
|
+
this.stopSelecting(event);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
onPointerLeave(event) {
|
|
1851
|
+
if (this.isResizing || this.isUpdatingEdge || this.isPanning || this.isSelecting || this.isDraggingNode || this.isConnecting) {
|
|
1852
|
+
this.onPointerUp(event);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
// --- Connecting Logic ---
|
|
1856
|
+
startConnecting(event, handleElement) {
|
|
1857
|
+
event.stopPropagation();
|
|
1858
|
+
event.preventDefault();
|
|
1859
|
+
this.isConnecting = true;
|
|
1860
|
+
this.svgRef.nativeElement.setPointerCapture(event.pointerId);
|
|
1861
|
+
const nodeId = handleElement.dataset['nodeid'];
|
|
1862
|
+
const handleId = handleElement.dataset['handleid'];
|
|
1863
|
+
if (!nodeId)
|
|
1864
|
+
return;
|
|
1865
|
+
this.connectingSourceNodeId = nodeId;
|
|
1866
|
+
this.connectingSourceHandleId = handleId;
|
|
1867
|
+
const previewEdgeId = `preview-${v4()}`;
|
|
1868
|
+
this.currentPreviewEdgeId = previewEdgeId;
|
|
1869
|
+
const viewport = this.viewport();
|
|
1870
|
+
const diagramSvgEl = this.svgRef.nativeElement;
|
|
1871
|
+
const handleScreenCoords = handleElement.getBoundingClientRect();
|
|
1872
|
+
const diagramScreenCoords = diagramSvgEl.getBoundingClientRect();
|
|
1873
|
+
const sourceX = (handleScreenCoords.x + handleScreenCoords.width / 2 - diagramScreenCoords.x - viewport.x) / viewport.zoom;
|
|
1874
|
+
const sourceY = (handleScreenCoords.y + handleScreenCoords.height / 2 - diagramScreenCoords.y - viewport.y) / viewport.zoom;
|
|
1875
|
+
const newTempEdge = {
|
|
1876
|
+
id: previewEdgeId,
|
|
1877
|
+
source: nodeId,
|
|
1878
|
+
sourceHandle: handleId,
|
|
1879
|
+
target: 'preview-target',
|
|
1880
|
+
targetHandle: undefined,
|
|
1881
|
+
type: 'straight',
|
|
1882
|
+
animated: true,
|
|
1883
|
+
style: { stroke: 'blue', strokeWidth: '2' },
|
|
1884
|
+
sourceX: sourceX,
|
|
1885
|
+
sourceY: sourceY,
|
|
1886
|
+
targetX: sourceX,
|
|
1887
|
+
targetY: sourceY,
|
|
1888
|
+
};
|
|
1889
|
+
this.diagramStateService.addTempEdge(newTempEdge);
|
|
1890
|
+
}
|
|
1891
|
+
updateConnection(event) {
|
|
1892
|
+
if (!this.currentPreviewEdgeId)
|
|
1893
|
+
return;
|
|
1894
|
+
this.ngZone.runOutsideAngular(() => {
|
|
1895
|
+
const diagramSvgEl = this.svgRef.nativeElement;
|
|
1896
|
+
const diagramScreenCoords = diagramSvgEl.getBoundingClientRect();
|
|
1897
|
+
const viewport = this.viewport();
|
|
1898
|
+
const currentPointerX = (event.clientX - diagramScreenCoords.x - viewport.x) / viewport.zoom;
|
|
1899
|
+
const currentPointerY = (event.clientY - diagramScreenCoords.y - viewport.y) / viewport.zoom;
|
|
1900
|
+
this.diagramStateService.updateTempEdgeTarget(this.currentPreviewEdgeId, { x: currentPointerX, y: currentPointerY });
|
|
1901
|
+
// Use geometric distance check instead of elementFromPoint to avoid pointer capture issues
|
|
1902
|
+
let closestHandle = null;
|
|
1903
|
+
let minDistance = 20; // Detection radius
|
|
1904
|
+
const nodes = this.nodes();
|
|
1905
|
+
for (const node of nodes) {
|
|
1906
|
+
const handles = ['top', 'right', 'bottom', 'left'];
|
|
1907
|
+
for (const handleId of handles) {
|
|
1908
|
+
const handlePos = getHandleAbsolutePosition(node, handleId);
|
|
1909
|
+
const dist = Math.hypot(handlePos.x - currentPointerX, handlePos.y - currentPointerY);
|
|
1910
|
+
if (dist < minDistance) {
|
|
1911
|
+
minDistance = dist;
|
|
1912
|
+
closestHandle = { nodeId: node.id, handleId: handleId };
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
this.clearTargetHandleHighlight();
|
|
1917
|
+
if (closestHandle) {
|
|
1918
|
+
const targetNodeId = closestHandle.nodeId;
|
|
1919
|
+
const targetHandleId = closestHandle.handleId;
|
|
1920
|
+
// Allow connecting to any handle on a different node OR same node (self-loop)
|
|
1921
|
+
// console.log('updateConnection: handle found', targetNodeId, this.connectingSourceNodeId);
|
|
1922
|
+
if (targetNodeId && this.isValidConnection(this.connectingSourceNodeId, targetNodeId)) {
|
|
1923
|
+
this.currentTargetHandle = { nodeId: targetNodeId, handleId: targetHandleId, type: 'target' };
|
|
1924
|
+
// We need to find the handle element to highlight it
|
|
1925
|
+
// This is a bit expensive but necessary for visual feedback
|
|
1926
|
+
const handleEl = this.el.nativeElement.querySelector(`.ngx-workflow__handle[data-nodeid="${targetNodeId}"][data-handleid="${targetHandleId}"]`);
|
|
1927
|
+
if (handleEl) {
|
|
1928
|
+
this.renderer.addClass(handleEl, 'ngx-workflow__handle--valid-target');
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
else {
|
|
1932
|
+
this.currentTargetHandle = null;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
else {
|
|
1936
|
+
this.currentTargetHandle = null;
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
finishConnecting(event) {
|
|
1941
|
+
console.log('finishConnecting: start');
|
|
1942
|
+
event.stopPropagation();
|
|
1943
|
+
event.preventDefault();
|
|
1944
|
+
this.isConnecting = false;
|
|
1945
|
+
this.svgRef.nativeElement.releasePointerCapture(event.pointerId);
|
|
1946
|
+
this.clearTargetHandleHighlight();
|
|
1947
|
+
if (this.currentPreviewEdgeId) {
|
|
1948
|
+
console.log('finishConnecting: removing preview edge');
|
|
1949
|
+
this.diagramStateService.removeEdge(this.currentPreviewEdgeId);
|
|
1950
|
+
}
|
|
1951
|
+
if (this.currentTargetHandle && this.connectingSourceNodeId) {
|
|
1952
|
+
const sourceId = this.connectingSourceNodeId;
|
|
1953
|
+
const targetId = this.currentTargetHandle.nodeId;
|
|
1954
|
+
console.log('finishConnecting: attempting connection', { sourceId, targetId });
|
|
1955
|
+
if (this.isValidConnection(sourceId, targetId)) {
|
|
1956
|
+
const newEdge = {
|
|
1957
|
+
id: v4(),
|
|
1958
|
+
source: sourceId,
|
|
1959
|
+
sourceHandle: this.connectingSourceHandleId,
|
|
1960
|
+
target: targetId,
|
|
1961
|
+
targetHandle: this.currentTargetHandle.handleId,
|
|
1962
|
+
// type: 'bezier', // Removed to use default smart routing
|
|
1963
|
+
};
|
|
1964
|
+
console.log('finishConnecting: adding edge', newEdge);
|
|
1965
|
+
this.diagramStateService.addEdge(newEdge);
|
|
1966
|
+
}
|
|
1967
|
+
else {
|
|
1968
|
+
console.log('finishConnecting: invalid connection');
|
|
1969
|
+
// Visual feedback for invalid connection: flash source node
|
|
1970
|
+
const sourceNodeEl = this.el.nativeElement.querySelector(`[data-nodeid="${sourceId}"]`);
|
|
1971
|
+
if (sourceNodeEl) {
|
|
1972
|
+
this.renderer.addClass(sourceNodeEl, 'invalid-connection');
|
|
1973
|
+
setTimeout(() => this.renderer.removeClass(sourceNodeEl, 'invalid-connection'), 1000);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
this.currentPreviewEdgeId = null;
|
|
1978
|
+
this.currentTargetHandle = null;
|
|
1979
|
+
this.connectingSourceNodeId = null;
|
|
1980
|
+
this.connectingSourceHandleId = undefined;
|
|
1981
|
+
console.log('finishConnecting: end');
|
|
1982
|
+
}
|
|
1983
|
+
clearTargetHandleHighlight() {
|
|
1984
|
+
const activeHighlights = document.querySelectorAll('.ngx-workflow__handle--valid-target');
|
|
1985
|
+
activeHighlights.forEach(el => this.renderer.removeClass(el, 'ngx-workflow__handle--valid-target'));
|
|
1986
|
+
}
|
|
1987
|
+
// --- Dragging Logic ---
|
|
1988
|
+
startDraggingNode(event, node) {
|
|
1989
|
+
event.stopPropagation();
|
|
1990
|
+
this.isDraggingNode = true;
|
|
1991
|
+
this.draggingNode = node;
|
|
1992
|
+
this.startNodePosition = { x: node.position.x, y: node.position.y };
|
|
1993
|
+
this.startPointerPosition = { x: event.clientX, y: event.clientY };
|
|
1994
|
+
this.svgRef.nativeElement.setPointerCapture(event.pointerId);
|
|
1995
|
+
// Check if this node is part of a multi-selection
|
|
1996
|
+
const selectedNodes = this.nodes().filter(n => n.selected);
|
|
1997
|
+
if (selectedNodes.length > 1 && node.selected) {
|
|
1998
|
+
// Multi-node drag: store all selected nodes and their positions
|
|
1999
|
+
this.draggingNodes = selectedNodes;
|
|
2000
|
+
this.startNodePositions.clear();
|
|
2001
|
+
selectedNodes.forEach(n => {
|
|
2002
|
+
this.startNodePositions.set(n.id, { x: n.position.x, y: n.position.y });
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
else {
|
|
2006
|
+
// Single node drag
|
|
2007
|
+
this.draggingNodes = [node];
|
|
2008
|
+
this.startNodePositions.clear();
|
|
2009
|
+
this.startNodePositions.set(node.id, { x: node.position.x, y: node.position.y });
|
|
2010
|
+
}
|
|
2011
|
+
this.diagramStateService.onDragStart(node);
|
|
2012
|
+
console.log('startDraggingNode: started for', node.id);
|
|
2013
|
+
}
|
|
2014
|
+
dragNode(event) {
|
|
2015
|
+
if (!this.draggingNode)
|
|
2016
|
+
return;
|
|
2017
|
+
event.stopPropagation();
|
|
2018
|
+
if (this.dragAnimationFrameId) {
|
|
2019
|
+
cancelAnimationFrame(this.dragAnimationFrameId);
|
|
2020
|
+
}
|
|
2021
|
+
this.dragAnimationFrameId = requestAnimationFrame(() => {
|
|
2022
|
+
if (!this.draggingNode)
|
|
2023
|
+
return;
|
|
2024
|
+
const zoom = this.viewport().zoom;
|
|
2025
|
+
const deltaX = (event.clientX - this.startPointerPosition.x) / zoom;
|
|
2026
|
+
const deltaY = (event.clientY - this.startPointerPosition.y) / zoom;
|
|
2027
|
+
if (this.draggingNodes.length > 1) {
|
|
2028
|
+
// Multi-node drag: move all selected nodes by the same delta
|
|
2029
|
+
const moves = this.draggingNodes.map(node => {
|
|
2030
|
+
const startPos = this.startNodePositions.get(node.id);
|
|
2031
|
+
return {
|
|
2032
|
+
id: node.id,
|
|
2033
|
+
position: {
|
|
2034
|
+
x: startPos.x + deltaX,
|
|
2035
|
+
y: startPos.y + deltaY
|
|
2036
|
+
}
|
|
2037
|
+
};
|
|
2038
|
+
});
|
|
2039
|
+
this.diagramStateService.moveNodes(moves);
|
|
2040
|
+
}
|
|
2041
|
+
else {
|
|
2042
|
+
// Single node drag
|
|
2043
|
+
const newPosition = {
|
|
2044
|
+
x: this.startNodePosition.x + deltaX,
|
|
2045
|
+
y: this.startNodePosition.y + deltaY,
|
|
2046
|
+
};
|
|
2047
|
+
this.diagramStateService.moveNode(this.draggingNode.id, newPosition);
|
|
2048
|
+
}
|
|
2049
|
+
this.cdRef.detectChanges();
|
|
2050
|
+
this.dragAnimationFrameId = null;
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
stopDraggingNode(event) {
|
|
2054
|
+
if (!this.draggingNode)
|
|
2055
|
+
return;
|
|
2056
|
+
event.stopPropagation();
|
|
2057
|
+
this.isDraggingNode = false;
|
|
2058
|
+
this.updatePathFinder(this.nodes());
|
|
2059
|
+
if (this.dragAnimationFrameId) {
|
|
2060
|
+
cancelAnimationFrame(this.dragAnimationFrameId);
|
|
2061
|
+
this.dragAnimationFrameId = null;
|
|
2062
|
+
}
|
|
2063
|
+
this.svgRef.nativeElement.releasePointerCapture(event.pointerId);
|
|
2064
|
+
// Trigger onDragEnd for all dragged nodes
|
|
2065
|
+
this.draggingNodes.forEach(node => {
|
|
2066
|
+
this.diagramStateService.onDragEnd(node);
|
|
2067
|
+
});
|
|
2068
|
+
console.log('stopDraggingNode: stopped');
|
|
2069
|
+
this.draggingNode = null;
|
|
2070
|
+
this.draggingNodes = [];
|
|
2071
|
+
this.startNodePositions.clear();
|
|
2072
|
+
// Emit the final state after drag is complete
|
|
2073
|
+
this.nodesChange.emit(this.nodes());
|
|
2074
|
+
}
|
|
2075
|
+
// --- Resizing Logic ---
|
|
2076
|
+
startResizing(event, node, handle) {
|
|
2077
|
+
event.stopPropagation();
|
|
2078
|
+
this.isResizing = true;
|
|
2079
|
+
this.resizingNode = node;
|
|
2080
|
+
this.resizeHandle = handle;
|
|
2081
|
+
this.startResizePosition = { x: event.clientX, y: event.clientY };
|
|
2082
|
+
this.startNodeDimensions = {
|
|
2083
|
+
width: node.width || this.defaultNodeWidth,
|
|
2084
|
+
height: node.height || this.defaultNodeHeight,
|
|
2085
|
+
x: node.position.x,
|
|
2086
|
+
y: node.position.y
|
|
2087
|
+
};
|
|
2088
|
+
this.svgRef.nativeElement.setPointerCapture(event.pointerId);
|
|
2089
|
+
this.diagramStateService.onResizeStart(node);
|
|
2090
|
+
}
|
|
2091
|
+
resize(event) {
|
|
2092
|
+
if (!this.resizingNode || !this.resizeHandle)
|
|
2093
|
+
return;
|
|
2094
|
+
event.stopPropagation();
|
|
2095
|
+
const resizingNode = this.resizingNode;
|
|
2096
|
+
const resizeHandle = this.resizeHandle;
|
|
2097
|
+
this.ngZone.runOutsideAngular(() => {
|
|
2098
|
+
const zoom = this.viewport().zoom;
|
|
2099
|
+
const deltaX = (event.clientX - this.startResizePosition.x) / zoom;
|
|
2100
|
+
const deltaY = (event.clientY - this.startResizePosition.y) / zoom;
|
|
2101
|
+
let newWidth = this.startNodeDimensions.width;
|
|
2102
|
+
let newHeight = this.startNodeDimensions.height;
|
|
2103
|
+
let newX = this.startNodeDimensions.x;
|
|
2104
|
+
let newY = this.startNodeDimensions.y;
|
|
2105
|
+
// Calculate new dimensions based on handle
|
|
2106
|
+
switch (resizeHandle) {
|
|
2107
|
+
case 'se': // Southeast - resize from bottom-right
|
|
2108
|
+
newWidth = this.startNodeDimensions.width + deltaX;
|
|
2109
|
+
newHeight = this.startNodeDimensions.height + deltaY;
|
|
2110
|
+
break;
|
|
2111
|
+
case 'sw': // Southwest - resize from bottom-left
|
|
2112
|
+
newWidth = this.startNodeDimensions.width - deltaX;
|
|
2113
|
+
newHeight = this.startNodeDimensions.height + deltaY;
|
|
2114
|
+
newX = this.startNodeDimensions.x + deltaX;
|
|
2115
|
+
break;
|
|
2116
|
+
case 'ne': // Northeast - resize from top-right
|
|
2117
|
+
newWidth = this.startNodeDimensions.width + deltaX;
|
|
2118
|
+
newHeight = this.startNodeDimensions.height - deltaY;
|
|
2119
|
+
newY = this.startNodeDimensions.y + deltaY;
|
|
2120
|
+
break;
|
|
2121
|
+
case 'nw': // Northwest - resize from top-left
|
|
2122
|
+
newWidth = this.startNodeDimensions.width - deltaX;
|
|
2123
|
+
newHeight = this.startNodeDimensions.height - deltaY;
|
|
2124
|
+
newX = this.startNodeDimensions.x + deltaX;
|
|
2125
|
+
newY = this.startNodeDimensions.y + deltaY;
|
|
2126
|
+
break;
|
|
2127
|
+
}
|
|
2128
|
+
// Apply constraints
|
|
2129
|
+
const minWidth = resizingNode.minWidth || 50;
|
|
2130
|
+
const minHeight = resizingNode.minHeight || 30;
|
|
2131
|
+
const maxWidth = resizingNode.maxWidth || 500;
|
|
2132
|
+
const maxHeight = resizingNode.maxHeight || 500;
|
|
2133
|
+
newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));
|
|
2134
|
+
newHeight = Math.max(minHeight, Math.min(maxHeight, newHeight));
|
|
2135
|
+
// Adjust position if constrained (for nw, ne, sw handles)
|
|
2136
|
+
if (resizeHandle === 'nw' || resizeHandle === 'sw') {
|
|
2137
|
+
const widthDiff = newWidth - (this.startNodeDimensions.width - deltaX);
|
|
2138
|
+
newX = this.startNodeDimensions.x + deltaX - widthDiff;
|
|
2139
|
+
}
|
|
2140
|
+
if (resizeHandle === 'nw' || resizeHandle === 'ne') {
|
|
2141
|
+
const heightDiff = newHeight - (this.startNodeDimensions.height - deltaY);
|
|
2142
|
+
newY = this.startNodeDimensions.y + deltaY - heightDiff;
|
|
2143
|
+
}
|
|
2144
|
+
// Update node
|
|
2145
|
+
this.diagramStateService.resizeNode(resizingNode.id, newWidth, newHeight, { x: newX, y: newY });
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
stopResizing(event) {
|
|
2149
|
+
if (!this.resizingNode)
|
|
2150
|
+
return;
|
|
2151
|
+
event.stopPropagation();
|
|
2152
|
+
this.isResizing = false;
|
|
2153
|
+
this.svgRef.nativeElement.releasePointerCapture(event.pointerId);
|
|
2154
|
+
this.diagramStateService.onResizeEnd(this.resizingNode);
|
|
2155
|
+
this.resizingNode = null;
|
|
2156
|
+
this.resizeHandle = null;
|
|
2157
|
+
}
|
|
2158
|
+
// --- Edge Updating Logic ---
|
|
2159
|
+
startUpdatingEdge(event, edge, handleType) {
|
|
2160
|
+
event.stopPropagation();
|
|
2161
|
+
this.isUpdatingEdge = true;
|
|
2162
|
+
this.updatingEdge = edge;
|
|
2163
|
+
this.updatingEdgeHandle = handleType;
|
|
2164
|
+
this.svgRef.nativeElement.setPointerCapture(event.pointerId);
|
|
2165
|
+
const tempEdgeId = `temp-update-${edge.id}`;
|
|
2166
|
+
this.currentPreviewEdgeId = tempEdgeId;
|
|
2167
|
+
const sourceNode = this.nodes().find(n => n.id === edge.source);
|
|
2168
|
+
const targetNode = this.nodes().find(n => n.id === edge.target);
|
|
2169
|
+
if (!sourceNode || !targetNode)
|
|
2170
|
+
return;
|
|
2171
|
+
const sourcePos = getHandleAbsolutePosition(sourceNode, edge.sourceHandle);
|
|
2172
|
+
const targetPos = getHandleAbsolutePosition(targetNode, edge.targetHandle);
|
|
2173
|
+
let startX, startY, endX, endY;
|
|
2174
|
+
if (handleType === 'source') {
|
|
2175
|
+
startX = targetPos.x;
|
|
2176
|
+
startY = targetPos.y;
|
|
2177
|
+
endX = sourcePos.x;
|
|
2178
|
+
endY = sourcePos.y;
|
|
2179
|
+
}
|
|
2180
|
+
else {
|
|
2181
|
+
startX = sourcePos.x;
|
|
2182
|
+
startY = sourcePos.y;
|
|
2183
|
+
endX = targetPos.x;
|
|
2184
|
+
endY = targetPos.y;
|
|
2185
|
+
}
|
|
2186
|
+
this.diagramStateService.addTempEdge({
|
|
2187
|
+
id: tempEdgeId,
|
|
2188
|
+
source: edge.source,
|
|
2189
|
+
sourceHandle: edge.sourceHandle,
|
|
2190
|
+
target: edge.target,
|
|
2191
|
+
targetHandle: edge.targetHandle,
|
|
2192
|
+
type: edge.type || 'bezier',
|
|
2193
|
+
animated: edge.animated,
|
|
2194
|
+
sourceX: startX,
|
|
2195
|
+
sourceY: startY,
|
|
2196
|
+
targetX: endX,
|
|
2197
|
+
targetY: endY,
|
|
2198
|
+
style: edge.style,
|
|
2199
|
+
markerEnd: edge.markerEnd
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
updateEdge(event) {
|
|
2203
|
+
if (!this.updatingEdge || !this.currentPreviewEdgeId)
|
|
2204
|
+
return;
|
|
2205
|
+
event.stopPropagation();
|
|
2206
|
+
this.ngZone.runOutsideAngular(() => {
|
|
2207
|
+
const diagramRect = this.svgRef.nativeElement.getBoundingClientRect();
|
|
2208
|
+
const viewport = this.viewport();
|
|
2209
|
+
const point = {
|
|
2210
|
+
x: (event.clientX - diagramRect.left - viewport.x) / viewport.zoom,
|
|
2211
|
+
y: (event.clientY - diagramRect.top - viewport.y) / viewport.zoom
|
|
2212
|
+
};
|
|
2213
|
+
this.diagramStateService.updateTempEdgeTarget(this.currentPreviewEdgeId, point);
|
|
2214
|
+
const element = document.elementFromPoint(event.clientX, event.clientY);
|
|
2215
|
+
const handle = element?.closest('.ngx-workflow__handle');
|
|
2216
|
+
if (handle) {
|
|
2217
|
+
const nodeId = handle.getAttribute('data-nodeid');
|
|
2218
|
+
const handleId = handle.getAttribute('data-handleid');
|
|
2219
|
+
const type = handle.getAttribute('data-type');
|
|
2220
|
+
if (nodeId && type) {
|
|
2221
|
+
this.currentTargetHandle = { nodeId, handleId: handleId || undefined, type: type };
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
else {
|
|
2225
|
+
this.currentTargetHandle = null;
|
|
2226
|
+
}
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
stopUpdatingEdge(event) {
|
|
2230
|
+
if (!this.updatingEdge)
|
|
2231
|
+
return;
|
|
2232
|
+
event.stopPropagation();
|
|
2233
|
+
this.isUpdatingEdge = false;
|
|
2234
|
+
this.svgRef.nativeElement.releasePointerCapture(event.pointerId);
|
|
2235
|
+
if (this.currentPreviewEdgeId) {
|
|
2236
|
+
this.diagramStateService.removeEdge(this.currentPreviewEdgeId);
|
|
2237
|
+
this.currentPreviewEdgeId = null;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
// --- Panning Logic ---
|
|
2241
|
+
startPanning(event) {
|
|
2242
|
+
this.isPanning = true;
|
|
2243
|
+
this.lastPanPosition = { x: event.clientX, y: event.clientY };
|
|
2244
|
+
this.renderer.setStyle(this.svgRef.nativeElement, 'cursor', 'grabbing');
|
|
2245
|
+
this.svgRef.nativeElement.setPointerCapture(event.pointerId);
|
|
2246
|
+
this.diagramStateService.clearSelection();
|
|
2247
|
+
}
|
|
2248
|
+
pan(event) {
|
|
2249
|
+
this.ngZone.runOutsideAngular(() => {
|
|
2250
|
+
const deltaX = event.clientX - this.lastPanPosition.x;
|
|
2251
|
+
const deltaY = event.clientY - this.lastPanPosition.y;
|
|
2252
|
+
const currentViewport = this.viewport();
|
|
2253
|
+
this.diagramStateService.setViewport({
|
|
2254
|
+
x: currentViewport.x + deltaX,
|
|
2255
|
+
y: currentViewport.y + deltaY,
|
|
2256
|
+
zoom: currentViewport.zoom,
|
|
2257
|
+
});
|
|
2258
|
+
this.lastPanPosition = { x: event.clientX, y: event.clientY };
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
stopPanning(event) {
|
|
2262
|
+
this.isPanning = false;
|
|
2263
|
+
this.renderer.setStyle(this.svgRef.nativeElement, 'cursor', 'grab');
|
|
2264
|
+
this.svgRef.nativeElement.releasePointerCapture(event.pointerId);
|
|
2265
|
+
}
|
|
2266
|
+
// --- Selection Logic ---
|
|
2267
|
+
startSelecting(event) {
|
|
2268
|
+
this.isSelecting = true;
|
|
2269
|
+
this.selectionStart = this.getDiagramCoordinates(event.clientX, event.clientY);
|
|
2270
|
+
this.selectionEnd = { ...this.selectionStart };
|
|
2271
|
+
this.svgRef.nativeElement.setPointerCapture(event.pointerId);
|
|
2272
|
+
}
|
|
2273
|
+
updateSelection(event) {
|
|
2274
|
+
this.ngZone.runOutsideAngular(() => {
|
|
2275
|
+
this.selectionEnd = this.getDiagramCoordinates(event.clientX, event.clientY);
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
stopSelecting(event) {
|
|
2279
|
+
this.isSelecting = false;
|
|
2280
|
+
this.svgRef.nativeElement.releasePointerCapture(event.pointerId);
|
|
2281
|
+
this.performLassoSelection();
|
|
2282
|
+
}
|
|
2283
|
+
getDiagramCoordinates(clientX, clientY) {
|
|
2284
|
+
const svgRect = this.svgRef.nativeElement.getBoundingClientRect();
|
|
2285
|
+
const viewport = this.viewport();
|
|
2286
|
+
const x = (clientX - svgRect.left - viewport.x) / viewport.zoom;
|
|
2287
|
+
const y = (clientY - svgRect.top - viewport.y) / viewport.zoom;
|
|
2288
|
+
return { x, y };
|
|
2289
|
+
}
|
|
2290
|
+
performLassoSelection() {
|
|
2291
|
+
const minX = Math.min(this.selectionStart.x, this.selectionEnd.x);
|
|
2292
|
+
const maxX = Math.max(this.selectionStart.x, this.selectionEnd.x);
|
|
2293
|
+
const minY = Math.min(this.selectionStart.y, this.selectionEnd.y);
|
|
2294
|
+
const maxY = Math.max(this.selectionStart.y, this.selectionEnd.y);
|
|
2295
|
+
const selectedNodeIds = [];
|
|
2296
|
+
this.nodes().forEach((node) => {
|
|
2297
|
+
const nodeX = node.position.x;
|
|
2298
|
+
const nodeY = node.position.y;
|
|
2299
|
+
const nodeWidth = node.width || this.defaultNodeWidth;
|
|
2300
|
+
const nodeHeight = node.height || this.defaultNodeHeight;
|
|
2301
|
+
if (nodeX < maxX &&
|
|
2302
|
+
nodeX + nodeWidth > minX &&
|
|
2303
|
+
nodeY < maxY &&
|
|
2304
|
+
nodeY + nodeHeight > minY) {
|
|
2305
|
+
selectedNodeIds.push(node.id);
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
this.diagramStateService.clearSelection();
|
|
2309
|
+
this.diagramStateService.selectNodes(selectedNodeIds, false);
|
|
2310
|
+
}
|
|
2311
|
+
// --- Edge Logic ---
|
|
2312
|
+
getEdgePath(edge, isTemporary = false) {
|
|
2313
|
+
const nodes = this.nodes();
|
|
2314
|
+
let sourcePos;
|
|
2315
|
+
let targetPos;
|
|
2316
|
+
if (isTemporary && 'sourceX' in edge && 'sourceY' in edge && 'targetX' in edge && 'targetY' in edge) {
|
|
2317
|
+
sourcePos = { x: edge.sourceX, y: edge.sourceY };
|
|
2318
|
+
targetPos = { x: edge.targetX, y: edge.targetY };
|
|
2319
|
+
}
|
|
2320
|
+
else {
|
|
2321
|
+
const sourceNode = getNode(edge.source, nodes);
|
|
2322
|
+
const targetNode = getNode(edge.target, nodes);
|
|
2323
|
+
if (!sourceNode || !targetNode) {
|
|
2324
|
+
return 'M 0 0';
|
|
2325
|
+
}
|
|
2326
|
+
sourcePos = getHandleAbsolutePosition(sourceNode, edge.sourceHandle);
|
|
2327
|
+
targetPos = getHandleAbsolutePosition(targetNode, edge.targetHandle);
|
|
2328
|
+
if (sourceNode.id === targetNode.id) {
|
|
2329
|
+
return getSelfLoopPath(sourcePos, edge.sourceHandle);
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
// Use smart routing if type is 'smart' or not specified (default)
|
|
2333
|
+
// But respect explicit 'straight' type if user wants simple straight line
|
|
2334
|
+
if ((edge.type === 'smart' || !edge.type) && !isTemporary) {
|
|
2335
|
+
const cacheKey = `${edge.id}-${sourcePos.x},${sourcePos.y}-${targetPos.x},${targetPos.y}`;
|
|
2336
|
+
if (this.pathCache.has(cacheKey)) {
|
|
2337
|
+
return this.pathCache.get(cacheKey);
|
|
2338
|
+
}
|
|
2339
|
+
try {
|
|
2340
|
+
if (!this._pathFinder) {
|
|
2341
|
+
this.updatePathFinder(this.nodes());
|
|
2342
|
+
}
|
|
2343
|
+
const path = this._pathFinder.findPath(sourcePos, targetPos);
|
|
2344
|
+
const d = getSmartEdgePath(path);
|
|
2345
|
+
this.pathCache.set(cacheKey, d);
|
|
2346
|
+
return d;
|
|
2347
|
+
}
|
|
2348
|
+
catch (e) {
|
|
2349
|
+
console.warn('Pathfinding failed, falling back to straight path', e);
|
|
2350
|
+
return getStraightPath(sourcePos, targetPos);
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
switch (edge.type) {
|
|
2354
|
+
case 'bezier': return getBezierPath(sourcePos, targetPos);
|
|
2355
|
+
case 'step': return getStepPath(sourcePos, targetPos);
|
|
2356
|
+
case 'straight': return getStraightPath(sourcePos, targetPos);
|
|
2357
|
+
default: return getStraightPath(sourcePos, targetPos);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
getMarkerUrl(marker) {
|
|
2361
|
+
if (!marker)
|
|
2362
|
+
return null;
|
|
2363
|
+
// Support built-in markers or custom marker IDs
|
|
2364
|
+
if (marker === 'arrow' || marker === 'arrowclosed' || marker === 'dot') {
|
|
2365
|
+
return `url(#ngx-workflow__${marker})`;
|
|
2366
|
+
}
|
|
2367
|
+
return `url(#${marker})`;
|
|
2368
|
+
}
|
|
2369
|
+
getEdgeLabelPosition(edge) {
|
|
2370
|
+
const nodes = this.nodes();
|
|
2371
|
+
const sourceNode = getNode(edge.source, nodes);
|
|
2372
|
+
const targetNode = getNode(edge.target, nodes);
|
|
2373
|
+
if (!sourceNode || !targetNode) {
|
|
2374
|
+
return { x: 0, y: 0 };
|
|
2375
|
+
}
|
|
2376
|
+
const sourcePos = getHandleAbsolutePosition(sourceNode, edge.sourceHandle);
|
|
2377
|
+
const targetPos = getHandleAbsolutePosition(targetNode, edge.targetHandle);
|
|
2378
|
+
if (edge.type === 'smart' || !edge.type) {
|
|
2379
|
+
try {
|
|
2380
|
+
// For label position, we can re-use the cached path if available,
|
|
2381
|
+
// but we need the points, not the string.
|
|
2382
|
+
// For now, let's just re-calculate or maybe cache points too?
|
|
2383
|
+
// Re-calculating for label might be okay if getEdgePath is cached,
|
|
2384
|
+
// but ideally we cache the points.
|
|
2385
|
+
// Let's optimize this later if needed, or cache points instead of string.
|
|
2386
|
+
// Optimization: Cache points instead of string
|
|
2387
|
+
const cacheKey = `${edge.id}-${sourcePos.x},${sourcePos.y}-${targetPos.x},${targetPos.y}-points`;
|
|
2388
|
+
let path;
|
|
2389
|
+
if (this.pathPointsCache.has(cacheKey)) {
|
|
2390
|
+
path = this.pathPointsCache.get(cacheKey);
|
|
2391
|
+
}
|
|
2392
|
+
else {
|
|
2393
|
+
if (!this._pathFinder) {
|
|
2394
|
+
this.updatePathFinder(this.nodes());
|
|
2395
|
+
}
|
|
2396
|
+
path = this._pathFinder.findPath(sourcePos, targetPos);
|
|
2397
|
+
this.pathPointsCache.set(cacheKey, path);
|
|
2398
|
+
}
|
|
2399
|
+
return getPolylineMidpoint(path);
|
|
2400
|
+
}
|
|
2401
|
+
catch (e) {
|
|
2402
|
+
console.warn('Pathfinding failed for label position', e);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
// Return midpoint of the edge
|
|
2406
|
+
return {
|
|
2407
|
+
x: (sourcePos.x + targetPos.x) / 2,
|
|
2408
|
+
y: (sourcePos.y + targetPos.y) / 2
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
onEdgeClick(event, edge) {
|
|
2412
|
+
event.stopPropagation();
|
|
2413
|
+
event.preventDefault();
|
|
2414
|
+
this.diagramStateService.onEdgeClick(edge);
|
|
2415
|
+
const isMultiSelect = event.ctrlKey || event.metaKey || event.shiftKey;
|
|
2416
|
+
// Clear node selection when selecting edges
|
|
2417
|
+
this.diagramStateService.nodes.update(nodes => nodes.map(n => ({ ...n, selected: false })));
|
|
2418
|
+
// Toggle edge selection
|
|
2419
|
+
this.diagramStateService.edges.update(edges => edges.map(e => ({
|
|
2420
|
+
...e,
|
|
2421
|
+
selected: e.id === edge.id
|
|
2422
|
+
? !e.selected
|
|
2423
|
+
: (isMultiSelect ? e.selected : false)
|
|
2424
|
+
})));
|
|
2425
|
+
}
|
|
2426
|
+
// --- Node Logic ---
|
|
2427
|
+
getCustomNodeComponent(type) {
|
|
2428
|
+
if (type && this.nodeTypes && this.nodeTypes[type]) {
|
|
2429
|
+
return this.nodeTypes[type];
|
|
2430
|
+
}
|
|
2431
|
+
return null;
|
|
2432
|
+
}
|
|
2433
|
+
zoomIn() {
|
|
2434
|
+
const currentViewport = this.viewport();
|
|
2435
|
+
const newZoom = Math.min(currentViewport.zoom * 1.2, 10);
|
|
2436
|
+
this.diagramStateService.setViewport({
|
|
2437
|
+
...currentViewport,
|
|
2438
|
+
zoom: newZoom
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
zoomOut() {
|
|
2442
|
+
const currentViewport = this.viewport();
|
|
2443
|
+
const newZoom = Math.max(currentViewport.zoom / 1.2, 0.1);
|
|
2444
|
+
this.diagramStateService.setViewport({
|
|
2445
|
+
...currentViewport,
|
|
2446
|
+
zoom: newZoom
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
resetZoom() {
|
|
2450
|
+
const currentViewport = this.viewport();
|
|
2451
|
+
this.diagramStateService.setViewport({
|
|
2452
|
+
...currentViewport,
|
|
2453
|
+
zoom: 1
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
fitView() {
|
|
2457
|
+
const nodes = this.nodes();
|
|
2458
|
+
if (nodes.length === 0)
|
|
2459
|
+
return;
|
|
2460
|
+
// Calculate bounds of all nodes
|
|
2461
|
+
let minX = Infinity;
|
|
2462
|
+
let minY = Infinity;
|
|
2463
|
+
let maxX = -Infinity;
|
|
2464
|
+
let maxY = -Infinity;
|
|
2465
|
+
nodes.forEach(node => {
|
|
2466
|
+
const width = node.width || this.defaultNodeWidth;
|
|
2467
|
+
const height = node.height || this.defaultNodeHeight;
|
|
2468
|
+
minX = Math.min(minX, node.position.x);
|
|
2469
|
+
minY = Math.min(minY, node.position.y);
|
|
2470
|
+
maxX = Math.max(maxX, node.position.x + width);
|
|
2471
|
+
maxY = Math.max(maxY, node.position.y + height);
|
|
2472
|
+
});
|
|
2473
|
+
const boundsWidth = maxX - minX;
|
|
2474
|
+
const boundsHeight = maxY - minY;
|
|
2475
|
+
// Get SVG dimensions
|
|
2476
|
+
const svgRect = this.svgRef.nativeElement.getBoundingClientRect();
|
|
2477
|
+
const padding = 50; // Padding around nodes
|
|
2478
|
+
// Calculate zoom to fit
|
|
2479
|
+
const zoomX = (svgRect.width - padding * 2) / boundsWidth;
|
|
2480
|
+
const zoomY = (svgRect.height - padding * 2) / boundsHeight;
|
|
2481
|
+
const zoom = Math.min(zoomX, zoomY, 2); // Max zoom of 2x for fit view
|
|
2482
|
+
// Calculate center position
|
|
2483
|
+
const x = (svgRect.width - boundsWidth * zoom) / 2 - minX * zoom;
|
|
2484
|
+
const y = (svgRect.height - boundsHeight * zoom) / 2 - minY * zoom;
|
|
2485
|
+
this.diagramStateService.setViewport({ x, y, zoom });
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* Returns the current state of the diagram (nodes, edges, viewport).
|
|
2489
|
+
*/
|
|
2490
|
+
getDiagramState() {
|
|
2491
|
+
return this.diagramStateService.getDiagramState();
|
|
2492
|
+
}
|
|
2493
|
+
/**
|
|
2494
|
+
* Sets the state of the diagram.
|
|
2495
|
+
*/
|
|
2496
|
+
setDiagramState(state) {
|
|
2497
|
+
this.diagramStateService.setDiagramState(state);
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Exports the diagram as an SVG file.
|
|
2501
|
+
* @param fileName The name of the file to download (default: 'diagram.svg')
|
|
2502
|
+
* @param download Whether to trigger a download (default: true)
|
|
2503
|
+
* @returns The SVG string
|
|
2504
|
+
*/
|
|
2505
|
+
exportToSVG(fileName = 'diagram.svg', download = true) {
|
|
2506
|
+
const svgElement = this.svgRef.nativeElement;
|
|
2507
|
+
// Clone the SVG to avoid modifying the live diagram
|
|
2508
|
+
const clone = svgElement.cloneNode(true);
|
|
2509
|
+
// Get the bounding box of the content (nodes and edges)
|
|
2510
|
+
// We need to calculate this manually because getBBox() on the clone won't work if it's not in the DOM
|
|
2511
|
+
const nodes = this.nodes();
|
|
2512
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
2513
|
+
if (nodes.length > 0) {
|
|
2514
|
+
nodes.forEach(node => {
|
|
2515
|
+
minX = Math.min(minX, node.position.x);
|
|
2516
|
+
minY = Math.min(minY, node.position.y);
|
|
2517
|
+
maxX = Math.max(maxX, node.position.x + (node.width || this.defaultNodeWidth));
|
|
2518
|
+
maxY = Math.max(maxY, node.position.y + (node.height || this.defaultNodeHeight));
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
else {
|
|
2522
|
+
minX = 0;
|
|
2523
|
+
minY = 0;
|
|
2524
|
+
maxX = 100;
|
|
2525
|
+
maxY = 100;
|
|
2526
|
+
}
|
|
2527
|
+
// Add some padding
|
|
2528
|
+
const padding = 20;
|
|
2529
|
+
minX -= padding;
|
|
2530
|
+
minY -= padding;
|
|
2531
|
+
maxX += padding;
|
|
2532
|
+
maxY += padding;
|
|
2533
|
+
const width = maxX - minX;
|
|
2534
|
+
const height = maxY - minY;
|
|
2535
|
+
// Set the viewBox to the content bounds
|
|
2536
|
+
clone.setAttribute('viewBox', `${minX} ${minY} ${width} ${height}`);
|
|
2537
|
+
clone.setAttribute('width', `${width}`);
|
|
2538
|
+
clone.setAttribute('height', `${height}`);
|
|
2539
|
+
// Remove the transform from the viewport group in the clone to reset zoom/pan
|
|
2540
|
+
// The viewport group is the first child g element
|
|
2541
|
+
const viewportGroup = clone.querySelector('.ngx-workflow__viewport');
|
|
2542
|
+
if (viewportGroup) {
|
|
2543
|
+
viewportGroup.removeAttribute('transform');
|
|
2544
|
+
}
|
|
2545
|
+
// Serialize the SVG
|
|
2546
|
+
const serializer = new XMLSerializer();
|
|
2547
|
+
let svgString = serializer.serializeToString(clone);
|
|
2548
|
+
// Add XML declaration
|
|
2549
|
+
if (!svgString.match(/^<xml/)) {
|
|
2550
|
+
svgString = '<?xml version="1.0" encoding="utf-8"?>\n' + svgString;
|
|
2551
|
+
}
|
|
2552
|
+
if (download) {
|
|
2553
|
+
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
2554
|
+
const url = URL.createObjectURL(blob);
|
|
2555
|
+
this.downloadFile(url, fileName);
|
|
2556
|
+
URL.revokeObjectURL(url);
|
|
2557
|
+
}
|
|
2558
|
+
return svgString;
|
|
2559
|
+
}
|
|
2560
|
+
/**
|
|
2561
|
+
* Exports the diagram as a PNG image.
|
|
2562
|
+
* @param fileName The name of the file to download (default: 'diagram.png')
|
|
2563
|
+
* @param download Whether to trigger a download (default: true)
|
|
2564
|
+
* @returns A promise that resolves to the data URL of the PNG
|
|
2565
|
+
*/
|
|
2566
|
+
async exportToPNG(fileName = 'diagram.png', download = true) {
|
|
2567
|
+
const svgString = this.exportToSVG(fileName, false);
|
|
2568
|
+
return new Promise((resolve, reject) => {
|
|
2569
|
+
const img = new Image();
|
|
2570
|
+
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
2571
|
+
const url = URL.createObjectURL(svgBlob);
|
|
2572
|
+
img.onload = () => {
|
|
2573
|
+
const canvas = document.createElement('canvas');
|
|
2574
|
+
canvas.width = img.width;
|
|
2575
|
+
canvas.height = img.height;
|
|
2576
|
+
const ctx = canvas.getContext('2d');
|
|
2577
|
+
if (!ctx) {
|
|
2578
|
+
reject(new Error('Could not get canvas context'));
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
// Draw white background
|
|
2582
|
+
ctx.fillStyle = '#ffffff';
|
|
2583
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
2584
|
+
ctx.drawImage(img, 0, 0);
|
|
2585
|
+
const pngUrl = canvas.toDataURL('image/png');
|
|
2586
|
+
if (download) {
|
|
2587
|
+
this.downloadFile(pngUrl, fileName);
|
|
2588
|
+
}
|
|
2589
|
+
URL.revokeObjectURL(url);
|
|
2590
|
+
resolve(pngUrl);
|
|
2591
|
+
};
|
|
2592
|
+
img.onerror = (e) => {
|
|
2593
|
+
URL.revokeObjectURL(url);
|
|
2594
|
+
reject(e);
|
|
2595
|
+
};
|
|
2596
|
+
img.src = url;
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Exports the diagram state as a JSON file.
|
|
2601
|
+
* @param fileName The name of the file to download (default: 'diagram.json')
|
|
2602
|
+
*/
|
|
2603
|
+
exportToJSON(fileName = 'diagram.json') {
|
|
2604
|
+
const state = this.getDiagramState();
|
|
2605
|
+
const jsonString = JSON.stringify(state, null, 2);
|
|
2606
|
+
const blob = new Blob([jsonString], { type: 'application/json' });
|
|
2607
|
+
const url = URL.createObjectURL(blob);
|
|
2608
|
+
this.downloadFile(url, fileName);
|
|
2609
|
+
URL.revokeObjectURL(url);
|
|
2610
|
+
}
|
|
2611
|
+
/**
|
|
2612
|
+
* Triggers the file input to select a JSON file for import.
|
|
2613
|
+
*/
|
|
2614
|
+
triggerImport() {
|
|
2615
|
+
const fileInput = document.createElement('input');
|
|
2616
|
+
fileInput.type = 'file';
|
|
2617
|
+
fileInput.accept = '.json';
|
|
2618
|
+
fileInput.style.display = 'none';
|
|
2619
|
+
fileInput.onchange = (e) => this.onFileSelected(e);
|
|
2620
|
+
document.body.appendChild(fileInput);
|
|
2621
|
+
fileInput.click();
|
|
2622
|
+
document.body.removeChild(fileInput);
|
|
2623
|
+
}
|
|
2624
|
+
/**
|
|
2625
|
+
* Handles the file selection for import.
|
|
2626
|
+
*/
|
|
2627
|
+
onFileSelected(event) {
|
|
2628
|
+
const input = event.target;
|
|
2629
|
+
if (!input.files || input.files.length === 0)
|
|
2630
|
+
return;
|
|
2631
|
+
const file = input.files[0];
|
|
2632
|
+
const reader = new FileReader();
|
|
2633
|
+
reader.onload = (e) => {
|
|
2634
|
+
try {
|
|
2635
|
+
const jsonString = e.target?.result;
|
|
2636
|
+
const state = JSON.parse(jsonString);
|
|
2637
|
+
// Basic validation
|
|
2638
|
+
if (state.nodes && state.edges && state.viewport) {
|
|
2639
|
+
this.setDiagramState(state);
|
|
2640
|
+
}
|
|
2641
|
+
else {
|
|
2642
|
+
console.error('Invalid diagram JSON format');
|
|
2643
|
+
// TODO: Show user notification
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
catch (error) {
|
|
2647
|
+
console.error('Error parsing JSON', error);
|
|
2648
|
+
// TODO: Show user notification
|
|
2649
|
+
}
|
|
2650
|
+
};
|
|
2651
|
+
reader.readAsText(file);
|
|
2652
|
+
}
|
|
2653
|
+
downloadFile(url, fileName) {
|
|
2654
|
+
const link = document.createElement('a');
|
|
2655
|
+
link.href = url;
|
|
2656
|
+
link.download = fileName;
|
|
2657
|
+
document.body.appendChild(link);
|
|
2658
|
+
link.click();
|
|
2659
|
+
document.body.removeChild(link);
|
|
2660
|
+
document.body.removeChild(link);
|
|
2661
|
+
}
|
|
2662
|
+
onSearch(event) {
|
|
2663
|
+
const input = event.target;
|
|
2664
|
+
this.diagramStateService.setSearchQuery(input.value);
|
|
2665
|
+
}
|
|
2666
|
+
onFilterType(event) {
|
|
2667
|
+
const select = event.target;
|
|
2668
|
+
this.diagramStateService.setFilterType(select.value || null);
|
|
2669
|
+
}
|
|
2670
|
+
onZoomChange(zoom) {
|
|
2671
|
+
this.diagramStateService.setZoom(zoom);
|
|
2672
|
+
}
|
|
2673
|
+
onMinimapViewportChange(viewport) {
|
|
2674
|
+
this.diagramStateService.setViewport(viewport);
|
|
2675
|
+
}
|
|
2676
|
+
getEdgeHandlePosition(edge, type) {
|
|
2677
|
+
const nodes = this.nodes();
|
|
2678
|
+
const nodeId = type === 'source' ? edge.source : edge.target;
|
|
2679
|
+
const handleId = type === 'source' ? edge.sourceHandle : edge.targetHandle;
|
|
2680
|
+
const node = nodes.find(n => n.id === nodeId);
|
|
2681
|
+
if (!node)
|
|
2682
|
+
return { x: 0, y: 0 };
|
|
2683
|
+
const pos = getHandleAbsolutePosition(node, handleId);
|
|
2684
|
+
return {
|
|
2685
|
+
x: isFinite(pos.x) ? pos.x : 0,
|
|
2686
|
+
y: isFinite(pos.y) ? pos.y : 0
|
|
2687
|
+
};
|
|
2688
|
+
}
|
|
2689
|
+
onKeyDown(event) {
|
|
2690
|
+
const target = event.target;
|
|
2691
|
+
// Ignore if focus is on an input or textarea
|
|
2692
|
+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
|
2693
|
+
return;
|
|
2694
|
+
}
|
|
2695
|
+
// Delete or Backspace to remove selected elements
|
|
2696
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
2697
|
+
this.diagramStateService.deleteSelectedElements();
|
|
2698
|
+
}
|
|
2699
|
+
// Ctrl+A or Cmd+A to select all
|
|
2700
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
|
|
2701
|
+
event.preventDefault(); // Prevent default browser select all
|
|
2702
|
+
this.diagramStateService.selectAll();
|
|
2703
|
+
}
|
|
2704
|
+
// Undo (Ctrl+Z) and Redo (Ctrl+Y or Ctrl+Shift+Z)
|
|
2705
|
+
if (event.ctrlKey || event.metaKey) {
|
|
2706
|
+
if (event.key === 'z') {
|
|
2707
|
+
event.preventDefault();
|
|
2708
|
+
this.diagramStateService.undo();
|
|
2709
|
+
}
|
|
2710
|
+
else if (event.key === 'y' || (event.shiftKey && event.key === 'Z')) {
|
|
2711
|
+
event.preventDefault();
|
|
2712
|
+
this.diagramStateService.redo();
|
|
2713
|
+
}
|
|
2714
|
+
// Clipboard Operations
|
|
2715
|
+
if (event.key === 'c') {
|
|
2716
|
+
// Handled by onCopyKeyPress
|
|
2717
|
+
}
|
|
2718
|
+
else if (event.key === 'v') {
|
|
2719
|
+
// Handled by onPasteKeyPress
|
|
2720
|
+
}
|
|
2721
|
+
else if (event.key === 'x') {
|
|
2722
|
+
// Handled by onCutKeyPress
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: DiagramComponent, deps: [{ token: i0.ElementRef }, { token: i0.Renderer2 }, { token: i0.NgZone }, { token: i0.ChangeDetectorRef }, { token: DiagramStateService }, { token: NGX_WORKFLOW_NODE_TYPES, optional: true }], target: i0.ɵɵFactoryTarget.Component });
|
|
2727
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.13", type: DiagramComponent, isStandalone: true, selector: "ngx-workflow-diagram", inputs: { initialNodes: "initialNodes", initialEdges: "initialEdges", initialViewport: "initialViewport", showZoomControls: "showZoomControls", showMinimap: "showMinimap", showBackground: "showBackground", backgroundVariant: "backgroundVariant", backgroundGap: "backgroundGap", backgroundSize: "backgroundSize", backgroundColor: "backgroundColor", backgroundBgColor: "backgroundBgColor", connectionValidator: "connectionValidator", nodesResizable: "nodesResizable" }, outputs: { nodeClick: "nodeClick", edgeClick: "edgeClick", connect: "connect", nodesChange: "nodesChange", edgesChange: "edgesChange", nodeDoubleClick: "nodeDoubleClick" }, host: { listeners: { "window:keydown.delete": "onDeleteKeyPress($event)", "window:keydown.control.z": "onUndoKeyPress($event)", "window:keydown.meta.z": "onUndoKeyPress($event)", "window:keydown.control.shift.z": "onRedoKeyPress($event)", "window:keydown.meta.shift.z": "onRedoKeyPress($event)", "window:keydown.control.c": "onCopyKeyPress($event)", "window:keydown.meta.c": "onCopyKeyPress($event)", "window:keydown.control.v": "onPasteKeyPress($event)", "window:keydown.meta.v": "onPasteKeyPress($event)", "window:keydown.control.x": "onCutKeyPress($event)", "window:keydown.meta.x": "onCutKeyPress($event)", "window:keydown.control.d": "onDuplicateKeyPress($event)", "window:keydown.meta.d": "onDuplicateKeyPress($event)", "window:keydown.control.g": "onGroupKeyPress($event)", "window:keydown.meta.g": "onGroupKeyPress($event)", "window:keydown.control.shift.g": "onUngroupKeyPress($event)", "window:keydown.meta.shift.g": "onUngroupKeyPress($event)", "window:keydown": "onKeyDown($event)" } }, viewQueries: [{ propertyName: "svgRef", first: true, predicate: ["svg"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-workflow__container\" [attr.data-lod]=\"lodLevel\">\r\n <ngx-workflow-background *ngIf=\"showBackground\" [variant]=\"backgroundVariant\" [gap]=\"backgroundGap\"\r\n [size]=\"backgroundSize\" [color]=\"backgroundColor\" [backgroundColor]=\"backgroundBgColor\"></ngx-workflow-background>\r\n <svg #svg class=\"ngx-workflow__diagram\" (wheel)=\"onWheel($event)\" (pointerdown)=\"onPointerDown($event)\"\r\n (dblclick)=\"onDiagramDoubleClick($event)\">\r\n <defs>\r\n <pattern id=\"ngx-workflow__grid-pattern\" x=\"0\" y=\"0\" width=\"20\" height=\"20\" patternUnits=\"userSpaceOnUse\">\r\n <circle cx=\"0.5\" cy=\"0.5\" r=\"0.5\" fill=\"#ccc\" />\r\n </pattern>\r\n\r\n <!-- Arrow marker -->\r\n <marker id=\"ngx-workflow__arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\"\r\n orient=\"auto-start-reverse\">\r\n <path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"context-stroke\" />\r\n </marker>\r\n\r\n <!-- Arrowclosed marker (filled) -->\r\n <marker id=\"ngx-workflow__arrowclosed\" viewBox=\"0 0 10 10\" refX=\"10\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\"\r\n orient=\"auto-start-reverse\">\r\n <path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"context-stroke\" stroke=\"context-stroke\" />\r\n </marker>\r\n\r\n <!-- Dot marker -->\r\n <marker id=\"ngx-workflow__dot\" viewBox=\"0 0 10 10\" refX=\"5\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\">\r\n <circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"context-stroke\" />\r\n </marker>\r\n </defs>\r\n\r\n <rect width=\"100%\" height=\"100%\" fill=\"url(#ngx-workflow__grid-pattern)\" class=\"ngx-workflow__background\" />\r\n\r\n <g class=\"ngx-workflow__viewport\" [attr.transform]=\"transform\">\r\n <!-- Edges -->\r\n <g *ngFor=\"let edge of edges(); trackBy: trackByEdgeId\" [class.selected]=\"edge.selected\"\r\n [class.animated]=\"edge.animated\" class=\"ngx-workflow__edge\" (click)=\"onEdgeClick($event, edge)\"\r\n (dblclick)=\"onEdgeDoubleClick($event, edge)\">\r\n <!-- Edge path with markers -->\r\n <path [attr.d]=\"getEdgePath(edge)\" class=\"ngx-workflow__edge-path\" [style]=\"edge.style\"\r\n [attr.marker-start]=\"getMarkerUrl(edge.markerStart)\" [attr.marker-end]=\"getMarkerUrl(edge.markerEnd)\">\r\n </path>\r\n <!-- Invisible hitbox for easier clicking -->\r\n <path [attr.d]=\"getEdgePath(edge)\" class=\"ngx-workflow__edge-hitbox\"></path>\r\n\r\n <!-- Edge label -->\r\n <g *ngIf=\"edge.label || editingEdgeId === edge.id\" class=\"ngx-workflow__edge-label\">\r\n <foreignObject *ngIf=\"editingEdgeId === edge.id\" [attr.x]=\"getEdgeLabelPosition(edge).x - 50\"\r\n [attr.y]=\"getEdgeLabelPosition(edge).y - 15\" width=\"100\" height=\"30\">\r\n <input #edgeLabelInput type=\"text\" [value]=\"edge.label || ''\" (blur)=\"onEdgeLabelBlur()\"\r\n (keydown.enter)=\"updateEdgeLabel(edge, edgeLabelInput.value)\" class=\"ngx-workflow__edge-label-input\"\r\n autofocus>\r\n </foreignObject>\r\n\r\n <text *ngIf=\"editingEdgeId !== edge.id\" [attr.x]=\"getEdgeLabelPosition(edge).x\"\r\n [attr.y]=\"getEdgeLabelPosition(edge).y\" text-anchor=\"middle\" dominant-baseline=\"middle\"\r\n class=\"ngx-workflow__edge-label-text\" [style]=\"edge.labelStyle\">\r\n <!-- Label background -->\r\n <tspan *ngIf=\"edge.labelBgStyle\" class=\"ngx-workflow__edge-label-bg\" [style]=\"edge.labelBgStyle\"></tspan>\r\n {{ edge.label }}\r\n </text>\r\n </g>\r\n\r\n <!-- Edge Update Handles -->\r\n <g *ngIf=\"edge.selected\">\r\n <!-- Source Handle -->\r\n <circle class=\"ngx-workflow__edge-handle\" [attr.cx]=\"getEdgeHandlePosition(edge, 'source').x\"\r\n [attr.cy]=\"getEdgeHandlePosition(edge, 'source').y\" r=\"4\"\r\n (pointerdown)=\"startUpdatingEdge($event, edge, 'source')\"></circle>\r\n <!-- Target Handle -->\r\n <circle class=\"ngx-workflow__edge-handle\" [attr.cx]=\"getEdgeHandlePosition(edge, 'target').x\"\r\n [attr.cy]=\"getEdgeHandlePosition(edge, 'target').y\" r=\"4\"\r\n (pointerdown)=\"startUpdatingEdge($event, edge, 'target')\"></circle>\r\n </g>\r\n </g>\r\n\r\n <!-- Render temporary edges (previews) -->\r\n <g *ngFor=\"let tempEdge of tempEdges(); trackBy: trackByEdgeId\" [class.animated]=\"tempEdge.animated\"\r\n class=\"ngx-workflow__edge\">\r\n <path [attr.d]=\"getEdgePath(tempEdge, true)\" class=\"ngx-workflow__edge-path\" [style]=\"tempEdge.style\"></path>\r\n </g>\r\n\r\n <!-- Nodes -->\r\n <g *ngFor=\"let node of filteredNodes(); trackBy: trackByNodeId\" class=\"ngx-workflow__node\"\r\n [attr.data-id]=\"node.id\" [class.selected]=\"node.selected\" [class.dragging]=\"node.dragging\"\r\n [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\"\r\n (dblclick)=\"onNodeDoubleClick($event, node)\">\r\n\r\n <!-- Node outline and background (visual + dragging) -->\r\n <rect [attr.width]=\"node.width || defaultNodeWidth\" [attr.height]=\"node.height || defaultNodeHeight\" rx=\"3\"\r\n ry=\"3\" class=\"ngx-workflow__node-rect\"></rect>\r\n\r\n <!-- Node Label -->\r\n <text *ngIf=\"node.type !== 'group'\" [attr.x]=\"(node.width || defaultNodeWidth) / 2\"\r\n [attr.y]=\"(node.height || defaultNodeHeight) / 2\" text-anchor=\"middle\" dominant-baseline=\"middle\"\r\n class=\"ngx-workflow__node-label\">\r\n {{ node.label }}\r\n </text>\r\n\r\n <!-- Hit area -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) / 2\" cy=\"-10\" r=\"20\" fill=\"transparent\"\r\n pointer-events=\"all\"></circle>\r\n <!-- Visual -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) / 2\" [attr.cy]=\"0\" r=\"6\"\r\n class=\"ngx-workflow__handle-circle\">\r\n </circle>\r\n\r\n <!-- Top Handle -->\r\n <g class=\"ngx-workflow__handle ngx-workflow__handle--source ngx-workflow__handle--target\"\r\n [attr.data-nodeid]=\"node.id\" data-handleid=\"top\" [attr.data-type]=\"'source'\">\r\n <!-- Hit area -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) / 2\" cy=\"-10\" r=\"20\" fill=\"transparent\"\r\n pointer-events=\"all\"></circle>\r\n <!-- Visual -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) / 2\" [attr.cy]=\"0\" r=\"6\"\r\n class=\"ngx-workflow__handle-circle\">\r\n </circle>\r\n </g>\r\n\r\n <!-- Right Handle -->\r\n <g class=\"ngx-workflow__handle ngx-workflow__handle--source ngx-workflow__handle--target\"\r\n [attr.data-nodeid]=\"node.id\" data-handleid=\"right\" [attr.data-type]=\"'source'\">\r\n <!-- Hit area -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) + 10\" [attr.cy]=\"(node.height || defaultNodeHeight) / 2\"\r\n r=\"20\" fill=\"transparent\" pointer-events=\"all\">\r\n </circle>\r\n <!-- Visual -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth)\" [attr.cy]=\"(node.height || defaultNodeHeight) / 2\" r=\"6\"\r\n class=\"ngx-workflow__handle-circle\"></circle>\r\n </g>\r\n <!-- Bottom Handle -->\r\n <g class=\"ngx-workflow__handle ngx-workflow__handle--source ngx-workflow__handle--target\"\r\n [attr.data-nodeid]=\"node.id\" data-handleid=\"bottom\" [attr.data-type]=\"'source'\">\r\n <!-- Hit area -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) / 2\" [attr.cy]=\"(node.height || defaultNodeHeight) + 10\"\r\n r=\"20\" fill=\"transparent\" pointer-events=\"all\">\r\n </circle>\r\n <!-- Visual -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) / 2\" [attr.cy]=\"(node.height || defaultNodeHeight)\" r=\"6\"\r\n class=\"ngx-workflow__handle-circle\"></circle>\r\n </g>\r\n <!-- Left Handle -->\r\n <g class=\"ngx-workflow__handle ngx-workflow__handle--source ngx-workflow__handle--target\"\r\n [attr.data-nodeid]=\"node.id\" data-handleid=\"left\" [attr.data-type]=\"'source'\">\r\n <!-- Hit area -->\r\n <circle cx=\"-10\" [attr.cy]=\"(node.height || defaultNodeHeight) / 2\" r=\"20\" fill=\"transparent\"\r\n pointer-events=\"all\"></circle>\r\n <!-- Visual -->\r\n <circle [attr.cx]=\"0\" [attr.cy]=\"(node.height || defaultNodeHeight) / 2\" r=\"6\"\r\n class=\"ngx-workflow__handle-circle\">\r\n </circle>\r\n </g>\r\n\r\n <!-- Resize Handles -->\r\n <g *ngIf=\"node.selected && (node.resizable !== false) && nodesResizable\">\r\n <!-- NW Handle -->\r\n <rect class=\"ngx-workflow__resize-handle\" data-handle=\"nw\" [attr.x]=\"-5\" [attr.y]=\"-5\" width=\"10\" height=\"10\">\r\n </rect>\r\n <!-- NE Handle -->\r\n <rect class=\"ngx-workflow__resize-handle\" data-handle=\"ne\" [attr.x]=\"(node.width || defaultNodeWidth) - 5\"\r\n [attr.y]=\"-5\" width=\"10\" height=\"10\"></rect>\r\n <!-- SW Handle -->\r\n <rect class=\"ngx-workflow__resize-handle\" data-handle=\"sw\" [attr.x]=\"-5\"\r\n [attr.y]=\"(node.height || defaultNodeHeight) - 5\" width=\"10\" height=\"10\"></rect>\r\n <!-- SE Handle -->\r\n <rect class=\"ngx-workflow__resize-handle\" data-handle=\"se\" [attr.x]=\"(node.width || defaultNodeWidth) - 5\"\r\n [attr.y]=\"(node.height || defaultNodeHeight) - 5\" width=\"10\" height=\"10\"></rect>\r\n </g>\r\n\r\n <!-- Group Node Template -->\r\n <g *ngIf=\"node.type === 'group'\" class=\"ngx-workflow__group-node\">\r\n <!-- Header -->\r\n <rect [attr.width]=\"node.width\" height=\"30\" class=\"ngx-workflow__group-header\"></rect>\r\n <text [attr.x]=\"10\" [attr.y]=\"20\" class=\"ngx-workflow__group-label\">{{ node.label }}</text>\r\n\r\n <!-- Expand/Collapse Toggle -->\r\n <g class=\"ngx-workflow__group-toggle\" [attr.transform]=\"'translate(' + (node.width! - 25) + ', 5)'\"\r\n (click)=\"toggleGroup($event, node)\">\r\n <rect width=\"20\" height=\"20\" rx=\"4\" class=\"ngx-workflow__group-toggle-bg\"></rect>\r\n <path [attr.d]=\"node.expanded ? 'M 5 10 L 15 10' : 'M 5 10 L 15 10 M 10 5 L 10 15'\"\r\n class=\"ngx-workflow__group-toggle-icon\"></path>\r\n </g>\r\n </g>\r\n </g>\r\n\r\n <!-- Alignment Guides -->\r\n <g *ngFor=\"let guide of alignmentGuides()\" class=\"ngx-workflow__alignment-guide\">\r\n <line *ngIf=\"guide.type === 'horizontal'\" [attr.x1]=\"guide.start\" [attr.y1]=\"guide.position\"\r\n [attr.x2]=\"guide.end\" [attr.y2]=\"guide.position\" />\r\n <line *ngIf=\"guide.type === 'vertical'\" [attr.x1]=\"guide.position\" [attr.y1]=\"guide.start\"\r\n [attr.x2]=\"guide.position\" [attr.y2]=\"guide.end\" />\r\n </g>\r\n </g>\r\n\r\n </svg>\r\n\r\n <!-- IO Controls -->\r\n <div class=\"ngx-workflow__io-controls\">\r\n <input type=\"text\" placeholder=\"Search...\" (input)=\"onSearch($event)\">\r\n <select (change)=\"onFilterType($event)\">\r\n <option value=\"\">All Types</option>\r\n <option *ngFor=\"let type of nodeTypeKeys\" [value]=\"type\">{{ type }}</option>\r\n </select>\r\n <div class=\"ngx-workflow__separator\"></div>\r\n <button (click)=\"exportToJSON()\">Export JSON</button>\r\n <button (click)=\"triggerImport()\">Import JSON</button>\r\n <button (click)=\"exportToPNG()\">Export PNG</button>\r\n <button (click)=\"exportToSVG()\">Export SVG</button>\r\n </div>\r\n\r\n <ngx-workflow-zoom-controls *ngIf=\"showZoomControls\" [zoom]=\"viewport().zoom\" [minZoom]=\"0.1\" [maxZoom]=\"4\"\r\n (zoomIn)=\"onZoomChange(viewport().zoom + 0.1)\" (zoomOut)=\"onZoomChange(viewport().zoom - 0.1)\"\r\n (fitView)=\"onZoomChange(1)\" (resetZoom)=\"onZoomChange(1)\"></ngx-workflow-zoom-controls>\r\n\r\n <ngx-workflow-alignment-controls></ngx-workflow-alignment-controls>\r\n\r\n <ngx-workflow-minimap *ngIf=\"showMinimap\"></ngx-workflow-minimap>\r\n\r\n <ngx-workflow-properties-sidebar [node]=\"selectedNodeForEditing\" (close)=\"closeSidebar()\"\r\n (change)=\"onPropertiesChange($event)\"></ngx-workflow-properties-sidebar>\r\n</div>", styles: [":host{--ngx-workflow-primary: #3b82f6;--ngx-workflow-primary-hover: #2563eb;--ngx-workflow-bg: #f8fafc;--ngx-workflow-surface: #ffffff;--ngx-workflow-border: #e2e8f0;--ngx-workflow-text-primary: #1e293b;--ngx-workflow-text-secondary: #64748b;--ngx-workflow-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / .05);--ngx-workflow-shadow-md: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--ngx-workflow-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--ngx-workflow-glass-bg: rgba(255, 255, 255, .8);--ngx-workflow-glass-border: rgba(255, 255, 255, .5);--ngx-workflow-glass-blur: blur(12px);display:block;width:100%;height:100%;font-family:Inter,system-ui,-apple-system,sans-serif}.ngx-workflow__container{position:relative;width:100%;height:100%;overflow:hidden;background-color:var(--ngx-workflow-bg)}.ngx-workflow__diagram{width:100%;height:100%;cursor:grab;position:relative;z-index:1}.ngx-workflow__diagram:active{cursor:grabbing}.ngx-workflow__background{fill:url(#ngx-workflow__grid-pattern);opacity:.6}.ngx-workflow__viewport{transform-origin:0 0}.ngx-workflow__node{cursor:grab;transition:transform .1s ease,opacity .2s ease}.ngx-workflow__node .ngx-workflow__node-rect{fill:var(--ngx-workflow-surface);stroke:var(--ngx-workflow-border);stroke-width:1.5px;filter:drop-shadow(0 2px 4px rgba(0,0,0,.05));transition:all .2s cubic-bezier(.4,0,.2,1);pointer-events:all}.ngx-workflow__node .ngx-workflow__node-label{font-size:12px;font-weight:500;fill:var(--ngx-workflow-text-primary);pointer-events:none;-webkit-user-select:none;user-select:none}.ngx-workflow__node:hover .ngx-workflow__node-rect{stroke:var(--ngx-workflow-primary);filter:drop-shadow(0 4px 6px rgba(0,0,0,.08));transform:translateY(-1px)}.ngx-workflow__node.selected .ngx-workflow__node-rect{stroke:var(--ngx-workflow-primary);stroke-width:2px;filter:drop-shadow(0 0 0 2px rgba(59,130,246,.2))}.ngx-workflow__node.dragging{cursor:grabbing;opacity:.9}.ngx-workflow__node.dragging .ngx-workflow__node-rect{filter:drop-shadow(0 10px 15px rgba(0,0,0,.1));transform:scale(1.02)}.ngx-workflow__node.dimmed{opacity:.4}foreignObject{overflow:visible;pointer-events:none}foreignObject *{pointer-events:auto}.ngx-workflow__edge{pointer-events:none}.ngx-workflow__edge-path{fill:none;stroke:#94a3b8;stroke-width:2;transition:stroke .2s ease,stroke-width .2s ease;pointer-events:stroke;cursor:pointer}.ngx-workflow__edge-hitbox{fill:none;stroke:transparent;stroke-width:20;pointer-events:stroke;cursor:pointer}.ngx-workflow__edge:hover .ngx-workflow__edge-path{stroke:var(--ngx-workflow-primary)}.ngx-workflow__edge.selected .ngx-workflow__edge-path{stroke:var(--ngx-workflow-primary);stroke-width:3;filter:drop-shadow(0 1px 2px rgba(59,130,246,.3))}.ngx-workflow__edge.animated .ngx-workflow__edge-path{stroke-dasharray:10;animation:flowAnimation 1s linear infinite}@keyframes flowAnimation{0%{stroke-dashoffset:20}to{stroke-dashoffset:0}}.ngx-workflow__edge-label{pointer-events:none}.ngx-workflow__edge-label-text{font-family:Inter,sans-serif;font-size:12px;font-weight:500;fill:var(--ngx-workflow-text-secondary);text-anchor:middle;dominant-baseline:middle}.ngx-workflow__edge-label-bg{fill:var(--ngx-workflow-surface);rx:4;ry:4;filter:drop-shadow(0 1px 2px rgba(0,0,0,.1))}.ngx-workflow__edge-label-input{width:100%;height:100%;border:1px solid var(--ngx-workflow-primary);border-radius:4px;padding:2px 6px;font-size:12px;text-align:center;background:var(--ngx-workflow-surface);pointer-events:all;outline:none;box-shadow:var(--ngx-workflow-shadow-sm)}.ngx-workflow__handle{opacity:1;transition:transform .2s cubic-bezier(.34,1.56,.64,1);pointer-events:all}.ngx-workflow__handle-circle{fill:var(--ngx-workflow-surface);stroke:var(--ngx-workflow-text-secondary);stroke-width:1.5;transition:all .2s ease}.ngx-workflow__handle-circle:hover{fill:var(--ngx-workflow-primary);stroke:var(--ngx-workflow-primary);r:7}.ngx-workflow__handle.ngx-workflow__handle--valid-target .ngx-workflow__handle-circle{fill:#10b981!important;stroke:#059669!important;r:8;stroke-width:2}.ngx-workflow__io-controls{position:absolute;top:16px;left:16px;z-index:10;display:flex;gap:8px;padding:8px;background:var(--ngx-workflow-glass-bg);-webkit-backdrop-filter:var(--ngx-workflow-glass-blur);backdrop-filter:var(--ngx-workflow-glass-blur);border:1px solid var(--ngx-workflow-glass-border);border-radius:12px;box-shadow:var(--ngx-workflow-shadow-md);align-items:center}.ngx-workflow__io-controls input[type=text]{padding:6px 12px;border:1px solid transparent;border-radius:8px;font-size:13px;background:#0000000d;transition:all .2s ease;width:160px}.ngx-workflow__io-controls input[type=text]:focus{background:#fff;border-color:var(--ngx-workflow-primary);outline:none;box-shadow:0 0 0 2px #3b82f61a}.ngx-workflow__io-controls select{padding:6px 12px;border:1px solid transparent;border-radius:8px;font-size:13px;background:#0000000d;cursor:pointer;transition:all .2s ease}.ngx-workflow__io-controls select:hover{background:#00000014}.ngx-workflow__io-controls select:focus{outline:none;border-color:var(--ngx-workflow-primary)}.ngx-workflow__io-controls .ngx-workflow__separator{width:1px;height:24px;background:#0000001a;margin:0 4px}.ngx-workflow__io-controls button{padding:6px 12px;background:transparent;border:1px solid transparent;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;color:var(--ngx-workflow-text-primary);transition:all .2s ease}.ngx-workflow__io-controls button:hover{background:#0000000d;color:var(--ngx-workflow-primary)}.ngx-workflow__io-controls button:active{transform:translateY(1px)}.ngx-workflow__group-node .ngx-workflow__group-header{fill:#00000008;stroke:none}.ngx-workflow__group-node .ngx-workflow__group-label{font-family:Inter,sans-serif;font-size:11px;font-weight:600;fill:var(--ngx-workflow-text-secondary);text-transform:uppercase;letter-spacing:.5px}.ngx-workflow__group-node .ngx-workflow__group-toggle{cursor:pointer;opacity:.6;transition:opacity .2s}.ngx-workflow__group-node .ngx-workflow__group-toggle:hover{opacity:1}.ngx-workflow__group-node .ngx-workflow__group-toggle-bg{fill:#0000001a}.ngx-workflow__group-node .ngx-workflow__group-toggle-icon{stroke:var(--ngx-workflow-text-secondary);stroke-width:1.5;fill:none}.ngx-workflow__alignment-guide{pointer-events:none}.ngx-workflow__alignment-guide line{stroke:#ec4899;stroke-width:1;stroke-dasharray:4 4;opacity:.8}.ngx-workflow__resize-handle{fill:var(--ngx-workflow-surface);stroke:var(--ngx-workflow-primary);stroke-width:1.5;width:8px;height:8px;transition:transform .2s ease;transform-box:fill-box;transform-origin:center}.ngx-workflow__resize-handle:hover{fill:var(--ngx-workflow-primary);transform:scale(1.2)}.ngx-workflow__resize-handle[data-handle=nw]{cursor:nw-resize}.ngx-workflow__resize-handle[data-handle=ne]{cursor:ne-resize}.ngx-workflow__resize-handle[data-handle=sw]{cursor:sw-resize}.ngx-workflow__resize-handle[data-handle=se]{cursor:se-resize}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: ZoomControlsComponent, selector: "ngx-workflow-zoom-controls", inputs: ["zoom", "minZoom", "maxZoom"], outputs: ["zoomIn", "zoomOut", "fitView", "resetZoom"] }, { kind: "component", type: MinimapComponent, selector: "ngx-workflow-minimap", inputs: ["nodeColor", "nodeClass"] }, { kind: "component", type: BackgroundComponent, selector: "ngx-workflow-background", inputs: ["variant", "gap", "size", "color", "backgroundColor"] }, { kind: "component", type: AlignmentControlsComponent, selector: "ngx-workflow-alignment-controls" }, { kind: "component", type: PropertiesSidebarComponent, selector: "ngx-workflow-properties-sidebar", inputs: ["node"], outputs: ["close", "change"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2728
|
+
}
|
|
2729
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: DiagramComponent, decorators: [{
|
|
2730
|
+
type: Component,
|
|
2731
|
+
args: [{ selector: 'ngx-workflow-diagram', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [CommonModule, ZoomControlsComponent, MinimapComponent, BackgroundComponent, AlignmentControlsComponent, PropertiesSidebarComponent], template: "<div class=\"ngx-workflow__container\" [attr.data-lod]=\"lodLevel\">\r\n <ngx-workflow-background *ngIf=\"showBackground\" [variant]=\"backgroundVariant\" [gap]=\"backgroundGap\"\r\n [size]=\"backgroundSize\" [color]=\"backgroundColor\" [backgroundColor]=\"backgroundBgColor\"></ngx-workflow-background>\r\n <svg #svg class=\"ngx-workflow__diagram\" (wheel)=\"onWheel($event)\" (pointerdown)=\"onPointerDown($event)\"\r\n (dblclick)=\"onDiagramDoubleClick($event)\">\r\n <defs>\r\n <pattern id=\"ngx-workflow__grid-pattern\" x=\"0\" y=\"0\" width=\"20\" height=\"20\" patternUnits=\"userSpaceOnUse\">\r\n <circle cx=\"0.5\" cy=\"0.5\" r=\"0.5\" fill=\"#ccc\" />\r\n </pattern>\r\n\r\n <!-- Arrow marker -->\r\n <marker id=\"ngx-workflow__arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\"\r\n orient=\"auto-start-reverse\">\r\n <path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"context-stroke\" />\r\n </marker>\r\n\r\n <!-- Arrowclosed marker (filled) -->\r\n <marker id=\"ngx-workflow__arrowclosed\" viewBox=\"0 0 10 10\" refX=\"10\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\"\r\n orient=\"auto-start-reverse\">\r\n <path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"context-stroke\" stroke=\"context-stroke\" />\r\n </marker>\r\n\r\n <!-- Dot marker -->\r\n <marker id=\"ngx-workflow__dot\" viewBox=\"0 0 10 10\" refX=\"5\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\">\r\n <circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"context-stroke\" />\r\n </marker>\r\n </defs>\r\n\r\n <rect width=\"100%\" height=\"100%\" fill=\"url(#ngx-workflow__grid-pattern)\" class=\"ngx-workflow__background\" />\r\n\r\n <g class=\"ngx-workflow__viewport\" [attr.transform]=\"transform\">\r\n <!-- Edges -->\r\n <g *ngFor=\"let edge of edges(); trackBy: trackByEdgeId\" [class.selected]=\"edge.selected\"\r\n [class.animated]=\"edge.animated\" class=\"ngx-workflow__edge\" (click)=\"onEdgeClick($event, edge)\"\r\n (dblclick)=\"onEdgeDoubleClick($event, edge)\">\r\n <!-- Edge path with markers -->\r\n <path [attr.d]=\"getEdgePath(edge)\" class=\"ngx-workflow__edge-path\" [style]=\"edge.style\"\r\n [attr.marker-start]=\"getMarkerUrl(edge.markerStart)\" [attr.marker-end]=\"getMarkerUrl(edge.markerEnd)\">\r\n </path>\r\n <!-- Invisible hitbox for easier clicking -->\r\n <path [attr.d]=\"getEdgePath(edge)\" class=\"ngx-workflow__edge-hitbox\"></path>\r\n\r\n <!-- Edge label -->\r\n <g *ngIf=\"edge.label || editingEdgeId === edge.id\" class=\"ngx-workflow__edge-label\">\r\n <foreignObject *ngIf=\"editingEdgeId === edge.id\" [attr.x]=\"getEdgeLabelPosition(edge).x - 50\"\r\n [attr.y]=\"getEdgeLabelPosition(edge).y - 15\" width=\"100\" height=\"30\">\r\n <input #edgeLabelInput type=\"text\" [value]=\"edge.label || ''\" (blur)=\"onEdgeLabelBlur()\"\r\n (keydown.enter)=\"updateEdgeLabel(edge, edgeLabelInput.value)\" class=\"ngx-workflow__edge-label-input\"\r\n autofocus>\r\n </foreignObject>\r\n\r\n <text *ngIf=\"editingEdgeId !== edge.id\" [attr.x]=\"getEdgeLabelPosition(edge).x\"\r\n [attr.y]=\"getEdgeLabelPosition(edge).y\" text-anchor=\"middle\" dominant-baseline=\"middle\"\r\n class=\"ngx-workflow__edge-label-text\" [style]=\"edge.labelStyle\">\r\n <!-- Label background -->\r\n <tspan *ngIf=\"edge.labelBgStyle\" class=\"ngx-workflow__edge-label-bg\" [style]=\"edge.labelBgStyle\"></tspan>\r\n {{ edge.label }}\r\n </text>\r\n </g>\r\n\r\n <!-- Edge Update Handles -->\r\n <g *ngIf=\"edge.selected\">\r\n <!-- Source Handle -->\r\n <circle class=\"ngx-workflow__edge-handle\" [attr.cx]=\"getEdgeHandlePosition(edge, 'source').x\"\r\n [attr.cy]=\"getEdgeHandlePosition(edge, 'source').y\" r=\"4\"\r\n (pointerdown)=\"startUpdatingEdge($event, edge, 'source')\"></circle>\r\n <!-- Target Handle -->\r\n <circle class=\"ngx-workflow__edge-handle\" [attr.cx]=\"getEdgeHandlePosition(edge, 'target').x\"\r\n [attr.cy]=\"getEdgeHandlePosition(edge, 'target').y\" r=\"4\"\r\n (pointerdown)=\"startUpdatingEdge($event, edge, 'target')\"></circle>\r\n </g>\r\n </g>\r\n\r\n <!-- Render temporary edges (previews) -->\r\n <g *ngFor=\"let tempEdge of tempEdges(); trackBy: trackByEdgeId\" [class.animated]=\"tempEdge.animated\"\r\n class=\"ngx-workflow__edge\">\r\n <path [attr.d]=\"getEdgePath(tempEdge, true)\" class=\"ngx-workflow__edge-path\" [style]=\"tempEdge.style\"></path>\r\n </g>\r\n\r\n <!-- Nodes -->\r\n <g *ngFor=\"let node of filteredNodes(); trackBy: trackByNodeId\" class=\"ngx-workflow__node\"\r\n [attr.data-id]=\"node.id\" [class.selected]=\"node.selected\" [class.dragging]=\"node.dragging\"\r\n [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\"\r\n (dblclick)=\"onNodeDoubleClick($event, node)\">\r\n\r\n <!-- Node outline and background (visual + dragging) -->\r\n <rect [attr.width]=\"node.width || defaultNodeWidth\" [attr.height]=\"node.height || defaultNodeHeight\" rx=\"3\"\r\n ry=\"3\" class=\"ngx-workflow__node-rect\"></rect>\r\n\r\n <!-- Node Label -->\r\n <text *ngIf=\"node.type !== 'group'\" [attr.x]=\"(node.width || defaultNodeWidth) / 2\"\r\n [attr.y]=\"(node.height || defaultNodeHeight) / 2\" text-anchor=\"middle\" dominant-baseline=\"middle\"\r\n class=\"ngx-workflow__node-label\">\r\n {{ node.label }}\r\n </text>\r\n\r\n <!-- Hit area -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) / 2\" cy=\"-10\" r=\"20\" fill=\"transparent\"\r\n pointer-events=\"all\"></circle>\r\n <!-- Visual -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) / 2\" [attr.cy]=\"0\" r=\"6\"\r\n class=\"ngx-workflow__handle-circle\">\r\n </circle>\r\n\r\n <!-- Top Handle -->\r\n <g class=\"ngx-workflow__handle ngx-workflow__handle--source ngx-workflow__handle--target\"\r\n [attr.data-nodeid]=\"node.id\" data-handleid=\"top\" [attr.data-type]=\"'source'\">\r\n <!-- Hit area -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) / 2\" cy=\"-10\" r=\"20\" fill=\"transparent\"\r\n pointer-events=\"all\"></circle>\r\n <!-- Visual -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) / 2\" [attr.cy]=\"0\" r=\"6\"\r\n class=\"ngx-workflow__handle-circle\">\r\n </circle>\r\n </g>\r\n\r\n <!-- Right Handle -->\r\n <g class=\"ngx-workflow__handle ngx-workflow__handle--source ngx-workflow__handle--target\"\r\n [attr.data-nodeid]=\"node.id\" data-handleid=\"right\" [attr.data-type]=\"'source'\">\r\n <!-- Hit area -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) + 10\" [attr.cy]=\"(node.height || defaultNodeHeight) / 2\"\r\n r=\"20\" fill=\"transparent\" pointer-events=\"all\">\r\n </circle>\r\n <!-- Visual -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth)\" [attr.cy]=\"(node.height || defaultNodeHeight) / 2\" r=\"6\"\r\n class=\"ngx-workflow__handle-circle\"></circle>\r\n </g>\r\n <!-- Bottom Handle -->\r\n <g class=\"ngx-workflow__handle ngx-workflow__handle--source ngx-workflow__handle--target\"\r\n [attr.data-nodeid]=\"node.id\" data-handleid=\"bottom\" [attr.data-type]=\"'source'\">\r\n <!-- Hit area -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) / 2\" [attr.cy]=\"(node.height || defaultNodeHeight) + 10\"\r\n r=\"20\" fill=\"transparent\" pointer-events=\"all\">\r\n </circle>\r\n <!-- Visual -->\r\n <circle [attr.cx]=\"(node.width || defaultNodeWidth) / 2\" [attr.cy]=\"(node.height || defaultNodeHeight)\" r=\"6\"\r\n class=\"ngx-workflow__handle-circle\"></circle>\r\n </g>\r\n <!-- Left Handle -->\r\n <g class=\"ngx-workflow__handle ngx-workflow__handle--source ngx-workflow__handle--target\"\r\n [attr.data-nodeid]=\"node.id\" data-handleid=\"left\" [attr.data-type]=\"'source'\">\r\n <!-- Hit area -->\r\n <circle cx=\"-10\" [attr.cy]=\"(node.height || defaultNodeHeight) / 2\" r=\"20\" fill=\"transparent\"\r\n pointer-events=\"all\"></circle>\r\n <!-- Visual -->\r\n <circle [attr.cx]=\"0\" [attr.cy]=\"(node.height || defaultNodeHeight) / 2\" r=\"6\"\r\n class=\"ngx-workflow__handle-circle\">\r\n </circle>\r\n </g>\r\n\r\n <!-- Resize Handles -->\r\n <g *ngIf=\"node.selected && (node.resizable !== false) && nodesResizable\">\r\n <!-- NW Handle -->\r\n <rect class=\"ngx-workflow__resize-handle\" data-handle=\"nw\" [attr.x]=\"-5\" [attr.y]=\"-5\" width=\"10\" height=\"10\">\r\n </rect>\r\n <!-- NE Handle -->\r\n <rect class=\"ngx-workflow__resize-handle\" data-handle=\"ne\" [attr.x]=\"(node.width || defaultNodeWidth) - 5\"\r\n [attr.y]=\"-5\" width=\"10\" height=\"10\"></rect>\r\n <!-- SW Handle -->\r\n <rect class=\"ngx-workflow__resize-handle\" data-handle=\"sw\" [attr.x]=\"-5\"\r\n [attr.y]=\"(node.height || defaultNodeHeight) - 5\" width=\"10\" height=\"10\"></rect>\r\n <!-- SE Handle -->\r\n <rect class=\"ngx-workflow__resize-handle\" data-handle=\"se\" [attr.x]=\"(node.width || defaultNodeWidth) - 5\"\r\n [attr.y]=\"(node.height || defaultNodeHeight) - 5\" width=\"10\" height=\"10\"></rect>\r\n </g>\r\n\r\n <!-- Group Node Template -->\r\n <g *ngIf=\"node.type === 'group'\" class=\"ngx-workflow__group-node\">\r\n <!-- Header -->\r\n <rect [attr.width]=\"node.width\" height=\"30\" class=\"ngx-workflow__group-header\"></rect>\r\n <text [attr.x]=\"10\" [attr.y]=\"20\" class=\"ngx-workflow__group-label\">{{ node.label }}</text>\r\n\r\n <!-- Expand/Collapse Toggle -->\r\n <g class=\"ngx-workflow__group-toggle\" [attr.transform]=\"'translate(' + (node.width! - 25) + ', 5)'\"\r\n (click)=\"toggleGroup($event, node)\">\r\n <rect width=\"20\" height=\"20\" rx=\"4\" class=\"ngx-workflow__group-toggle-bg\"></rect>\r\n <path [attr.d]=\"node.expanded ? 'M 5 10 L 15 10' : 'M 5 10 L 15 10 M 10 5 L 10 15'\"\r\n class=\"ngx-workflow__group-toggle-icon\"></path>\r\n </g>\r\n </g>\r\n </g>\r\n\r\n <!-- Alignment Guides -->\r\n <g *ngFor=\"let guide of alignmentGuides()\" class=\"ngx-workflow__alignment-guide\">\r\n <line *ngIf=\"guide.type === 'horizontal'\" [attr.x1]=\"guide.start\" [attr.y1]=\"guide.position\"\r\n [attr.x2]=\"guide.end\" [attr.y2]=\"guide.position\" />\r\n <line *ngIf=\"guide.type === 'vertical'\" [attr.x1]=\"guide.position\" [attr.y1]=\"guide.start\"\r\n [attr.x2]=\"guide.position\" [attr.y2]=\"guide.end\" />\r\n </g>\r\n </g>\r\n\r\n </svg>\r\n\r\n <!-- IO Controls -->\r\n <div class=\"ngx-workflow__io-controls\">\r\n <input type=\"text\" placeholder=\"Search...\" (input)=\"onSearch($event)\">\r\n <select (change)=\"onFilterType($event)\">\r\n <option value=\"\">All Types</option>\r\n <option *ngFor=\"let type of nodeTypeKeys\" [value]=\"type\">{{ type }}</option>\r\n </select>\r\n <div class=\"ngx-workflow__separator\"></div>\r\n <button (click)=\"exportToJSON()\">Export JSON</button>\r\n <button (click)=\"triggerImport()\">Import JSON</button>\r\n <button (click)=\"exportToPNG()\">Export PNG</button>\r\n <button (click)=\"exportToSVG()\">Export SVG</button>\r\n </div>\r\n\r\n <ngx-workflow-zoom-controls *ngIf=\"showZoomControls\" [zoom]=\"viewport().zoom\" [minZoom]=\"0.1\" [maxZoom]=\"4\"\r\n (zoomIn)=\"onZoomChange(viewport().zoom + 0.1)\" (zoomOut)=\"onZoomChange(viewport().zoom - 0.1)\"\r\n (fitView)=\"onZoomChange(1)\" (resetZoom)=\"onZoomChange(1)\"></ngx-workflow-zoom-controls>\r\n\r\n <ngx-workflow-alignment-controls></ngx-workflow-alignment-controls>\r\n\r\n <ngx-workflow-minimap *ngIf=\"showMinimap\"></ngx-workflow-minimap>\r\n\r\n <ngx-workflow-properties-sidebar [node]=\"selectedNodeForEditing\" (close)=\"closeSidebar()\"\r\n (change)=\"onPropertiesChange($event)\"></ngx-workflow-properties-sidebar>\r\n</div>", styles: [":host{--ngx-workflow-primary: #3b82f6;--ngx-workflow-primary-hover: #2563eb;--ngx-workflow-bg: #f8fafc;--ngx-workflow-surface: #ffffff;--ngx-workflow-border: #e2e8f0;--ngx-workflow-text-primary: #1e293b;--ngx-workflow-text-secondary: #64748b;--ngx-workflow-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / .05);--ngx-workflow-shadow-md: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--ngx-workflow-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--ngx-workflow-glass-bg: rgba(255, 255, 255, .8);--ngx-workflow-glass-border: rgba(255, 255, 255, .5);--ngx-workflow-glass-blur: blur(12px);display:block;width:100%;height:100%;font-family:Inter,system-ui,-apple-system,sans-serif}.ngx-workflow__container{position:relative;width:100%;height:100%;overflow:hidden;background-color:var(--ngx-workflow-bg)}.ngx-workflow__diagram{width:100%;height:100%;cursor:grab;position:relative;z-index:1}.ngx-workflow__diagram:active{cursor:grabbing}.ngx-workflow__background{fill:url(#ngx-workflow__grid-pattern);opacity:.6}.ngx-workflow__viewport{transform-origin:0 0}.ngx-workflow__node{cursor:grab;transition:transform .1s ease,opacity .2s ease}.ngx-workflow__node .ngx-workflow__node-rect{fill:var(--ngx-workflow-surface);stroke:var(--ngx-workflow-border);stroke-width:1.5px;filter:drop-shadow(0 2px 4px rgba(0,0,0,.05));transition:all .2s cubic-bezier(.4,0,.2,1);pointer-events:all}.ngx-workflow__node .ngx-workflow__node-label{font-size:12px;font-weight:500;fill:var(--ngx-workflow-text-primary);pointer-events:none;-webkit-user-select:none;user-select:none}.ngx-workflow__node:hover .ngx-workflow__node-rect{stroke:var(--ngx-workflow-primary);filter:drop-shadow(0 4px 6px rgba(0,0,0,.08));transform:translateY(-1px)}.ngx-workflow__node.selected .ngx-workflow__node-rect{stroke:var(--ngx-workflow-primary);stroke-width:2px;filter:drop-shadow(0 0 0 2px rgba(59,130,246,.2))}.ngx-workflow__node.dragging{cursor:grabbing;opacity:.9}.ngx-workflow__node.dragging .ngx-workflow__node-rect{filter:drop-shadow(0 10px 15px rgba(0,0,0,.1));transform:scale(1.02)}.ngx-workflow__node.dimmed{opacity:.4}foreignObject{overflow:visible;pointer-events:none}foreignObject *{pointer-events:auto}.ngx-workflow__edge{pointer-events:none}.ngx-workflow__edge-path{fill:none;stroke:#94a3b8;stroke-width:2;transition:stroke .2s ease,stroke-width .2s ease;pointer-events:stroke;cursor:pointer}.ngx-workflow__edge-hitbox{fill:none;stroke:transparent;stroke-width:20;pointer-events:stroke;cursor:pointer}.ngx-workflow__edge:hover .ngx-workflow__edge-path{stroke:var(--ngx-workflow-primary)}.ngx-workflow__edge.selected .ngx-workflow__edge-path{stroke:var(--ngx-workflow-primary);stroke-width:3;filter:drop-shadow(0 1px 2px rgba(59,130,246,.3))}.ngx-workflow__edge.animated .ngx-workflow__edge-path{stroke-dasharray:10;animation:flowAnimation 1s linear infinite}@keyframes flowAnimation{0%{stroke-dashoffset:20}to{stroke-dashoffset:0}}.ngx-workflow__edge-label{pointer-events:none}.ngx-workflow__edge-label-text{font-family:Inter,sans-serif;font-size:12px;font-weight:500;fill:var(--ngx-workflow-text-secondary);text-anchor:middle;dominant-baseline:middle}.ngx-workflow__edge-label-bg{fill:var(--ngx-workflow-surface);rx:4;ry:4;filter:drop-shadow(0 1px 2px rgba(0,0,0,.1))}.ngx-workflow__edge-label-input{width:100%;height:100%;border:1px solid var(--ngx-workflow-primary);border-radius:4px;padding:2px 6px;font-size:12px;text-align:center;background:var(--ngx-workflow-surface);pointer-events:all;outline:none;box-shadow:var(--ngx-workflow-shadow-sm)}.ngx-workflow__handle{opacity:1;transition:transform .2s cubic-bezier(.34,1.56,.64,1);pointer-events:all}.ngx-workflow__handle-circle{fill:var(--ngx-workflow-surface);stroke:var(--ngx-workflow-text-secondary);stroke-width:1.5;transition:all .2s ease}.ngx-workflow__handle-circle:hover{fill:var(--ngx-workflow-primary);stroke:var(--ngx-workflow-primary);r:7}.ngx-workflow__handle.ngx-workflow__handle--valid-target .ngx-workflow__handle-circle{fill:#10b981!important;stroke:#059669!important;r:8;stroke-width:2}.ngx-workflow__io-controls{position:absolute;top:16px;left:16px;z-index:10;display:flex;gap:8px;padding:8px;background:var(--ngx-workflow-glass-bg);-webkit-backdrop-filter:var(--ngx-workflow-glass-blur);backdrop-filter:var(--ngx-workflow-glass-blur);border:1px solid var(--ngx-workflow-glass-border);border-radius:12px;box-shadow:var(--ngx-workflow-shadow-md);align-items:center}.ngx-workflow__io-controls input[type=text]{padding:6px 12px;border:1px solid transparent;border-radius:8px;font-size:13px;background:#0000000d;transition:all .2s ease;width:160px}.ngx-workflow__io-controls input[type=text]:focus{background:#fff;border-color:var(--ngx-workflow-primary);outline:none;box-shadow:0 0 0 2px #3b82f61a}.ngx-workflow__io-controls select{padding:6px 12px;border:1px solid transparent;border-radius:8px;font-size:13px;background:#0000000d;cursor:pointer;transition:all .2s ease}.ngx-workflow__io-controls select:hover{background:#00000014}.ngx-workflow__io-controls select:focus{outline:none;border-color:var(--ngx-workflow-primary)}.ngx-workflow__io-controls .ngx-workflow__separator{width:1px;height:24px;background:#0000001a;margin:0 4px}.ngx-workflow__io-controls button{padding:6px 12px;background:transparent;border:1px solid transparent;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;color:var(--ngx-workflow-text-primary);transition:all .2s ease}.ngx-workflow__io-controls button:hover{background:#0000000d;color:var(--ngx-workflow-primary)}.ngx-workflow__io-controls button:active{transform:translateY(1px)}.ngx-workflow__group-node .ngx-workflow__group-header{fill:#00000008;stroke:none}.ngx-workflow__group-node .ngx-workflow__group-label{font-family:Inter,sans-serif;font-size:11px;font-weight:600;fill:var(--ngx-workflow-text-secondary);text-transform:uppercase;letter-spacing:.5px}.ngx-workflow__group-node .ngx-workflow__group-toggle{cursor:pointer;opacity:.6;transition:opacity .2s}.ngx-workflow__group-node .ngx-workflow__group-toggle:hover{opacity:1}.ngx-workflow__group-node .ngx-workflow__group-toggle-bg{fill:#0000001a}.ngx-workflow__group-node .ngx-workflow__group-toggle-icon{stroke:var(--ngx-workflow-text-secondary);stroke-width:1.5;fill:none}.ngx-workflow__alignment-guide{pointer-events:none}.ngx-workflow__alignment-guide line{stroke:#ec4899;stroke-width:1;stroke-dasharray:4 4;opacity:.8}.ngx-workflow__resize-handle{fill:var(--ngx-workflow-surface);stroke:var(--ngx-workflow-primary);stroke-width:1.5;width:8px;height:8px;transition:transform .2s ease;transform-box:fill-box;transform-origin:center}.ngx-workflow__resize-handle:hover{fill:var(--ngx-workflow-primary);transform:scale(1.2)}.ngx-workflow__resize-handle[data-handle=nw]{cursor:nw-resize}.ngx-workflow__resize-handle[data-handle=ne]{cursor:ne-resize}.ngx-workflow__resize-handle[data-handle=sw]{cursor:sw-resize}.ngx-workflow__resize-handle[data-handle=se]{cursor:se-resize}\n"] }]
|
|
2732
|
+
}], ctorParameters: () => [{ type: i0.ElementRef }, { type: i0.Renderer2 }, { type: i0.NgZone }, { type: i0.ChangeDetectorRef }, { type: DiagramStateService }, { type: undefined, decorators: [{
|
|
2733
|
+
type: Optional
|
|
2734
|
+
}, {
|
|
2735
|
+
type: Inject,
|
|
2736
|
+
args: [NGX_WORKFLOW_NODE_TYPES]
|
|
2737
|
+
}] }], propDecorators: { svgRef: [{
|
|
2738
|
+
type: ViewChild,
|
|
2739
|
+
args: ['svg', { static: true }]
|
|
2740
|
+
}], initialNodes: [{
|
|
2741
|
+
type: Input
|
|
2742
|
+
}], initialEdges: [{
|
|
2743
|
+
type: Input
|
|
2744
|
+
}], initialViewport: [{
|
|
2745
|
+
type: Input
|
|
2746
|
+
}], showZoomControls: [{
|
|
2747
|
+
type: Input
|
|
2748
|
+
}], showMinimap: [{
|
|
2749
|
+
type: Input
|
|
2750
|
+
}], showBackground: [{
|
|
2751
|
+
type: Input
|
|
2752
|
+
}], backgroundVariant: [{
|
|
2753
|
+
type: Input
|
|
2754
|
+
}], backgroundGap: [{
|
|
2755
|
+
type: Input
|
|
2756
|
+
}], backgroundSize: [{
|
|
2757
|
+
type: Input
|
|
2758
|
+
}], backgroundColor: [{
|
|
2759
|
+
type: Input
|
|
2760
|
+
}], backgroundBgColor: [{
|
|
2761
|
+
type: Input
|
|
2762
|
+
}], nodeClick: [{
|
|
2763
|
+
type: Output
|
|
2764
|
+
}], edgeClick: [{
|
|
2765
|
+
type: Output
|
|
2766
|
+
}], connect: [{
|
|
2767
|
+
type: Output
|
|
2768
|
+
}], nodesChange: [{
|
|
2769
|
+
type: Output
|
|
2770
|
+
}], edgesChange: [{
|
|
2771
|
+
type: Output
|
|
2772
|
+
}], nodeDoubleClick: [{
|
|
2773
|
+
type: Output
|
|
2774
|
+
}], connectionValidator: [{
|
|
2775
|
+
type: Input
|
|
2776
|
+
}], nodesResizable: [{
|
|
2777
|
+
type: Input
|
|
2778
|
+
}], onDeleteKeyPress: [{
|
|
2779
|
+
type: HostListener,
|
|
2780
|
+
args: ['window:keydown.delete', ['$event']]
|
|
2781
|
+
}], onUndoKeyPress: [{
|
|
2782
|
+
type: HostListener,
|
|
2783
|
+
args: ['window:keydown.control.z', ['$event']]
|
|
2784
|
+
}, {
|
|
2785
|
+
type: HostListener,
|
|
2786
|
+
args: ['window:keydown.meta.z', ['$event']]
|
|
2787
|
+
}], onRedoKeyPress: [{
|
|
2788
|
+
type: HostListener,
|
|
2789
|
+
args: ['window:keydown.control.shift.z', ['$event']]
|
|
2790
|
+
}, {
|
|
2791
|
+
type: HostListener,
|
|
2792
|
+
args: ['window:keydown.meta.shift.z', ['$event']]
|
|
2793
|
+
}], onCopyKeyPress: [{
|
|
2794
|
+
type: HostListener,
|
|
2795
|
+
args: ['window:keydown.control.c', ['$event']]
|
|
2796
|
+
}, {
|
|
2797
|
+
type: HostListener,
|
|
2798
|
+
args: ['window:keydown.meta.c', ['$event']]
|
|
2799
|
+
}], onPasteKeyPress: [{
|
|
2800
|
+
type: HostListener,
|
|
2801
|
+
args: ['window:keydown.control.v', ['$event']]
|
|
2802
|
+
}, {
|
|
2803
|
+
type: HostListener,
|
|
2804
|
+
args: ['window:keydown.meta.v', ['$event']]
|
|
2805
|
+
}], onCutKeyPress: [{
|
|
2806
|
+
type: HostListener,
|
|
2807
|
+
args: ['window:keydown.control.x', ['$event']]
|
|
2808
|
+
}, {
|
|
2809
|
+
type: HostListener,
|
|
2810
|
+
args: ['window:keydown.meta.x', ['$event']]
|
|
2811
|
+
}], onDuplicateKeyPress: [{
|
|
2812
|
+
type: HostListener,
|
|
2813
|
+
args: ['window:keydown.control.d', ['$event']]
|
|
2814
|
+
}, {
|
|
2815
|
+
type: HostListener,
|
|
2816
|
+
args: ['window:keydown.meta.d', ['$event']]
|
|
2817
|
+
}], onGroupKeyPress: [{
|
|
2818
|
+
type: HostListener,
|
|
2819
|
+
args: ['window:keydown.control.g', ['$event']]
|
|
2820
|
+
}, {
|
|
2821
|
+
type: HostListener,
|
|
2822
|
+
args: ['window:keydown.meta.g', ['$event']]
|
|
2823
|
+
}], onUngroupKeyPress: [{
|
|
2824
|
+
type: HostListener,
|
|
2825
|
+
args: ['window:keydown.control.shift.g', ['$event']]
|
|
2826
|
+
}, {
|
|
2827
|
+
type: HostListener,
|
|
2828
|
+
args: ['window:keydown.meta.shift.g', ['$event']]
|
|
2829
|
+
}], onKeyDown: [{
|
|
2830
|
+
type: HostListener,
|
|
2831
|
+
args: ['window:keydown', ['$event']]
|
|
2832
|
+
}] } });
|
|
2833
|
+
|
|
2834
|
+
class RoundedRectNodeComponent {
|
|
2835
|
+
node;
|
|
2836
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: RoundedRectNodeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2837
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.13", type: RoundedRectNodeComponent, isStandalone: true, selector: "ngx-workflow-rounded-rect-node", inputs: { node: "node" }, ngImport: i0, template: `
|
|
2838
|
+
<svg:g class="ngx-workflow__custom-node ngx-workflow__rounded-rect-node">
|
|
2839
|
+
<rect
|
|
2840
|
+
[attr.x]="0"
|
|
2841
|
+
[attr.y]="0"
|
|
2842
|
+
[attr.width]="node.width || 170"
|
|
2843
|
+
[attr.height]="node.height || 60"
|
|
2844
|
+
rx="10"
|
|
2845
|
+
ry="10"
|
|
2846
|
+
fill="#a7f3d0"
|
|
2847
|
+
stroke="#065f46"
|
|
2848
|
+
stroke-width="1.5"
|
|
2849
|
+
></rect>
|
|
2850
|
+
<text
|
|
2851
|
+
[attr.x]="(node.width || 170) / 2"
|
|
2852
|
+
[attr.y]="(node.height || 60) / 2"
|
|
2853
|
+
text-anchor="middle"
|
|
2854
|
+
alignment-baseline="middle"
|
|
2855
|
+
fill="#065f46"
|
|
2856
|
+
font-size="14px"
|
|
2857
|
+
font-family="sans-serif"
|
|
2858
|
+
>
|
|
2859
|
+
{{ node.data?.label || 'Custom Node' }}
|
|
2860
|
+
</text>
|
|
2861
|
+
</svg:g>
|
|
2862
|
+
`, isInline: true, styles: [""], dependencies: [{ kind: "ngmodule", type: CommonModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2863
|
+
}
|
|
2864
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: RoundedRectNodeComponent, decorators: [{
|
|
2865
|
+
type: Component,
|
|
2866
|
+
args: [{ selector: 'ngx-workflow-rounded-rect-node', template: `
|
|
2867
|
+
<svg:g class="ngx-workflow__custom-node ngx-workflow__rounded-rect-node">
|
|
2868
|
+
<rect
|
|
2869
|
+
[attr.x]="0"
|
|
2870
|
+
[attr.y]="0"
|
|
2871
|
+
[attr.width]="node.width || 170"
|
|
2872
|
+
[attr.height]="node.height || 60"
|
|
2873
|
+
rx="10"
|
|
2874
|
+
ry="10"
|
|
2875
|
+
fill="#a7f3d0"
|
|
2876
|
+
stroke="#065f46"
|
|
2877
|
+
stroke-width="1.5"
|
|
2878
|
+
></rect>
|
|
2879
|
+
<text
|
|
2880
|
+
[attr.x]="(node.width || 170) / 2"
|
|
2881
|
+
[attr.y]="(node.height || 60) / 2"
|
|
2882
|
+
text-anchor="middle"
|
|
2883
|
+
alignment-baseline="middle"
|
|
2884
|
+
fill="#065f46"
|
|
2885
|
+
font-size="14px"
|
|
2886
|
+
font-family="sans-serif"
|
|
2887
|
+
>
|
|
2888
|
+
{{ node.data?.label || 'Custom Node' }}
|
|
2889
|
+
</text>
|
|
2890
|
+
</svg:g>
|
|
2891
|
+
`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [CommonModule] }]
|
|
2892
|
+
}], propDecorators: { node: [{
|
|
2893
|
+
type: Input
|
|
2894
|
+
}] } });
|
|
2895
|
+
|
|
2896
|
+
class LayoutService {
|
|
2897
|
+
elk;
|
|
2898
|
+
constructor() {
|
|
2899
|
+
this.elk = new ELK();
|
|
2900
|
+
}
|
|
2901
|
+
// --- ELK Layout ---
|
|
2902
|
+
async applyElkLayout(nodes, edges, options) {
|
|
2903
|
+
const elkGraph = {
|
|
2904
|
+
id: 'root',
|
|
2905
|
+
layoutOptions: {
|
|
2906
|
+
'elk.algorithm': 'layered',
|
|
2907
|
+
'elk.direction': 'DOWN',
|
|
2908
|
+
'elk.spacing.nodeNode': '75',
|
|
2909
|
+
'elk.layered.nodePlacement.strategy': 'BRANDES_KOLLER',
|
|
2910
|
+
...options
|
|
2911
|
+
},
|
|
2912
|
+
children: nodes.map(node => ({
|
|
2913
|
+
id: node.id,
|
|
2914
|
+
width: node.width || 170,
|
|
2915
|
+
height: node.height || 60,
|
|
2916
|
+
})),
|
|
2917
|
+
edges: edges.map(edge => ({
|
|
2918
|
+
id: edge.id,
|
|
2919
|
+
sources: [edge.source],
|
|
2920
|
+
targets: [edge.target],
|
|
2921
|
+
})),
|
|
2922
|
+
};
|
|
2923
|
+
try {
|
|
2924
|
+
const result = await this.elk.layout(elkGraph);
|
|
2925
|
+
const laidOutNodes = nodes.map(node => {
|
|
2926
|
+
const elkNode = result.children?.find((n) => n.id === node.id);
|
|
2927
|
+
if (elkNode && elkNode.x !== undefined && elkNode.y !== undefined) {
|
|
2928
|
+
return {
|
|
2929
|
+
...node,
|
|
2930
|
+
position: {
|
|
2931
|
+
x: elkNode.x,
|
|
2932
|
+
y: elkNode.y,
|
|
2933
|
+
},
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
2936
|
+
return node; // Return original if not found or no position
|
|
2937
|
+
});
|
|
2938
|
+
return laidOutNodes;
|
|
2939
|
+
}
|
|
2940
|
+
catch (error) {
|
|
2941
|
+
console.error('ELK layout failed:', error);
|
|
2942
|
+
return nodes; // Return original nodes on error
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: LayoutService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
2946
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: LayoutService, providedIn: 'root' });
|
|
2947
|
+
}
|
|
2948
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: LayoutService, decorators: [{
|
|
2949
|
+
type: Injectable,
|
|
2950
|
+
args: [{
|
|
2951
|
+
providedIn: 'root',
|
|
2952
|
+
}]
|
|
2953
|
+
}], ctorParameters: () => [] });
|
|
2954
|
+
|
|
2955
|
+
class NgxWorkflowModule {
|
|
2956
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: NgxWorkflowModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
|
|
2957
|
+
static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.13", ngImport: i0, type: NgxWorkflowModule, imports: [CommonModule,
|
|
2958
|
+
DiagramComponent,
|
|
2959
|
+
RoundedRectNodeComponent], exports: [DiagramComponent,
|
|
2960
|
+
RoundedRectNodeComponent] });
|
|
2961
|
+
static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: NgxWorkflowModule, providers: [
|
|
2962
|
+
DiagramStateService,
|
|
2963
|
+
LayoutService,
|
|
2964
|
+
UndoRedoService,
|
|
2965
|
+
{
|
|
2966
|
+
provide: NGX_WORKFLOW_NODE_TYPES,
|
|
2967
|
+
useValue: {
|
|
2968
|
+
'rounded-rect': RoundedRectNodeComponent,
|
|
2969
|
+
// Add other custom node types here
|
|
2970
|
+
},
|
|
2971
|
+
},
|
|
2972
|
+
], imports: [CommonModule,
|
|
2973
|
+
DiagramComponent,
|
|
2974
|
+
RoundedRectNodeComponent] });
|
|
2975
|
+
}
|
|
2976
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: NgxWorkflowModule, decorators: [{
|
|
2977
|
+
type: NgModule,
|
|
2978
|
+
args: [{
|
|
2979
|
+
imports: [
|
|
2980
|
+
CommonModule,
|
|
2981
|
+
DiagramComponent,
|
|
2982
|
+
RoundedRectNodeComponent, // Import custom node component if it's standalone
|
|
2983
|
+
],
|
|
2984
|
+
declarations: [
|
|
2985
|
+
// Standalone components are imported, not declared.
|
|
2986
|
+
// If a non-standalone component was needed, it would go here.
|
|
2987
|
+
],
|
|
2988
|
+
providers: [
|
|
2989
|
+
DiagramStateService,
|
|
2990
|
+
LayoutService,
|
|
2991
|
+
UndoRedoService,
|
|
2992
|
+
{
|
|
2993
|
+
provide: NGX_WORKFLOW_NODE_TYPES,
|
|
2994
|
+
useValue: {
|
|
2995
|
+
'rounded-rect': RoundedRectNodeComponent,
|
|
2996
|
+
// Add other custom node types here
|
|
2997
|
+
},
|
|
2998
|
+
},
|
|
2999
|
+
],
|
|
3000
|
+
exports: [
|
|
3001
|
+
DiagramComponent,
|
|
3002
|
+
RoundedRectNodeComponent,
|
|
3003
|
+
],
|
|
3004
|
+
}]
|
|
3005
|
+
}] });
|
|
3006
|
+
|
|
3007
|
+
/*
|
|
3008
|
+
* Public API Surface of ngx-workflow
|
|
3009
|
+
*/
|
|
3010
|
+
|
|
3011
|
+
/**
|
|
3012
|
+
* Generated bundle index. Do not edit.
|
|
3013
|
+
*/
|
|
3014
|
+
|
|
3015
|
+
export { DiagramComponent, DiagramStateService, LayoutService, NGX_WORKFLOW_EDGE_TYPES, NGX_WORKFLOW_NODE_TYPES, NgxWorkflowModule, RoundedRectNodeComponent, UndoRedoService, getBezierPath, getPolylineMidpoint, getSelfLoopPath, getSmartEdgePath, getStepPath, getStraightPath };
|
|
3016
|
+
//# sourceMappingURL=ngx-workflow.mjs.map
|