@utisha/graph-editor 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/utisha-graph-editor.mjs +369 -13
- package/fesm2022/utisha-graph-editor.mjs.map +1 -1
- package/lib/graph-editor.component.d.ts +27 -3
- package/lib/graph-editor.config.d.ts +151 -1
- package/lib/template.directives.d.ts +116 -0
- package/lib/theme.resolver.d.ts +78 -0
- package/package.json +1 -1
- package/public-api.d.ts +4 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { signal, Injectable, EventEmitter, viewChild,
|
|
2
|
+
import { signal, Injectable, inject, TemplateRef, Directive, EventEmitter, viewChild, computed, contentChild, ElementRef, Output, Input, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { NgTemplateOutlet, NgComponentOutlet } from '@angular/common';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Service for managing undo/redo history of graph state.
|
|
@@ -122,6 +123,226 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImpo
|
|
|
122
123
|
type: Injectable
|
|
123
124
|
}] });
|
|
124
125
|
|
|
126
|
+
// ============================================================
|
|
127
|
+
// Template directives
|
|
128
|
+
// ============================================================
|
|
129
|
+
/**
|
|
130
|
+
* Marks an `<ng-template>` as a custom HTML node renderer.
|
|
131
|
+
* Content is rendered inside an `<svg:foreignObject>` — write standard HTML/CSS.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```html
|
|
135
|
+
* <graph-editor [config]="config" [graph]="graph">
|
|
136
|
+
* <ng-template geNodeHtml let-ctx>
|
|
137
|
+
* <div class="my-node" [class.selected]="ctx.selected">
|
|
138
|
+
* <div class="header">{{ ctx.type.label }}</div>
|
|
139
|
+
* <div class="body">{{ ctx.node.data.name }}</div>
|
|
140
|
+
* </div>
|
|
141
|
+
* </ng-template>
|
|
142
|
+
* </graph-editor>
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
class NodeHtmlTemplateDirective {
|
|
146
|
+
templateRef = inject(TemplateRef);
|
|
147
|
+
static ngTemplateContextGuard(_dir, _ctx) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: NodeHtmlTemplateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
151
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.19", type: NodeHtmlTemplateDirective, isStandalone: true, selector: "ng-template[geNodeHtml]", ngImport: i0 });
|
|
152
|
+
}
|
|
153
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: NodeHtmlTemplateDirective, decorators: [{
|
|
154
|
+
type: Directive,
|
|
155
|
+
args: [{
|
|
156
|
+
standalone: true,
|
|
157
|
+
selector: 'ng-template[geNodeHtml]',
|
|
158
|
+
}]
|
|
159
|
+
}] });
|
|
160
|
+
/**
|
|
161
|
+
* Marks an `<ng-template>` as a custom SVG node renderer.
|
|
162
|
+
* Content is rendered inside an `<svg:g>` — use `svg:` prefixed elements.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```html
|
|
166
|
+
* <graph-editor [config]="config" [graph]="graph">
|
|
167
|
+
* <ng-template geNodeSvg let-ctx>
|
|
168
|
+
* <svg:rect [attr.width]="ctx.width" [attr.height]="ctx.height"
|
|
169
|
+
* rx="8" fill="white" stroke="#ccc" />
|
|
170
|
+
* <svg:text x="10" y="24">{{ ctx.node.data.name }}</svg:text>
|
|
171
|
+
* </ng-template>
|
|
172
|
+
* </graph-editor>
|
|
173
|
+
* ```
|
|
174
|
+
*
|
|
175
|
+
* **Important:** All SVG elements inside the template MUST use the `svg:` prefix
|
|
176
|
+
* (e.g. `<svg:rect>`, `<svg:text>`, `<svg:g>`).
|
|
177
|
+
*/
|
|
178
|
+
class NodeSvgTemplateDirective {
|
|
179
|
+
templateRef = inject(TemplateRef);
|
|
180
|
+
static ngTemplateContextGuard(_dir, _ctx) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: NodeSvgTemplateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
184
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.19", type: NodeSvgTemplateDirective, isStandalone: true, selector: "ng-template[geNodeSvg]", ngImport: i0 });
|
|
185
|
+
}
|
|
186
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: NodeSvgTemplateDirective, decorators: [{
|
|
187
|
+
type: Directive,
|
|
188
|
+
args: [{
|
|
189
|
+
standalone: true,
|
|
190
|
+
selector: 'ng-template[geNodeSvg]',
|
|
191
|
+
}]
|
|
192
|
+
}] });
|
|
193
|
+
/**
|
|
194
|
+
* Marks an `<ng-template>` as a custom edge renderer.
|
|
195
|
+
* Content is rendered inside an `<svg:g>` — use `svg:` prefixed elements.
|
|
196
|
+
* The library still handles the invisible hit-area and endpoint circles.
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```html
|
|
200
|
+
* <graph-editor [config]="config" [graph]="graph">
|
|
201
|
+
* <ng-template geEdge let-ctx>
|
|
202
|
+
* <svg:path [attr.d]="ctx.path"
|
|
203
|
+
* [attr.stroke]="ctx.selected ? 'blue' : 'gray'"
|
|
204
|
+
* stroke-width="2" fill="none" />
|
|
205
|
+
* </ng-template>
|
|
206
|
+
* </graph-editor>
|
|
207
|
+
* ```
|
|
208
|
+
*/
|
|
209
|
+
class EdgeTemplateDirective {
|
|
210
|
+
templateRef = inject(TemplateRef);
|
|
211
|
+
static ngTemplateContextGuard(_dir, _ctx) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: EdgeTemplateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
215
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.19", type: EdgeTemplateDirective, isStandalone: true, selector: "ng-template[geEdge]", ngImport: i0 });
|
|
216
|
+
}
|
|
217
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: EdgeTemplateDirective, decorators: [{
|
|
218
|
+
type: Directive,
|
|
219
|
+
args: [{
|
|
220
|
+
standalone: true,
|
|
221
|
+
selector: 'ng-template[geEdge]',
|
|
222
|
+
}]
|
|
223
|
+
}] });
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Resolves a partial ThemeConfig into a complete ResolvedTheme with all defaults filled.
|
|
227
|
+
*/
|
|
228
|
+
function resolveTheme(theme) {
|
|
229
|
+
const selectionColor = theme?.selection?.color ?? '#3b82f6';
|
|
230
|
+
return {
|
|
231
|
+
shadows: theme?.shadows !== false,
|
|
232
|
+
canvas: {
|
|
233
|
+
background: theme?.canvas?.background ?? '#f8f9fa',
|
|
234
|
+
gridType: theme?.canvas?.gridType ?? 'line',
|
|
235
|
+
gridColor: theme?.canvas?.gridColor ?? '#e0e0e0',
|
|
236
|
+
},
|
|
237
|
+
node: {
|
|
238
|
+
background: theme?.node?.background ?? 'white',
|
|
239
|
+
borderColor: theme?.node?.borderColor ?? '#e2e8f0',
|
|
240
|
+
borderWidth: theme?.node?.borderWidth ?? 1.5,
|
|
241
|
+
borderRadius: theme?.node?.borderRadius ?? 12,
|
|
242
|
+
selectedBorderColor: theme?.node?.selectedBorderColor ?? selectionColor,
|
|
243
|
+
selectedBorderWidth: theme?.node?.selectedBorderWidth ?? 2.5,
|
|
244
|
+
shadowColor: theme?.node?.shadowColor ?? 'rgba(0,0,0,0.08)',
|
|
245
|
+
labelColor: theme?.node?.labelColor ?? '#1e293b',
|
|
246
|
+
labelFont: theme?.node?.labelFont ?? 'system-ui, -apple-system, sans-serif',
|
|
247
|
+
typeStyles: theme?.node?.typeStyles ?? {},
|
|
248
|
+
},
|
|
249
|
+
edge: {
|
|
250
|
+
stroke: theme?.edge?.stroke ?? '#94a3b8',
|
|
251
|
+
strokeWidth: theme?.edge?.strokeWidth ?? 2,
|
|
252
|
+
selectedStroke: theme?.edge?.selectedStroke ?? selectionColor,
|
|
253
|
+
selectedStrokeWidth: theme?.edge?.selectedStrokeWidth ?? 2.5,
|
|
254
|
+
markerColor: theme?.edge?.markerColor ?? '#94a3b8',
|
|
255
|
+
selectedMarkerColor: theme?.edge?.selectedMarkerColor ?? selectionColor,
|
|
256
|
+
pathType: theme?.edge?.pathType ?? 'straight',
|
|
257
|
+
},
|
|
258
|
+
port: {
|
|
259
|
+
fill: theme?.port?.fill ?? '#94a3b8',
|
|
260
|
+
stroke: theme?.port?.stroke ?? 'white',
|
|
261
|
+
strokeWidth: theme?.port?.strokeWidth ?? 2,
|
|
262
|
+
radius: theme?.port?.radius ?? 6,
|
|
263
|
+
hoverFill: theme?.port?.hoverFill ?? '#2563eb',
|
|
264
|
+
hoverRadius: theme?.port?.hoverRadius ?? 8,
|
|
265
|
+
},
|
|
266
|
+
selection: {
|
|
267
|
+
color: selectionColor,
|
|
268
|
+
boxFill: theme?.selection?.boxFill ?? `rgba(59, 130, 246, 0.1)`,
|
|
269
|
+
boxStroke: theme?.selection?.boxStroke ?? selectionColor,
|
|
270
|
+
},
|
|
271
|
+
font: {
|
|
272
|
+
family: theme?.font?.family ?? 'system-ui, -apple-system, sans-serif',
|
|
273
|
+
monoFamily: theme?.font?.monoFamily ?? 'monospace',
|
|
274
|
+
},
|
|
275
|
+
toolbar: resolveToolbar(theme?.toolbar, selectionColor),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function resolveToolbar(toolbar, selectionColor) {
|
|
279
|
+
return {
|
|
280
|
+
background: toolbar?.background ?? 'rgba(255, 255, 255, 0.95)',
|
|
281
|
+
borderRadius: toolbar?.borderRadius ?? 8,
|
|
282
|
+
shadow: toolbar?.shadow ?? '0 2px 8px rgba(0, 0, 0, 0.1)',
|
|
283
|
+
buttonBackground: toolbar?.buttonBackground ?? '#ffffff',
|
|
284
|
+
buttonBorderColor: toolbar?.buttonBorderColor ?? '#e5e7eb',
|
|
285
|
+
buttonTextColor: toolbar?.buttonTextColor ?? '#4b5563',
|
|
286
|
+
buttonHoverBackground: toolbar?.buttonHoverBackground ?? '#f9fafb',
|
|
287
|
+
buttonHoverAccent: toolbar?.buttonHoverAccent ?? selectionColor,
|
|
288
|
+
buttonActiveBackground: toolbar?.buttonActiveBackground ?? selectionColor,
|
|
289
|
+
buttonActiveTextColor: toolbar?.buttonActiveTextColor ?? '#ffffff',
|
|
290
|
+
dividerColor: toolbar?.dividerColor ?? '#e5e7eb',
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Applies resolved theme values as CSS custom properties on a host element.
|
|
295
|
+
* This enables consumer templates to use `var(--ge-*)` in their CSS.
|
|
296
|
+
*/
|
|
297
|
+
function applyThemeCssProperties(host, t, userVars) {
|
|
298
|
+
const style = host.style;
|
|
299
|
+
// Canvas
|
|
300
|
+
style.setProperty('--ge-canvas-bg', t.canvas.background);
|
|
301
|
+
style.setProperty('--ge-grid-color', t.canvas.gridColor);
|
|
302
|
+
// Node
|
|
303
|
+
style.setProperty('--ge-node-bg', t.node.background);
|
|
304
|
+
style.setProperty('--ge-node-border', t.node.borderColor);
|
|
305
|
+
style.setProperty('--ge-node-border-width', `${t.node.borderWidth}px`);
|
|
306
|
+
style.setProperty('--ge-node-border-radius', `${t.node.borderRadius}px`);
|
|
307
|
+
style.setProperty('--ge-node-selected-border', t.node.selectedBorderColor);
|
|
308
|
+
style.setProperty('--ge-node-selected-border-width', `${t.node.selectedBorderWidth}px`);
|
|
309
|
+
style.setProperty('--ge-node-shadow', t.node.shadowColor);
|
|
310
|
+
style.setProperty('--ge-node-label-color', t.node.labelColor);
|
|
311
|
+
style.setProperty('--ge-node-label-font', t.node.labelFont);
|
|
312
|
+
// Edge
|
|
313
|
+
style.setProperty('--ge-edge-stroke', t.edge.stroke);
|
|
314
|
+
style.setProperty('--ge-edge-stroke-width', `${t.edge.strokeWidth}px`);
|
|
315
|
+
style.setProperty('--ge-edge-selected-stroke', t.edge.selectedStroke);
|
|
316
|
+
// Port
|
|
317
|
+
style.setProperty('--ge-port-fill', t.port.fill);
|
|
318
|
+
style.setProperty('--ge-port-hover-fill', t.port.hoverFill);
|
|
319
|
+
// Selection
|
|
320
|
+
style.setProperty('--ge-selection-color', t.selection.color);
|
|
321
|
+
// Font
|
|
322
|
+
style.setProperty('--ge-font-family', t.font.family);
|
|
323
|
+
style.setProperty('--ge-font-mono', t.font.monoFamily);
|
|
324
|
+
// Toolbar
|
|
325
|
+
style.setProperty('--ge-toolbar-bg', t.toolbar.background);
|
|
326
|
+
style.setProperty('--ge-toolbar-radius', `${t.toolbar.borderRadius}px`);
|
|
327
|
+
style.setProperty('--ge-toolbar-shadow', t.toolbar.shadow);
|
|
328
|
+
style.setProperty('--ge-toolbar-btn-bg', t.toolbar.buttonBackground);
|
|
329
|
+
style.setProperty('--ge-toolbar-btn-border', t.toolbar.buttonBorderColor);
|
|
330
|
+
style.setProperty('--ge-toolbar-btn-color', t.toolbar.buttonTextColor);
|
|
331
|
+
style.setProperty('--ge-toolbar-btn-hover-bg', t.toolbar.buttonHoverBackground);
|
|
332
|
+
style.setProperty('--ge-toolbar-btn-hover-accent', t.toolbar.buttonHoverAccent);
|
|
333
|
+
style.setProperty('--ge-toolbar-btn-active-bg', t.toolbar.buttonActiveBackground);
|
|
334
|
+
style.setProperty('--ge-toolbar-btn-active-color', t.toolbar.buttonActiveTextColor);
|
|
335
|
+
style.setProperty('--ge-toolbar-divider', t.toolbar.dividerColor);
|
|
336
|
+
// Backward compat: --graph-editor-canvas-bg
|
|
337
|
+
style.setProperty('--graph-editor-canvas-bg', t.canvas.background);
|
|
338
|
+
// User-provided custom variables
|
|
339
|
+
if (userVars) {
|
|
340
|
+
for (const [key, value] of Object.entries(userVars)) {
|
|
341
|
+
style.setProperty(key.startsWith('--') ? key : `--${key}`, value);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
125
346
|
/**
|
|
126
347
|
* Main graph editor component.
|
|
127
348
|
*
|
|
@@ -208,8 +429,14 @@ class GraphEditorComponent {
|
|
|
208
429
|
height: viewportHeight * 2
|
|
209
430
|
};
|
|
210
431
|
});
|
|
211
|
-
//
|
|
212
|
-
|
|
432
|
+
// Resolved theme (filled defaults)
|
|
433
|
+
resolvedTheme;
|
|
434
|
+
// Shadow configuration (derived from resolved theme)
|
|
435
|
+
shadowsEnabled = computed(() => this.resolvedTheme?.shadows ?? true);
|
|
436
|
+
// Template queries (signal-based contentChild)
|
|
437
|
+
nodeHtmlTemplate = contentChild(NodeHtmlTemplateDirective);
|
|
438
|
+
nodeSvgTemplate = contentChild(NodeSvgTemplateDirective);
|
|
439
|
+
edgeTemplate = contentChild(EdgeTemplateDirective);
|
|
213
440
|
// Selected edge info for direction selector positioning
|
|
214
441
|
selectedEdgeMidpoint = computed(() => {
|
|
215
442
|
const sel = this.selection();
|
|
@@ -228,14 +455,25 @@ class GraphEditorComponent {
|
|
|
228
455
|
y: midY * this.scale() + this.panY()
|
|
229
456
|
};
|
|
230
457
|
});
|
|
458
|
+
hostEl = inject(ElementRef);
|
|
231
459
|
constructor() { }
|
|
232
460
|
ngOnChanges(changes) {
|
|
233
461
|
// Sync graph input to internal signal
|
|
234
462
|
if (changes['graph'] && changes['graph'].currentValue) {
|
|
235
463
|
this.internalGraph.set(structuredClone(changes['graph'].currentValue));
|
|
236
464
|
}
|
|
465
|
+
// Re-resolve theme when config changes
|
|
466
|
+
if (changes['config']) {
|
|
467
|
+
this.resolvedTheme = resolveTheme(this.config.theme);
|
|
468
|
+
applyThemeCssProperties(this.hostEl.nativeElement, this.resolvedTheme, this.config.theme?.variables);
|
|
469
|
+
}
|
|
237
470
|
}
|
|
238
471
|
ngOnInit() {
|
|
472
|
+
// Resolve theme (first time, in case ngOnChanges didn't fire for config)
|
|
473
|
+
if (!this.resolvedTheme) {
|
|
474
|
+
this.resolvedTheme = resolveTheme(this.config.theme);
|
|
475
|
+
applyThemeCssProperties(this.hostEl.nativeElement, this.resolvedTheme, this.config.theme?.variables);
|
|
476
|
+
}
|
|
239
477
|
// Initialize with current graph value
|
|
240
478
|
if (this.graph) {
|
|
241
479
|
this.internalGraph.set(structuredClone(this.graph));
|
|
@@ -652,6 +890,20 @@ class GraphEditorComponent {
|
|
|
652
890
|
const max = zoomConfig?.max ?? 2.0;
|
|
653
891
|
this.scale.set(Math.max(min, Math.min(max, level)));
|
|
654
892
|
}
|
|
893
|
+
zoomIn() {
|
|
894
|
+
const zoomConfig = this.config.canvas?.zoom;
|
|
895
|
+
const step = zoomConfig?.step ?? 0.1;
|
|
896
|
+
const max = zoomConfig?.max ?? 2.0;
|
|
897
|
+
const newScale = Math.min(max, this.scale() + step);
|
|
898
|
+
this.scale.set(newScale);
|
|
899
|
+
}
|
|
900
|
+
zoomOut() {
|
|
901
|
+
const zoomConfig = this.config.canvas?.zoom;
|
|
902
|
+
const step = zoomConfig?.step ?? 0.1;
|
|
903
|
+
const min = zoomConfig?.min ?? 0.25;
|
|
904
|
+
const newScale = Math.max(min, this.scale() - step);
|
|
905
|
+
this.scale.set(newScale);
|
|
906
|
+
}
|
|
655
907
|
getSelection() {
|
|
656
908
|
return this.selection();
|
|
657
909
|
}
|
|
@@ -1238,16 +1490,57 @@ class GraphEditorComponent {
|
|
|
1238
1490
|
const targetNode = this.internalGraph().nodes.find(n => n.id === edge.target);
|
|
1239
1491
|
if (!sourceNode || !targetNode)
|
|
1240
1492
|
return '';
|
|
1241
|
-
// Get port positions from edge or calculate closest
|
|
1242
1493
|
const sourcePort = edge.sourcePort || this.findClosestPortForEdge(sourceNode, targetNode, 'source');
|
|
1243
1494
|
const targetPort = edge.targetPort || this.findClosestPortForEdge(targetNode, sourceNode, 'target');
|
|
1244
|
-
const
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
|
|
1495
|
+
const s = this.getPortWorldPosition(sourceNode, sourcePort);
|
|
1496
|
+
const t = this.getPortWorldPosition(targetNode, targetPort);
|
|
1497
|
+
const pathType = this.resolvedTheme.edge.pathType;
|
|
1498
|
+
if (pathType === 'bezier') {
|
|
1499
|
+
const offset = Math.max(40, Math.abs(t.x - s.x) * 0.3, Math.abs(t.y - s.y) * 0.3);
|
|
1500
|
+
const sc = this.getPortControlOffset(sourcePort, offset);
|
|
1501
|
+
const tc = this.getPortControlOffset(targetPort, offset);
|
|
1502
|
+
// Blend a small cross-axis component so the bezier tangent at endpoints
|
|
1503
|
+
// isn't purely axis-aligned — this makes arrowheads follow the curve naturally.
|
|
1504
|
+
const crossBias = 0.15;
|
|
1505
|
+
const dx = t.x - s.x;
|
|
1506
|
+
const dy = t.y - s.y;
|
|
1507
|
+
const sc1x = s.x + sc.dx + (sc.dx !== 0 ? 0 : dx * crossBias);
|
|
1508
|
+
const sc1y = s.y + sc.dy + (sc.dy !== 0 ? 0 : dy * crossBias);
|
|
1509
|
+
const tc1x = t.x + tc.dx + (tc.dx !== 0 ? 0 : dx * -crossBias);
|
|
1510
|
+
const tc1y = t.y + tc.dy + (tc.dy !== 0 ? 0 : dy * -crossBias);
|
|
1511
|
+
return `M ${s.x},${s.y} C ${sc1x},${sc1y} ${tc1x},${tc1y} ${t.x},${t.y}`;
|
|
1512
|
+
}
|
|
1513
|
+
if (pathType === 'step') {
|
|
1514
|
+
const midX = (s.x + t.x) / 2;
|
|
1515
|
+
const midY = (s.y + t.y) / 2;
|
|
1516
|
+
const isSourceVertical = sourcePort === 'top' || sourcePort === 'bottom';
|
|
1517
|
+
const isTargetVertical = targetPort === 'top' || targetPort === 'bottom';
|
|
1518
|
+
if (isSourceVertical && isTargetVertical) {
|
|
1519
|
+
return `M ${s.x},${s.y} L ${s.x},${midY} L ${t.x},${midY} L ${t.x},${t.y}`;
|
|
1520
|
+
}
|
|
1521
|
+
else if (!isSourceVertical && !isTargetVertical) {
|
|
1522
|
+
return `M ${s.x},${s.y} L ${midX},${s.y} L ${midX},${t.y} L ${t.x},${t.y}`;
|
|
1523
|
+
}
|
|
1524
|
+
else if (isSourceVertical) {
|
|
1525
|
+
return `M ${s.x},${s.y} L ${s.x},${t.y} L ${t.x},${t.y}`;
|
|
1526
|
+
}
|
|
1527
|
+
else {
|
|
1528
|
+
return `M ${s.x},${s.y} L ${t.x},${s.y} L ${t.x},${t.y}`;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
return `M ${s.x},${s.y} L ${t.x},${t.y}`;
|
|
1532
|
+
}
|
|
1533
|
+
/** Get the control point offset direction for a port (used by bezier path). */
|
|
1534
|
+
getPortControlOffset(port, offset) {
|
|
1535
|
+
switch (port) {
|
|
1536
|
+
case 'top': return { dx: 0, dy: -offset };
|
|
1537
|
+
case 'bottom': return { dx: 0, dy: offset };
|
|
1538
|
+
case 'left': return { dx: -offset, dy: 0 };
|
|
1539
|
+
case 'right': return { dx: offset, dy: 0 };
|
|
1540
|
+
}
|
|
1248
1541
|
}
|
|
1249
1542
|
getEdgeColor(edge) {
|
|
1250
|
-
return edge.metadata?.style?.stroke || this.
|
|
1543
|
+
return edge.metadata?.style?.stroke || this.resolvedTheme.edge.stroke;
|
|
1251
1544
|
}
|
|
1252
1545
|
getEdgeMarkerEnd(edge) {
|
|
1253
1546
|
const dir = edge.direction || 'forward';
|
|
@@ -1364,6 +1657,29 @@ class GraphEditorComponent {
|
|
|
1364
1657
|
.map(p => p.trim())
|
|
1365
1658
|
.filter(p => p.length > 0);
|
|
1366
1659
|
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Split node types into columns for the palette.
|
|
1662
|
+
* When there are too many node types to fit vertically, creates additional columns.
|
|
1663
|
+
*/
|
|
1664
|
+
getPaletteColumns() {
|
|
1665
|
+
const types = this.config.nodes.types;
|
|
1666
|
+
if (!types || types.length === 0)
|
|
1667
|
+
return [];
|
|
1668
|
+
// Calculate available height for palette
|
|
1669
|
+
// Top toolbar: 60px (12px top + 36px height + 12px gap)
|
|
1670
|
+
// Bottom padding: 12px
|
|
1671
|
+
// Each item: 40px (36px height + 4px gap)
|
|
1672
|
+
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800;
|
|
1673
|
+
const availableHeight = viewportHeight - 72 - 12 - 12; // toolbar + gaps + bottom padding
|
|
1674
|
+
const itemHeight = 40;
|
|
1675
|
+
const maxItemsPerColumn = Math.max(1, Math.floor(availableHeight / itemHeight));
|
|
1676
|
+
// Split into columns
|
|
1677
|
+
const columns = [];
|
|
1678
|
+
for (let i = 0; i < types.length; i += maxItemsPerColumn) {
|
|
1679
|
+
columns.push(types.slice(i, i + maxItemsPerColumn));
|
|
1680
|
+
}
|
|
1681
|
+
return columns;
|
|
1682
|
+
}
|
|
1367
1683
|
/**
|
|
1368
1684
|
* Get the position for the node image (top-left corner of image).
|
|
1369
1685
|
* Uses same positioning logic as icon but accounts for image dimensions.
|
|
@@ -1685,16 +2001,56 @@ class GraphEditorComponent {
|
|
|
1685
2001
|
return dy > 0 ? 'bottom' : 'top';
|
|
1686
2002
|
}
|
|
1687
2003
|
}
|
|
2004
|
+
/** Get the component type for a node (from NodeTypeDefinition.component, if set). */
|
|
2005
|
+
getNodeComponent(node) {
|
|
2006
|
+
const nodeConfig = this.config.nodes.types.find(t => t.type === node.type);
|
|
2007
|
+
return nodeConfig?.component ?? null;
|
|
2008
|
+
}
|
|
2009
|
+
/** Build inputs map for ngComponentOutlet when rendering a node's custom component. */
|
|
2010
|
+
getNodeComponentInputs(node) {
|
|
2011
|
+
const size = this.getNodeSize(node);
|
|
2012
|
+
return {
|
|
2013
|
+
node,
|
|
2014
|
+
selected: this.selection().nodes.includes(node.id),
|
|
2015
|
+
width: size.width,
|
|
2016
|
+
height: size.height,
|
|
2017
|
+
config: this.config,
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
/** Build the template context for custom node templates. */
|
|
2021
|
+
getNodeTemplateContext(node) {
|
|
2022
|
+
const nodeConfig = this.config.nodes.types.find(t => t.type === node.type);
|
|
2023
|
+
const size = this.getNodeSize(node);
|
|
2024
|
+
return {
|
|
2025
|
+
$implicit: {
|
|
2026
|
+
node,
|
|
2027
|
+
type: nodeConfig,
|
|
2028
|
+
selected: this.selection().nodes.includes(node.id),
|
|
2029
|
+
width: size.width,
|
|
2030
|
+
height: size.height,
|
|
2031
|
+
},
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
/** Build the template context for custom edge templates. */
|
|
2035
|
+
getEdgeTemplateContext(edge) {
|
|
2036
|
+
return {
|
|
2037
|
+
$implicit: {
|
|
2038
|
+
edge,
|
|
2039
|
+
path: this.getEdgePath(edge),
|
|
2040
|
+
selected: this.selection().edges.includes(edge.id),
|
|
2041
|
+
},
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
1688
2044
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1689
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.19", type: GraphEditorComponent, isStandalone: true, selector: "graph-editor", inputs: { config: "config", graph: "graph", readonly: "readonly", visualizationMode: "visualizationMode", overlayData: "overlayData" }, outputs: { graphChange: "graphChange", nodeAdded: "nodeAdded", nodeUpdated: "nodeUpdated", nodeRemoved: "nodeRemoved", edgeAdded: "edgeAdded", edgeUpdated: "edgeUpdated", edgeRemoved: "edgeRemoved", selectionChange: "selectionChange", validationChange: "validationChange", nodeClick: "nodeClick", nodeDoubleClick: "nodeDoubleClick", edgeClick: "edgeClick", edgeDoubleClick: "edgeDoubleClick", canvasClick: "canvasClick", contextMenu: "contextMenu" }, host: { attributes: { "tabindex": "0" }, listeners: { "keydown": "onKeyDown($event)" }, styleAttribute: "outline: none;" }, providers: [GraphHistoryService], viewQueries: [{ propertyName: "canvasSvgRef", first: true, predicate: ["canvasSvg"], descendants: true, isSignal: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"graph-editor-container\">\n <!-- Canvas with overlaid palette -->\n <div class=\"graph-canvas-wrapper\">\n <!-- Top-left horizontal palette overlay -->\n @if (config.palette?.enabled !== false) {\n <div class=\"graph-palette-overlay\">\n <!-- Tools -->\n <button\n class=\"palette-item tool-item\"\n [class.active]=\"activeTool() === 'hand'\"\n title=\"Hand tool (move nodes)\"\n (click)=\"switchTool('hand')\"\n >\n <span class=\"icon\">\u270B</span>\n </button>\n <button\n class=\"palette-item tool-item\"\n [class.active]=\"activeTool() === 'line'\"\n title=\"Line tool (draw connections)\"\n (click)=\"switchTool('line')\"\n >\n <span class=\"icon\">\u2215</span>\n </button>\n\n <!-- Divider -->\n <div class=\"palette-divider\"></div>\n\n <!-- Node types -->\n @for (nodeType of config.nodes.types; track nodeType.type) {\n <button\n class=\"palette-item\"\n [attr.data-node-type]=\"nodeType.type\"\n [attr.title]=\"nodeType.label || nodeType.type\"\n (click)=\"addNode(nodeType.type)\"\n >\n @if (nodeType.iconSvg) {\n <svg\n class=\"palette-icon-svg\"\n [attr.viewBox]=\"nodeType.iconSvg.viewBox || '0 0 24 24'\"\n [attr.fill]=\"nodeType.iconSvg.fill || 'none'\"\n [attr.stroke]=\"nodeType.iconSvg.stroke || '#1D6A96'\"\n [attr.stroke-width]=\"nodeType.iconSvg.strokeWidth || 2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n @for (pathData of splitIconPaths(nodeType.iconSvg.path); track $index) {\n <path [attr.d]=\"pathData\"/>\n }\n </svg>\n } @else if (nodeType.defaultData['imageUrl']) {\n <img\n class=\"palette-icon-img\"\n [src]=\"nodeType.defaultData['imageUrl']\"\n [alt]=\"nodeType.label || nodeType.type\"\n />\n } @else {\n <span class=\"icon\">{{ nodeType.icon || '\u25CF' }}</span>\n }\n </button>\n }\n </div>\n }\n\n <svg\n #canvasSvg\n [class.tool-line]=\"activeTool() === 'line'\"\n [attr.width]=\"'100%'\"\n [attr.height]=\"'100%'\"\n (mousedown)=\"onCanvasMouseDown($event)\"\n (mousemove)=\"onCanvasMouseMove($event)\"\n (mouseup)=\"onCanvasMouseUp($event)\"\n (wheel)=\"onWheel($event)\"\n (contextmenu)=\"onContextMenu($event)\"\n >\n <!-- Arrow marker definitions -->\n <defs>\n <marker id=\"arrow-end\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" fill=\"#94a3b8\"/>\n </marker>\n <marker id=\"arrow-end-selected\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" fill=\"#3b82f6\"/>\n </marker>\n <marker id=\"arrow-start\" viewBox=\"0 0 10 10\" refX=\"1\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" fill=\"#94a3b8\"/>\n </marker>\n <marker id=\"arrow-start-selected\" viewBox=\"0 0 10 10\" refX=\"1\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" fill=\"#3b82f6\"/>\n </marker>\n </defs>\n\n <!-- Main transform group (pan + zoom) -->\n <g [attr.transform]=\"transform()\">\n <!-- Grid (if enabled) -->\n <!-- Grid (if enabled) - extended to cover viewport during pan -->\n @if (config.canvas?.grid?.enabled) {\n <defs>\n <pattern\n id=\"grid\"\n [attr.width]=\"config.canvas!.grid!.size\"\n [attr.height]=\"config.canvas!.grid!.size\"\n patternUnits=\"userSpaceOnUse\"\n >\n <path\n [attr.d]=\"'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size\"\n fill=\"none\"\n [attr.stroke]=\"config.canvas!.grid!.color || '#e0e0e0'\"\n stroke-width=\"1\"\n />\n </pattern>\n </defs>\n <!-- Extended grid background covering viewport + pan offset -->\n <rect\n [attr.x]=\"gridBounds().x\"\n [attr.y]=\"gridBounds().y\"\n [attr.width]=\"gridBounds().width\"\n [attr.height]=\"gridBounds().height\"\n fill=\"url(#grid)\"\n />\n }\n\n <!-- Layer 0.5: Preview line for line tool (rubber-band) -->\n @if (previewLine()) {\n <line\n class=\"preview-line\"\n [attr.x1]=\"previewLine()!.source.x\"\n [attr.y1]=\"previewLine()!.source.y\"\n [attr.x2]=\"previewLine()!.target.x\"\n [attr.y2]=\"previewLine()!.target.y\"\n stroke=\"#3b82f6\"\n stroke-width=\"2.5\"\n stroke-dasharray=\"8,6\"\n stroke-linecap=\"round\"\n opacity=\"0.7\"\n />\n }\n\n <!-- Layer 1: Edge paths (behind everything) -->\n @for (edge of internalGraph().edges; track edge.id) {\n <!-- Edge shadow for depth (optional) -->\n @if (shadowsEnabled()) {\n <path\n class=\"edge-shadow\"\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"rgba(0,0,0,0.06)\"\n stroke-width=\"6\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [attr.transform]=\"'translate(1, 2)'\"\n />\n }\n <!-- Invisible wide hit-area for easier clicking (hand tool only) -->\n <path\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"transparent\"\n [attr.stroke-width]=\"20\"\n fill=\"none\"\n class=\"edge-hit-area\"\n [attr.pointer-events]=\"activeTool() === 'hand' ? 'stroke' : 'none'\"\n (click)=\"onEdgeClick($event, edge)\"\n (dblclick)=\"onEdgeDoubleClick($event, edge)\"\n />\n <!-- Visible edge line -->\n <path\n class=\"edge-line\"\n [attr.d]=\"getEdgePath(edge)\"\n [attr.stroke]=\"selection().edges.includes(edge.id) ? '#3b82f6' : '#94a3b8'\"\n [attr.stroke-width]=\"selection().edges.includes(edge.id) ? 2.5 : 2\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [class.selected]=\"selection().edges.includes(edge.id)\"\n [attr.marker-end]=\"getEdgeMarkerEnd(edge)\"\n [attr.marker-start]=\"getEdgeMarkerStart(edge)\"\n pointer-events=\"none\"\n />\n }\n\n <!-- Layer 2: Nodes -->\n @for (node of internalGraph().nodes; track node.id) {\n <g\n [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\"\n class=\"graph-node\"\n [class.selected]=\"selection().nodes.includes(node.id)\"\n [attr.data-node-id]=\"node.id\"\n (mousedown)=\"onNodeMouseDown($event, node)\"\n (click)=\"onNodeClick($event, node)\"\n (dblclick)=\"nodeDoubleClick.emit(node)\"\n >\n <!-- Node shadow (optional) -->\n @if (shadowsEnabled()) {\n <rect\n class=\"node-shadow\"\n [attr.width]=\"getNodeSize(node).width\"\n [attr.height]=\"getNodeSize(node).height\"\n fill=\"rgba(0,0,0,0.08)\"\n rx=\"12\"\n transform=\"translate(2, 3)\"\n style=\"filter: blur(4px);\"\n />\n }\n <!-- Node background -->\n <rect\n class=\"node-bg\"\n [attr.width]=\"getNodeSize(node).width\"\n [attr.height]=\"getNodeSize(node).height\"\n fill=\"white\"\n [attr.stroke]=\"selection().nodes.includes(node.id) ? '#3b82f6' : '#e2e8f0'\"\n [attr.stroke-width]=\"selection().nodes.includes(node.id) ? 2.5 : 1.5\"\n rx=\"12\"\n />\n\n <!-- Node type icon (text/emoji) or custom image -->\n @if (getNodeImage(node)) {\n <image\n class=\"node-image\"\n [attr.href]=\"getNodeImage(node)\"\n [attr.xlink:href]=\"getNodeImage(node)\"\n [attr.x]=\"getImagePosition(node).x\"\n [attr.y]=\"getImagePosition(node).y\"\n [attr.width]=\"getImageSize(node)\"\n [attr.height]=\"getImageSize(node)\"\n preserveAspectRatio=\"xMidYMid meet\"\n />\n } @else {\n <text\n class=\"node-icon\"\n [attr.x]=\"getIconPosition(node).x\"\n [attr.y]=\"getIconPosition(node).y\"\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n [attr.font-size]=\"getNodeSize(node).height * 0.28\"\n >\n {{ getNodeTypeIcon(node) }}\n </text>\n }\n\n <!-- Node label (wrapped and auto-sized) -->\n <text\n class=\"node-label\"\n [attr.x]=\"getLabelLineX(node)\"\n text-anchor=\"middle\"\n [attr.font-size]=\"getWrappedLabel(node).fontSize\"\n font-weight=\"500\"\n fill=\"#1e293b\"\n >\n @for (line of getWrappedLabel(node).lines; track $index) {\n <tspan\n [attr.x]=\"getLabelLineX(node)\"\n [attr.y]=\"getLabelLineY(node, $index, getWrappedLabel(node).lines.length, getWrappedLabel(node).lineHeight)\"\n >{{ line }}</tspan>\n }\n </text>\n\n <!-- Resize handle (SE corner, visible when selected + hand tool) -->\n @if (selection().nodes.includes(node.id) && activeTool() === 'hand' && selection().nodes.length === 1) {\n <rect\n class=\"resize-handle resize-handle-se\"\n [attr.x]=\"getNodeSize(node).width - 8\"\n [attr.y]=\"getNodeSize(node).height - 8\"\n width=\"10\"\n height=\"10\"\n fill=\"#3b82f6\"\n rx=\"2\"\n cursor=\"se-resize\"\n (mousedown)=\"onResizeHandleMouseDown($event, node)\"\n />\n }\n </g>\n }\n\n <!-- Layer 3: Attachment points (on top of nodes) -->\n @for (node of internalGraph().nodes; track node.id) {\n @if (showAttachmentPoints() === node.id) {\n <g [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\">\n @for (port of getNodePorts(node); track port.position) {\n <circle\n [attr.cx]=\"port.x\"\n [attr.cy]=\"port.y\"\n [attr.r]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? 8 : 6\"\n [attr.fill]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? '#2563eb' : '#94a3b8'\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"attachment-point\"\n [class.hovered]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position\"\n (mousedown)=\"$event.stopPropagation()\"\n (click)=\"onAttachmentPointClick($event, node, port.position)\"\n />\n }\n </g>\n }\n }\n\n <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->\n @for (edge of internalGraph().edges; track edge.id) {\n @if (selection().edges.includes(edge.id)) {\n <g>\n <!-- Source endpoint -->\n <circle\n [attr.cx]=\"getEdgeSourcePoint(edge).x\"\n [attr.cy]=\"getEdgeSourcePoint(edge).y\"\n r=\"6\"\n fill=\"#3b82f6\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'source')\"\n />\n\n <!-- Target endpoint -->\n <circle\n [attr.cx]=\"getEdgeTargetPoint(edge).x\"\n [attr.cy]=\"getEdgeTargetPoint(edge).y\"\n r=\"6\"\n fill=\"#3b82f6\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'target')\"\n />\n </g>\n }\n }\n\n <!-- Layer 5: Selection box (Shift+drag) -->\n @if (selectionBox()) {\n <rect\n class=\"selection-box\"\n [attr.x]=\"selectionBox()!.x\"\n [attr.y]=\"selectionBox()!.y\"\n [attr.width]=\"selectionBox()!.width\"\n [attr.height]=\"selectionBox()!.height\"\n fill=\"rgba(59, 130, 246, 0.1)\"\n stroke=\"#3b82f6\"\n stroke-width=\"1\"\n stroke-dasharray=\"4,2\"\n />\n }\n </g>\n </svg>\n </div>\n\n <!-- Edge direction selector overlay -->\n @if (selectedEdgeMidpoint()) {\n <div\n class=\"edge-direction-selector\"\n [style.left.px]=\"selectedEdgeMidpoint()!.x\"\n [style.top.px]=\"selectedEdgeMidpoint()!.y\"\n >\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'backward'\"\n title=\"Backward\"\n (click)=\"setEdgeDirection('backward')\"\n >\u2190</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'bidirectional'\"\n title=\"Bidirectional\"\n (click)=\"setEdgeDirection('bidirectional')\"\n >\u2194</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'\"\n title=\"Forward\"\n (click)=\"setEdgeDirection('forward')\"\n >\u2192</button>\n </div>\n }\n</div>\n", styles: [".graph-editor-container{display:flex;width:100%;height:100%;position:relative;background:var(--graph-editor-canvas-bg, #f8f9fa)}.graph-palette-overlay{position:absolute;top:16px;left:16px;display:flex;gap:4px;z-index:10;background:#fffffff2;padding:6px;border-radius:var(--radius-md, 8px);box-shadow:0 2px 8px #0000001a;backdrop-filter:blur(4px)}.palette-item{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;padding:0;border:1.5px solid var(--neutral-200, #e5e7eb);border-radius:var(--radius-md, 8px);background:var(--white, #fff);color:var(--neutral-600, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:20px}.palette-icon-img{width:24px;height:24px;object-fit:contain}.palette-icon-svg{width:22px;height:22px;flex-shrink:0}.palette-item:focus-visible{outline:2px solid var(--indigo-400, #818cf8);outline-offset:2px}.palette-item:hover{background:var(--neutral-50, #f9fafb);border-color:var(--interactive, #3b82f6);color:var(--interactive, #3b82f6);transform:translateY(-1px);box-shadow:0 2px 6px #00000014}.palette-item:active{transform:translateY(0);box-shadow:none}.palette-item.tool-item.active{background:var(--interactive, #3b82f6);border-color:var(--interactive, #3b82f6);color:#fff}.palette-item.tool-item.active:hover{background:var(--interactive-hover, #2563eb);border-color:var(--interactive-hover, #2563eb);color:#fff}.palette-divider{width:1px;background:var(--neutral-200, #e5e7eb);align-self:stretch;margin:4px 2px}.graph-canvas-wrapper{flex:1;position:relative;overflow:hidden}.graph-canvas{width:100%;height:100%;cursor:grab}.graph-canvas:active{cursor:grabbing}.graph-canvas.tool-line,.graph-canvas.tool-line .graph-node{cursor:crosshair}.graph-node{cursor:move;user-select:none;-webkit-user-select:none;transition:transform .1s ease-out}.graph-node:hover .node-bg{stroke:#cbd5e1}.graph-node text{pointer-events:none}.graph-node .node-label{font-family:system-ui,-apple-system,sans-serif}.graph-node.selected .node-bg{stroke:#3b82f6;filter:drop-shadow(0 4px 12px rgba(59,130,246,.25))}.edge-line{transition:stroke .15s,stroke-width .15s}.edge-line.selected{filter:drop-shadow(0 2px 4px rgba(59,130,246,.3))}.edge-hit-area{cursor:pointer}.edge-endpoint{cursor:pointer;transition:r .2s,fill .2s}.edge-endpoint:hover{r:8;fill:#2563eb}.edge-endpoint.selected{fill:#2563eb}.attachment-point{cursor:crosshair;transition:all .2s}.attachment-point.hovered{filter:drop-shadow(0 0 4px rgba(37,99,235,.6))}.validation-panel{position:absolute;bottom:0;left:0;right:0;max-height:200px;overflow-y:auto;background:#fff;border-top:1px solid #e5e7eb;padding:16px}.error-item{padding:8px 12px;margin-bottom:8px;background:#fee2e2;border-left:3px solid #ef4444;border-radius:4px;font-size:14px}.error-item.warning{background:#fef3c7;border-left-color:#f59e0b}.edge-direction-selector{position:absolute;transform:translate(-50%,-100%);margin-top:-12px;display:flex;gap:2px;background:#fffffff2;padding:4px;border-radius:6px;box-shadow:0 2px 8px #00000026;backdrop-filter:blur(4px);z-index:20;pointer-events:auto}.direction-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid #e5e7eb;border-radius:4px;background:#fff;cursor:pointer;font-size:16px;transition:all .15s;color:#6b7280}.direction-btn:hover{background:#f3f4f6;border-color:#3b82f6;color:#3b82f6}.direction-btn.active{background:#3b82f6;border-color:#3b82f6;color:#fff}.resize-handle{cursor:se-resize;opacity:.8;transition:opacity .15s,fill .15s}.resize-handle:hover{opacity:1;fill:#2563eb}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
2045
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.19", type: GraphEditorComponent, isStandalone: true, selector: "graph-editor", inputs: { config: "config", graph: "graph", readonly: "readonly", visualizationMode: "visualizationMode", overlayData: "overlayData" }, outputs: { graphChange: "graphChange", nodeAdded: "nodeAdded", nodeUpdated: "nodeUpdated", nodeRemoved: "nodeRemoved", edgeAdded: "edgeAdded", edgeUpdated: "edgeUpdated", edgeRemoved: "edgeRemoved", selectionChange: "selectionChange", validationChange: "validationChange", nodeClick: "nodeClick", nodeDoubleClick: "nodeDoubleClick", edgeClick: "edgeClick", edgeDoubleClick: "edgeDoubleClick", canvasClick: "canvasClick", contextMenu: "contextMenu" }, host: { attributes: { "tabindex": "0" }, listeners: { "keydown": "onKeyDown($event)" }, styleAttribute: "outline: none;" }, providers: [GraphHistoryService], queries: [{ propertyName: "nodeHtmlTemplate", first: true, predicate: NodeHtmlTemplateDirective, descendants: true, isSignal: true }, { propertyName: "nodeSvgTemplate", first: true, predicate: NodeSvgTemplateDirective, descendants: true, isSignal: true }, { propertyName: "edgeTemplate", first: true, predicate: EdgeTemplateDirective, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "canvasSvgRef", first: true, predicate: ["canvasSvg"], descendants: true, isSignal: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"graph-editor-container\">\n <!-- Canvas with overlaid palette -->\n <div class=\"graph-canvas-wrapper\">\n <!-- Top horizontal toolbar -->\n @if (config.palette?.enabled !== false) {\n <div class=\"graph-toolbar-top\">\n <!-- Tools -->\n <button\n class=\"toolbar-btn\"\n [class.active]=\"activeTool() === 'hand'\"\n title=\"Hand tool (move nodes)\"\n (click)=\"switchTool('hand')\"\n >\n <span class=\"icon\">\u270B</span>\n </button>\n <button\n class=\"toolbar-btn\"\n [class.active]=\"activeTool() === 'line'\"\n title=\"Line tool (draw connections)\"\n (click)=\"switchTool('line')\"\n >\n <span class=\"icon\">\u2215</span>\n </button>\n\n <div class=\"toolbar-divider\"></div>\n\n <!-- Zoom -->\n <button\n class=\"toolbar-btn\"\n title=\"Zoom in\"\n (click)=\"zoomIn()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"/>\n <path d=\"M21 21l-4.35-4.35M11 8v6M8 11h6\"/>\n </svg>\n </button>\n <button\n class=\"toolbar-btn\"\n title=\"Zoom out\"\n (click)=\"zoomOut()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"/>\n <path d=\"M21 21l-4.35-4.35M8 11h6\"/>\n </svg>\n </button>\n\n <div class=\"toolbar-divider\"></div>\n\n <!-- Actions -->\n <button\n class=\"toolbar-btn\"\n title=\"Auto layout\"\n (click)=\"applyLayout()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"/>\n <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"/>\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"/>\n <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"/>\n </svg>\n </button>\n <button\n class=\"toolbar-btn\"\n title=\"Fit to screen\"\n (click)=\"fitToScreen()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n </svg>\n </button>\n </div>\n\n <!-- Left vertical palette (node types) - supports multiple columns -->\n <div class=\"graph-palette-container\">\n @for (column of getPaletteColumns(); track $index) {\n <div class=\"graph-palette-column\">\n @for (nodeType of column; track nodeType.type) {\n <button\n class=\"palette-item\"\n [attr.data-node-type]=\"nodeType.type\"\n [attr.title]=\"nodeType.label || nodeType.type\"\n (click)=\"addNode(nodeType.type)\"\n >\n @if (nodeType.iconSvg) {\n <svg\n class=\"palette-icon-svg\"\n [attr.viewBox]=\"nodeType.iconSvg.viewBox || '0 0 24 24'\"\n [attr.fill]=\"nodeType.iconSvg.fill || 'none'\"\n [attr.stroke]=\"nodeType.iconSvg.stroke || '#1D6A96'\"\n [attr.stroke-width]=\"nodeType.iconSvg.strokeWidth || 2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n @for (pathData of splitIconPaths(nodeType.iconSvg.path); track $index) {\n <path [attr.d]=\"pathData\"/>\n }\n </svg>\n } @else if (nodeType.defaultData['imageUrl']) {\n <img\n class=\"palette-icon-img\"\n [src]=\"nodeType.defaultData['imageUrl']\"\n [alt]=\"nodeType.label || nodeType.type\"\n />\n } @else {\n <span class=\"icon\">{{ nodeType.icon || '\u25CF' }}</span>\n }\n </button>\n }\n </div>\n }\n </div>\n }\n\n <svg\n #canvasSvg\n [class.tool-line]=\"activeTool() === 'line'\"\n [attr.width]=\"'100%'\"\n [attr.height]=\"'100%'\"\n (mousedown)=\"onCanvasMouseDown($event)\"\n (mousemove)=\"onCanvasMouseMove($event)\"\n (mouseup)=\"onCanvasMouseUp($event)\"\n (wheel)=\"onWheel($event)\"\n (contextmenu)=\"onContextMenu($event)\"\n >\n <!-- Arrow marker definitions -->\n <defs>\n <marker id=\"arrow-end\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" [attr.fill]=\"resolvedTheme.edge.markerColor\"/>\n </marker>\n <marker id=\"arrow-end-selected\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" [attr.fill]=\"resolvedTheme.edge.selectedMarkerColor\"/>\n </marker>\n <marker id=\"arrow-start\" viewBox=\"0 0 10 10\" refX=\"2\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" [attr.fill]=\"resolvedTheme.edge.markerColor\"/>\n </marker>\n <marker id=\"arrow-start-selected\" viewBox=\"0 0 10 10\" refX=\"2\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" [attr.fill]=\"resolvedTheme.edge.selectedMarkerColor\"/>\n </marker>\n </defs>\n\n <!-- Main transform group (pan + zoom) -->\n <g [attr.transform]=\"transform()\">\n <!-- Grid (if enabled) -->\n <!-- Grid (if enabled) - extended to cover viewport during pan -->\n @if (config.canvas?.grid?.enabled) {\n <defs>\n @if (resolvedTheme.canvas.gridType === 'dot') {\n <pattern id=\"grid\" [attr.width]=\"config.canvas!.grid!.size\" [attr.height]=\"config.canvas!.grid!.size\" patternUnits=\"userSpaceOnUse\">\n <circle [attr.cx]=\"config.canvas!.grid!.size / 2\" [attr.cy]=\"config.canvas!.grid!.size / 2\" r=\"1\" [attr.fill]=\"resolvedTheme.canvas.gridColor\" />\n </pattern>\n } @else {\n <pattern id=\"grid\" [attr.width]=\"config.canvas!.grid!.size\" [attr.height]=\"config.canvas!.grid!.size\" patternUnits=\"userSpaceOnUse\">\n <path [attr.d]=\"'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size\" fill=\"none\" [attr.stroke]=\"resolvedTheme.canvas.gridColor\" stroke-width=\"1\" />\n </pattern>\n }\n </defs>\n <rect [attr.x]=\"gridBounds().x\" [attr.y]=\"gridBounds().y\" [attr.width]=\"gridBounds().width\" [attr.height]=\"gridBounds().height\" fill=\"url(#grid)\" />\n }\n\n <!-- Layer 0.5: Preview line for line tool (rubber-band) -->\n @if (previewLine()) {\n <line\n class=\"preview-line\"\n [attr.x1]=\"previewLine()!.source.x\"\n [attr.y1]=\"previewLine()!.source.y\"\n [attr.x2]=\"previewLine()!.target.x\"\n [attr.y2]=\"previewLine()!.target.y\"\n [attr.stroke]=\"resolvedTheme.selection.color\"\n stroke-width=\"2.5\"\n stroke-dasharray=\"8,6\"\n stroke-linecap=\"round\"\n opacity=\"0.7\"\n />\n }\n\n <!-- Layer 1: Edge paths (behind everything) -->\n @for (edge of internalGraph().edges; track edge.id) {\n <!-- Edge shadow for depth (optional) -->\n @if (shadowsEnabled()) {\n <path\n class=\"edge-shadow\"\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"rgba(0,0,0,0.06)\"\n stroke-width=\"6\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [attr.transform]=\"'translate(1, 2)'\"\n />\n }\n <!-- Invisible wide hit-area for easier clicking (hand tool only) -->\n <path\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"transparent\"\n [attr.stroke-width]=\"20\"\n fill=\"none\"\n class=\"edge-hit-area\"\n [attr.pointer-events]=\"activeTool() === 'hand' ? 'stroke' : 'none'\"\n (click)=\"onEdgeClick($event, edge)\"\n (dblclick)=\"onEdgeDoubleClick($event, edge)\"\n />\n <!-- Visible edge line -->\n @if (edgeTemplate()?.templateRef) {\n <ng-container [ngTemplateOutlet]=\"edgeTemplate()!.templateRef\" [ngTemplateOutletContext]=\"getEdgeTemplateContext(edge)\" />\n } @else {\n <path class=\"edge-line\" [attr.d]=\"getEdgePath(edge)\"\n [attr.stroke]=\"selection().edges.includes(edge.id) ? resolvedTheme.edge.selectedStroke : resolvedTheme.edge.stroke\"\n [attr.stroke-width]=\"selection().edges.includes(edge.id) ? resolvedTheme.edge.selectedStrokeWidth : resolvedTheme.edge.strokeWidth\"\n fill=\"none\" stroke-linecap=\"round\" [class.selected]=\"selection().edges.includes(edge.id)\"\n [attr.marker-end]=\"getEdgeMarkerEnd(edge)\" [attr.marker-start]=\"getEdgeMarkerStart(edge)\"\n pointer-events=\"none\" />\n }\n }\n\n <!-- Layer 2: Nodes -->\n @for (node of internalGraph().nodes; track node.id) {\n <g\n [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\"\n class=\"graph-node\"\n [class.selected]=\"selection().nodes.includes(node.id)\"\n [attr.data-node-id]=\"node.id\"\n (mousedown)=\"onNodeMouseDown($event, node)\"\n (click)=\"onNodeClick($event, node)\"\n (dblclick)=\"nodeDoubleClick.emit(node)\"\n >\n <!-- Priority: nodeHtmlTemplate > nodeSvgTemplate > component > default -->\n @if (nodeHtmlTemplate()?.templateRef) {\n <foreignObject [attr.width]=\"getNodeSize(node).width\" [attr.height]=\"getNodeSize(node).height\">\n <ng-container [ngTemplateOutlet]=\"nodeHtmlTemplate()!.templateRef\" [ngTemplateOutletContext]=\"getNodeTemplateContext(node)\" />\n </foreignObject>\n } @else if (nodeSvgTemplate()?.templateRef) {\n <ng-container [ngTemplateOutlet]=\"nodeSvgTemplate()!.templateRef\" [ngTemplateOutletContext]=\"getNodeTemplateContext(node)\" />\n } @else if (getNodeComponent(node)) {\n <foreignObject [attr.width]=\"getNodeSize(node).width\" [attr.height]=\"getNodeSize(node).height\">\n <ng-container *ngComponentOutlet=\"getNodeComponent(node)!; inputs: getNodeComponentInputs(node)\" />\n </foreignObject>\n } @else {\n <!-- Default built-in rendering (same as before but with resolvedTheme) -->\n @if (shadowsEnabled()) {\n <rect class=\"node-shadow\" [attr.width]=\"getNodeSize(node).width\" [attr.height]=\"getNodeSize(node).height\"\n [attr.fill]=\"resolvedTheme.node.shadowColor\" [attr.rx]=\"resolvedTheme.node.borderRadius\"\n transform=\"translate(2, 3)\" style=\"filter: blur(4px);\" />\n }\n <rect class=\"node-bg\" [attr.width]=\"getNodeSize(node).width\" [attr.height]=\"getNodeSize(node).height\"\n [attr.fill]=\"resolvedTheme.node.background\"\n [attr.stroke]=\"selection().nodes.includes(node.id) ? resolvedTheme.node.selectedBorderColor : resolvedTheme.node.borderColor\"\n [attr.stroke-width]=\"selection().nodes.includes(node.id) ? resolvedTheme.node.selectedBorderWidth : resolvedTheme.node.borderWidth\"\n [attr.rx]=\"resolvedTheme.node.borderRadius\" />\n @if (getNodeImage(node)) {\n <image class=\"node-image\" [attr.href]=\"getNodeImage(node)\" [attr.xlink:href]=\"getNodeImage(node)\"\n [attr.x]=\"getImagePosition(node).x\" [attr.y]=\"getImagePosition(node).y\"\n [attr.width]=\"getImageSize(node)\" [attr.height]=\"getImageSize(node)\"\n preserveAspectRatio=\"xMidYMid meet\" />\n } @else {\n <text class=\"node-icon\" [attr.x]=\"getIconPosition(node).x\" [attr.y]=\"getIconPosition(node).y\"\n text-anchor=\"middle\" dominant-baseline=\"middle\" [attr.font-size]=\"getNodeSize(node).height * 0.28\">\n {{ getNodeTypeIcon(node) }}\n </text>\n }\n <text class=\"node-label\" [attr.x]=\"getLabelLineX(node)\" text-anchor=\"middle\"\n [attr.font-size]=\"getWrappedLabel(node).fontSize\" font-weight=\"500\" [attr.fill]=\"resolvedTheme.node.labelColor\">\n @for (line of getWrappedLabel(node).lines; track $index) {\n <tspan [attr.x]=\"getLabelLineX(node)\"\n [attr.y]=\"getLabelLineY(node, $index, getWrappedLabel(node).lines.length, getWrappedLabel(node).lineHeight)\">{{ line }}</tspan>\n }\n </text>\n @if (selection().nodes.includes(node.id) && activeTool() === 'hand' && selection().nodes.length === 1) {\n <rect class=\"resize-handle resize-handle-se\" [attr.x]=\"getNodeSize(node).width - 8\" [attr.y]=\"getNodeSize(node).height - 8\"\n width=\"10\" height=\"10\" [attr.fill]=\"resolvedTheme.selection.color\" rx=\"2\" cursor=\"se-resize\"\n (mousedown)=\"onResizeHandleMouseDown($event, node)\" />\n }\n }\n </g>\n }\n\n <!-- Layer 3: Attachment points (on top of nodes) -->\n @for (node of internalGraph().nodes; track node.id) {\n @if (showAttachmentPoints() === node.id) {\n <g [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\">\n @for (port of getNodePorts(node); track port.position) {\n <circle\n [attr.cx]=\"port.x\"\n [attr.cy]=\"port.y\"\n [attr.r]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? resolvedTheme.port.hoverRadius : resolvedTheme.port.radius\"\n [attr.fill]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? resolvedTheme.port.hoverFill : resolvedTheme.port.fill\"\n [attr.stroke]=\"resolvedTheme.port.stroke\"\n [attr.stroke-width]=\"resolvedTheme.port.strokeWidth\"\n class=\"attachment-point\"\n [class.hovered]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position\"\n (mousedown)=\"$event.stopPropagation()\"\n (click)=\"onAttachmentPointClick($event, node, port.position)\"\n />\n }\n </g>\n }\n }\n\n <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->\n @for (edge of internalGraph().edges; track edge.id) {\n @if (selection().edges.includes(edge.id)) {\n <g>\n <!-- Source endpoint -->\n <circle\n [attr.cx]=\"getEdgeSourcePoint(edge).x\"\n [attr.cy]=\"getEdgeSourcePoint(edge).y\"\n r=\"6\"\n [attr.fill]=\"resolvedTheme.selection.color\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'source')\"\n />\n\n <!-- Target endpoint -->\n <circle\n [attr.cx]=\"getEdgeTargetPoint(edge).x\"\n [attr.cy]=\"getEdgeTargetPoint(edge).y\"\n r=\"6\"\n [attr.fill]=\"resolvedTheme.selection.color\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'target')\"\n />\n </g>\n }\n }\n\n <!-- Layer 5: Selection box (Shift+drag) -->\n @if (selectionBox()) {\n <rect\n class=\"selection-box\"\n [attr.x]=\"selectionBox()!.x\"\n [attr.y]=\"selectionBox()!.y\"\n [attr.width]=\"selectionBox()!.width\"\n [attr.height]=\"selectionBox()!.height\"\n [attr.fill]=\"resolvedTheme.selection.boxFill\"\n [attr.stroke]=\"resolvedTheme.selection.boxStroke\"\n stroke-width=\"1\"\n stroke-dasharray=\"4,2\"\n />\n }\n </g>\n </svg>\n </div>\n\n <!-- Edge direction selector overlay -->\n @if (selectedEdgeMidpoint()) {\n <div\n class=\"edge-direction-selector\"\n [style.left.px]=\"selectedEdgeMidpoint()!.x\"\n [style.top.px]=\"selectedEdgeMidpoint()!.y\"\n >\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'backward'\"\n title=\"Backward\"\n (click)=\"setEdgeDirection('backward')\"\n >\u2190</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'bidirectional'\"\n title=\"Bidirectional\"\n (click)=\"setEdgeDirection('bidirectional')\"\n >\u2194</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'\"\n title=\"Forward\"\n (click)=\"setEdgeDirection('forward')\"\n >\u2192</button>\n </div>\n }\n</div>\n", styles: [".graph-editor-container{display:flex;width:100%;height:100%;position:relative;background:var(--graph-editor-canvas-bg, #f8f9fa)}.graph-toolbar-top{position:absolute;top:12px;left:12px;display:flex;gap:4px;z-index:10;background:var(--ge-toolbar-bg, rgba(255, 255, 255, .95));padding:6px;border-radius:var(--ge-toolbar-radius, 8px);box-shadow:var(--ge-toolbar-shadow, 0 2px 8px rgba(0, 0, 0, .1));backdrop-filter:blur(4px)}.toolbar-btn{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1.5px solid var(--ge-toolbar-btn-border, #e5e7eb);border-radius:var(--ge-toolbar-radius, 8px);background:var(--ge-toolbar-btn-bg, #fff);color:var(--ge-toolbar-btn-color, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:18px}.toolbar-btn:focus-visible{outline:2px solid var(--indigo-400, #818cf8);outline-offset:2px}.toolbar-btn:hover{background:var(--ge-toolbar-btn-hover-bg, #f9fafb);border-color:var(--ge-toolbar-btn-hover-accent, #3b82f6);color:var(--ge-toolbar-btn-hover-accent, #3b82f6)}.toolbar-btn:active{transform:translateY(0);box-shadow:none}.toolbar-btn.active{background:var(--ge-toolbar-btn-active-bg, #3b82f6);border-color:var(--ge-toolbar-btn-active-bg, #3b82f6);color:var(--ge-toolbar-btn-active-color, white)}.toolbar-btn.active:hover{background:color-mix(in srgb,var(--ge-toolbar-btn-active-bg, #3b82f6) 85%,black);border-color:color-mix(in srgb,var(--ge-toolbar-btn-active-bg, #3b82f6) 85%,black);color:var(--ge-toolbar-btn-active-color, white)}.toolbar-divider{width:1px;background:var(--ge-toolbar-divider, #e5e7eb);align-self:stretch;margin:4px 2px}.graph-palette-container{position:absolute;top:72px;left:12px;display:flex;flex-direction:row;gap:8px;z-index:10}.graph-palette-column{display:flex;flex-direction:column;gap:4px;background:var(--ge-toolbar-bg, rgba(255, 255, 255, .95));padding:6px;border-radius:var(--ge-toolbar-radius, 8px);box-shadow:var(--ge-toolbar-shadow, 0 2px 8px rgba(0, 0, 0, .1));backdrop-filter:blur(4px)}.palette-item{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1.5px solid var(--ge-toolbar-btn-border, #e5e7eb);border-radius:var(--ge-toolbar-radius, 8px);background:var(--ge-toolbar-btn-bg, #fff);color:var(--ge-toolbar-btn-color, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:18px}.graph-canvas-wrapper{flex:1;position:relative;overflow:hidden}.graph-canvas{width:100%;height:100%;cursor:grab}.graph-canvas:active{cursor:grabbing}.graph-canvas.tool-line,.graph-canvas.tool-line .graph-node{cursor:crosshair}.graph-node{cursor:move;user-select:none;-webkit-user-select:none;transition:transform .1s ease-out}.graph-node:hover .node-bg{stroke:var(--ge-node-border, #cbd5e1)}.graph-node text{pointer-events:none}.graph-node .node-label{font-family:var(--ge-font-family, system-ui, -apple-system, sans-serif)}.graph-node.selected .node-bg{stroke:var(--ge-selection-color, #3b82f6);filter:drop-shadow(0 4px 12px rgba(59,130,246,.25))}.edge-line{transition:stroke .15s,stroke-width .15s}.edge-line.selected{filter:drop-shadow(0 2px 4px rgba(59,130,246,.3))}.edge-hit-area{cursor:pointer}.edge-endpoint{cursor:pointer;transition:r .2s,fill .2s}.edge-endpoint:hover{r:8;fill:var(--ge-selection-color, #3b82f6)}.edge-endpoint.selected{fill:var(--ge-selection-color, #3b82f6)}.attachment-point{cursor:crosshair;transition:all .2s}.attachment-point.hovered{filter:drop-shadow(0 0 4px rgba(37,99,235,.6))}.validation-panel{position:absolute;bottom:0;left:0;right:0;max-height:200px;overflow-y:auto;background:#fff;border-top:1px solid #e5e7eb;padding:16px}.error-item{padding:8px 12px;margin-bottom:8px;background:#fee2e2;border-left:3px solid #ef4444;border-radius:4px;font-size:14px}.error-item.warning{background:#fef3c7;border-left-color:#f59e0b}.edge-direction-selector{position:absolute;transform:translate(-50%,-100%);margin-top:-12px;display:flex;gap:2px;background:var(--ge-toolbar-bg, rgba(255, 255, 255, .95));padding:4px;border-radius:calc(var(--ge-toolbar-radius, 8px) * .75);box-shadow:var(--ge-toolbar-shadow, 0 2px 8px rgba(0, 0, 0, .15));backdrop-filter:blur(4px);z-index:20;pointer-events:auto}.direction-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid var(--ge-toolbar-btn-border, #e5e7eb);border-radius:calc(var(--ge-toolbar-radius, 8px) * .5);background:var(--ge-toolbar-btn-bg, white);cursor:pointer;font-size:16px;transition:all .15s;color:var(--ge-toolbar-btn-color, #6b7280)}.direction-btn:hover{background:var(--ge-toolbar-btn-hover-bg, #f3f4f6);border-color:var(--ge-toolbar-btn-hover-accent, #3b82f6);color:var(--ge-toolbar-btn-hover-accent, #3b82f6)}.direction-btn.active{background:var(--ge-toolbar-btn-active-bg, #3b82f6);border-color:var(--ge-toolbar-btn-active-bg, #3b82f6);color:var(--ge-toolbar-btn-active-color, white)}.resize-handle{cursor:se-resize;opacity:.8;transition:opacity .15s,fill .15s}.resize-handle:hover{opacity:1;fill:var(--ge-selection-color, #3b82f6)}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1690
2046
|
}
|
|
1691
2047
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphEditorComponent, decorators: [{
|
|
1692
2048
|
type: Component,
|
|
1693
|
-
args: [{ selector: 'graph-editor', standalone: true, imports: [], providers: [GraphHistoryService], host: {
|
|
2049
|
+
args: [{ selector: 'graph-editor', standalone: true, imports: [NgTemplateOutlet, NgComponentOutlet], providers: [GraphHistoryService], host: {
|
|
1694
2050
|
'tabindex': '0',
|
|
1695
2051
|
'style': 'outline: none;',
|
|
1696
2052
|
'(keydown)': 'onKeyDown($event)'
|
|
1697
|
-
}, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"graph-editor-container\">\n <!-- Canvas with overlaid palette -->\n <div class=\"graph-canvas-wrapper\">\n <!-- Top-left horizontal palette overlay -->\n @if (config.palette?.enabled !== false) {\n <div class=\"graph-palette-overlay\">\n <!-- Tools -->\n <button\n class=\"palette-item tool-item\"\n [class.active]=\"activeTool() === 'hand'\"\n title=\"Hand tool (move nodes)\"\n (click)=\"switchTool('hand')\"\n >\n <span class=\"icon\">\u270B</span>\n </button>\n <button\n class=\"palette-item tool-item\"\n [class.active]=\"activeTool() === 'line'\"\n title=\"Line tool (draw connections)\"\n (click)=\"switchTool('line')\"\n >\n <span class=\"icon\">\u2215</span>\n </button>\n\n <!-- Divider -->\n <div class=\"palette-divider\"></div>\n\n <!-- Node types -->\n @for (nodeType of config.nodes.types; track nodeType.type) {\n <button\n class=\"palette-item\"\n [attr.data-node-type]=\"nodeType.type\"\n [attr.title]=\"nodeType.label || nodeType.type\"\n (click)=\"addNode(nodeType.type)\"\n >\n @if (nodeType.iconSvg) {\n <svg\n class=\"palette-icon-svg\"\n [attr.viewBox]=\"nodeType.iconSvg.viewBox || '0 0 24 24'\"\n [attr.fill]=\"nodeType.iconSvg.fill || 'none'\"\n [attr.stroke]=\"nodeType.iconSvg.stroke || '#1D6A96'\"\n [attr.stroke-width]=\"nodeType.iconSvg.strokeWidth || 2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n @for (pathData of splitIconPaths(nodeType.iconSvg.path); track $index) {\n <path [attr.d]=\"pathData\"/>\n }\n </svg>\n } @else if (nodeType.defaultData['imageUrl']) {\n <img\n class=\"palette-icon-img\"\n [src]=\"nodeType.defaultData['imageUrl']\"\n [alt]=\"nodeType.label || nodeType.type\"\n />\n } @else {\n <span class=\"icon\">{{ nodeType.icon || '\u25CF' }}</span>\n }\n </button>\n }\n </div>\n }\n\n <svg\n #canvasSvg\n [class.tool-line]=\"activeTool() === 'line'\"\n [attr.width]=\"'100%'\"\n [attr.height]=\"'100%'\"\n (mousedown)=\"onCanvasMouseDown($event)\"\n (mousemove)=\"onCanvasMouseMove($event)\"\n (mouseup)=\"onCanvasMouseUp($event)\"\n (wheel)=\"onWheel($event)\"\n (contextmenu)=\"onContextMenu($event)\"\n >\n <!-- Arrow marker definitions -->\n <defs>\n <marker id=\"arrow-end\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" fill=\"#94a3b8\"/>\n </marker>\n <marker id=\"arrow-end-selected\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" fill=\"#3b82f6\"/>\n </marker>\n <marker id=\"arrow-start\" viewBox=\"0 0 10 10\" refX=\"1\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" fill=\"#94a3b8\"/>\n </marker>\n <marker id=\"arrow-start-selected\" viewBox=\"0 0 10 10\" refX=\"1\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" fill=\"#3b82f6\"/>\n </marker>\n </defs>\n\n <!-- Main transform group (pan + zoom) -->\n <g [attr.transform]=\"transform()\">\n <!-- Grid (if enabled) -->\n <!-- Grid (if enabled) - extended to cover viewport during pan -->\n @if (config.canvas?.grid?.enabled) {\n <defs>\n <pattern\n id=\"grid\"\n [attr.width]=\"config.canvas!.grid!.size\"\n [attr.height]=\"config.canvas!.grid!.size\"\n patternUnits=\"userSpaceOnUse\"\n >\n <path\n [attr.d]=\"'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size\"\n fill=\"none\"\n [attr.stroke]=\"config.canvas!.grid!.color || '#e0e0e0'\"\n stroke-width=\"1\"\n />\n </pattern>\n </defs>\n <!-- Extended grid background covering viewport + pan offset -->\n <rect\n [attr.x]=\"gridBounds().x\"\n [attr.y]=\"gridBounds().y\"\n [attr.width]=\"gridBounds().width\"\n [attr.height]=\"gridBounds().height\"\n fill=\"url(#grid)\"\n />\n }\n\n <!-- Layer 0.5: Preview line for line tool (rubber-band) -->\n @if (previewLine()) {\n <line\n class=\"preview-line\"\n [attr.x1]=\"previewLine()!.source.x\"\n [attr.y1]=\"previewLine()!.source.y\"\n [attr.x2]=\"previewLine()!.target.x\"\n [attr.y2]=\"previewLine()!.target.y\"\n stroke=\"#3b82f6\"\n stroke-width=\"2.5\"\n stroke-dasharray=\"8,6\"\n stroke-linecap=\"round\"\n opacity=\"0.7\"\n />\n }\n\n <!-- Layer 1: Edge paths (behind everything) -->\n @for (edge of internalGraph().edges; track edge.id) {\n <!-- Edge shadow for depth (optional) -->\n @if (shadowsEnabled()) {\n <path\n class=\"edge-shadow\"\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"rgba(0,0,0,0.06)\"\n stroke-width=\"6\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [attr.transform]=\"'translate(1, 2)'\"\n />\n }\n <!-- Invisible wide hit-area for easier clicking (hand tool only) -->\n <path\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"transparent\"\n [attr.stroke-width]=\"20\"\n fill=\"none\"\n class=\"edge-hit-area\"\n [attr.pointer-events]=\"activeTool() === 'hand' ? 'stroke' : 'none'\"\n (click)=\"onEdgeClick($event, edge)\"\n (dblclick)=\"onEdgeDoubleClick($event, edge)\"\n />\n <!-- Visible edge line -->\n <path\n class=\"edge-line\"\n [attr.d]=\"getEdgePath(edge)\"\n [attr.stroke]=\"selection().edges.includes(edge.id) ? '#3b82f6' : '#94a3b8'\"\n [attr.stroke-width]=\"selection().edges.includes(edge.id) ? 2.5 : 2\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [class.selected]=\"selection().edges.includes(edge.id)\"\n [attr.marker-end]=\"getEdgeMarkerEnd(edge)\"\n [attr.marker-start]=\"getEdgeMarkerStart(edge)\"\n pointer-events=\"none\"\n />\n }\n\n <!-- Layer 2: Nodes -->\n @for (node of internalGraph().nodes; track node.id) {\n <g\n [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\"\n class=\"graph-node\"\n [class.selected]=\"selection().nodes.includes(node.id)\"\n [attr.data-node-id]=\"node.id\"\n (mousedown)=\"onNodeMouseDown($event, node)\"\n (click)=\"onNodeClick($event, node)\"\n (dblclick)=\"nodeDoubleClick.emit(node)\"\n >\n <!-- Node shadow (optional) -->\n @if (shadowsEnabled()) {\n <rect\n class=\"node-shadow\"\n [attr.width]=\"getNodeSize(node).width\"\n [attr.height]=\"getNodeSize(node).height\"\n fill=\"rgba(0,0,0,0.08)\"\n rx=\"12\"\n transform=\"translate(2, 3)\"\n style=\"filter: blur(4px);\"\n />\n }\n <!-- Node background -->\n <rect\n class=\"node-bg\"\n [attr.width]=\"getNodeSize(node).width\"\n [attr.height]=\"getNodeSize(node).height\"\n fill=\"white\"\n [attr.stroke]=\"selection().nodes.includes(node.id) ? '#3b82f6' : '#e2e8f0'\"\n [attr.stroke-width]=\"selection().nodes.includes(node.id) ? 2.5 : 1.5\"\n rx=\"12\"\n />\n\n <!-- Node type icon (text/emoji) or custom image -->\n @if (getNodeImage(node)) {\n <image\n class=\"node-image\"\n [attr.href]=\"getNodeImage(node)\"\n [attr.xlink:href]=\"getNodeImage(node)\"\n [attr.x]=\"getImagePosition(node).x\"\n [attr.y]=\"getImagePosition(node).y\"\n [attr.width]=\"getImageSize(node)\"\n [attr.height]=\"getImageSize(node)\"\n preserveAspectRatio=\"xMidYMid meet\"\n />\n } @else {\n <text\n class=\"node-icon\"\n [attr.x]=\"getIconPosition(node).x\"\n [attr.y]=\"getIconPosition(node).y\"\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n [attr.font-size]=\"getNodeSize(node).height * 0.28\"\n >\n {{ getNodeTypeIcon(node) }}\n </text>\n }\n\n <!-- Node label (wrapped and auto-sized) -->\n <text\n class=\"node-label\"\n [attr.x]=\"getLabelLineX(node)\"\n text-anchor=\"middle\"\n [attr.font-size]=\"getWrappedLabel(node).fontSize\"\n font-weight=\"500\"\n fill=\"#1e293b\"\n >\n @for (line of getWrappedLabel(node).lines; track $index) {\n <tspan\n [attr.x]=\"getLabelLineX(node)\"\n [attr.y]=\"getLabelLineY(node, $index, getWrappedLabel(node).lines.length, getWrappedLabel(node).lineHeight)\"\n >{{ line }}</tspan>\n }\n </text>\n\n <!-- Resize handle (SE corner, visible when selected + hand tool) -->\n @if (selection().nodes.includes(node.id) && activeTool() === 'hand' && selection().nodes.length === 1) {\n <rect\n class=\"resize-handle resize-handle-se\"\n [attr.x]=\"getNodeSize(node).width - 8\"\n [attr.y]=\"getNodeSize(node).height - 8\"\n width=\"10\"\n height=\"10\"\n fill=\"#3b82f6\"\n rx=\"2\"\n cursor=\"se-resize\"\n (mousedown)=\"onResizeHandleMouseDown($event, node)\"\n />\n }\n </g>\n }\n\n <!-- Layer 3: Attachment points (on top of nodes) -->\n @for (node of internalGraph().nodes; track node.id) {\n @if (showAttachmentPoints() === node.id) {\n <g [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\">\n @for (port of getNodePorts(node); track port.position) {\n <circle\n [attr.cx]=\"port.x\"\n [attr.cy]=\"port.y\"\n [attr.r]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? 8 : 6\"\n [attr.fill]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? '#2563eb' : '#94a3b8'\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"attachment-point\"\n [class.hovered]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position\"\n (mousedown)=\"$event.stopPropagation()\"\n (click)=\"onAttachmentPointClick($event, node, port.position)\"\n />\n }\n </g>\n }\n }\n\n <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->\n @for (edge of internalGraph().edges; track edge.id) {\n @if (selection().edges.includes(edge.id)) {\n <g>\n <!-- Source endpoint -->\n <circle\n [attr.cx]=\"getEdgeSourcePoint(edge).x\"\n [attr.cy]=\"getEdgeSourcePoint(edge).y\"\n r=\"6\"\n fill=\"#3b82f6\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'source')\"\n />\n\n <!-- Target endpoint -->\n <circle\n [attr.cx]=\"getEdgeTargetPoint(edge).x\"\n [attr.cy]=\"getEdgeTargetPoint(edge).y\"\n r=\"6\"\n fill=\"#3b82f6\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'target')\"\n />\n </g>\n }\n }\n\n <!-- Layer 5: Selection box (Shift+drag) -->\n @if (selectionBox()) {\n <rect\n class=\"selection-box\"\n [attr.x]=\"selectionBox()!.x\"\n [attr.y]=\"selectionBox()!.y\"\n [attr.width]=\"selectionBox()!.width\"\n [attr.height]=\"selectionBox()!.height\"\n fill=\"rgba(59, 130, 246, 0.1)\"\n stroke=\"#3b82f6\"\n stroke-width=\"1\"\n stroke-dasharray=\"4,2\"\n />\n }\n </g>\n </svg>\n </div>\n\n <!-- Edge direction selector overlay -->\n @if (selectedEdgeMidpoint()) {\n <div\n class=\"edge-direction-selector\"\n [style.left.px]=\"selectedEdgeMidpoint()!.x\"\n [style.top.px]=\"selectedEdgeMidpoint()!.y\"\n >\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'backward'\"\n title=\"Backward\"\n (click)=\"setEdgeDirection('backward')\"\n >\u2190</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'bidirectional'\"\n title=\"Bidirectional\"\n (click)=\"setEdgeDirection('bidirectional')\"\n >\u2194</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'\"\n title=\"Forward\"\n (click)=\"setEdgeDirection('forward')\"\n >\u2192</button>\n </div>\n }\n</div>\n", styles: [".graph-editor-container{display:flex;width:100%;height:100%;position:relative;background:var(--graph-editor-canvas-bg, #f8f9fa)}.graph-palette-overlay{position:absolute;top:16px;left:16px;display:flex;gap:4px;z-index:10;background:#fffffff2;padding:6px;border-radius:var(--radius-md, 8px);box-shadow:0 2px 8px #0000001a;backdrop-filter:blur(4px)}.palette-item{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;padding:0;border:1.5px solid var(--neutral-200, #e5e7eb);border-radius:var(--radius-md, 8px);background:var(--white, #fff);color:var(--neutral-600, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:20px}.palette-icon-img{width:24px;height:24px;object-fit:contain}.palette-icon-svg{width:22px;height:22px;flex-shrink:0}.palette-item:focus-visible{outline:2px solid var(--indigo-400, #818cf8);outline-offset:2px}.palette-item:hover{background:var(--neutral-50, #f9fafb);border-color:var(--interactive, #3b82f6);color:var(--interactive, #3b82f6);transform:translateY(-1px);box-shadow:0 2px 6px #00000014}.palette-item:active{transform:translateY(0);box-shadow:none}.palette-item.tool-item.active{background:var(--interactive, #3b82f6);border-color:var(--interactive, #3b82f6);color:#fff}.palette-item.tool-item.active:hover{background:var(--interactive-hover, #2563eb);border-color:var(--interactive-hover, #2563eb);color:#fff}.palette-divider{width:1px;background:var(--neutral-200, #e5e7eb);align-self:stretch;margin:4px 2px}.graph-canvas-wrapper{flex:1;position:relative;overflow:hidden}.graph-canvas{width:100%;height:100%;cursor:grab}.graph-canvas:active{cursor:grabbing}.graph-canvas.tool-line,.graph-canvas.tool-line .graph-node{cursor:crosshair}.graph-node{cursor:move;user-select:none;-webkit-user-select:none;transition:transform .1s ease-out}.graph-node:hover .node-bg{stroke:#cbd5e1}.graph-node text{pointer-events:none}.graph-node .node-label{font-family:system-ui,-apple-system,sans-serif}.graph-node.selected .node-bg{stroke:#3b82f6;filter:drop-shadow(0 4px 12px rgba(59,130,246,.25))}.edge-line{transition:stroke .15s,stroke-width .15s}.edge-line.selected{filter:drop-shadow(0 2px 4px rgba(59,130,246,.3))}.edge-hit-area{cursor:pointer}.edge-endpoint{cursor:pointer;transition:r .2s,fill .2s}.edge-endpoint:hover{r:8;fill:#2563eb}.edge-endpoint.selected{fill:#2563eb}.attachment-point{cursor:crosshair;transition:all .2s}.attachment-point.hovered{filter:drop-shadow(0 0 4px rgba(37,99,235,.6))}.validation-panel{position:absolute;bottom:0;left:0;right:0;max-height:200px;overflow-y:auto;background:#fff;border-top:1px solid #e5e7eb;padding:16px}.error-item{padding:8px 12px;margin-bottom:8px;background:#fee2e2;border-left:3px solid #ef4444;border-radius:4px;font-size:14px}.error-item.warning{background:#fef3c7;border-left-color:#f59e0b}.edge-direction-selector{position:absolute;transform:translate(-50%,-100%);margin-top:-12px;display:flex;gap:2px;background:#fffffff2;padding:4px;border-radius:6px;box-shadow:0 2px 8px #00000026;backdrop-filter:blur(4px);z-index:20;pointer-events:auto}.direction-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid #e5e7eb;border-radius:4px;background:#fff;cursor:pointer;font-size:16px;transition:all .15s;color:#6b7280}.direction-btn:hover{background:#f3f4f6;border-color:#3b82f6;color:#3b82f6}.direction-btn.active{background:#3b82f6;border-color:#3b82f6;color:#fff}.resize-handle{cursor:se-resize;opacity:.8;transition:opacity .15s,fill .15s}.resize-handle:hover{opacity:1;fill:#2563eb}\n"] }]
|
|
2053
|
+
}, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"graph-editor-container\">\n <!-- Canvas with overlaid palette -->\n <div class=\"graph-canvas-wrapper\">\n <!-- Top horizontal toolbar -->\n @if (config.palette?.enabled !== false) {\n <div class=\"graph-toolbar-top\">\n <!-- Tools -->\n <button\n class=\"toolbar-btn\"\n [class.active]=\"activeTool() === 'hand'\"\n title=\"Hand tool (move nodes)\"\n (click)=\"switchTool('hand')\"\n >\n <span class=\"icon\">\u270B</span>\n </button>\n <button\n class=\"toolbar-btn\"\n [class.active]=\"activeTool() === 'line'\"\n title=\"Line tool (draw connections)\"\n (click)=\"switchTool('line')\"\n >\n <span class=\"icon\">\u2215</span>\n </button>\n\n <div class=\"toolbar-divider\"></div>\n\n <!-- Zoom -->\n <button\n class=\"toolbar-btn\"\n title=\"Zoom in\"\n (click)=\"zoomIn()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"/>\n <path d=\"M21 21l-4.35-4.35M11 8v6M8 11h6\"/>\n </svg>\n </button>\n <button\n class=\"toolbar-btn\"\n title=\"Zoom out\"\n (click)=\"zoomOut()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"/>\n <path d=\"M21 21l-4.35-4.35M8 11h6\"/>\n </svg>\n </button>\n\n <div class=\"toolbar-divider\"></div>\n\n <!-- Actions -->\n <button\n class=\"toolbar-btn\"\n title=\"Auto layout\"\n (click)=\"applyLayout()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"/>\n <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1\"/>\n <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"/>\n <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1\"/>\n </svg>\n </button>\n <button\n class=\"toolbar-btn\"\n title=\"Fit to screen\"\n (click)=\"fitToScreen()\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"/>\n </svg>\n </button>\n </div>\n\n <!-- Left vertical palette (node types) - supports multiple columns -->\n <div class=\"graph-palette-container\">\n @for (column of getPaletteColumns(); track $index) {\n <div class=\"graph-palette-column\">\n @for (nodeType of column; track nodeType.type) {\n <button\n class=\"palette-item\"\n [attr.data-node-type]=\"nodeType.type\"\n [attr.title]=\"nodeType.label || nodeType.type\"\n (click)=\"addNode(nodeType.type)\"\n >\n @if (nodeType.iconSvg) {\n <svg\n class=\"palette-icon-svg\"\n [attr.viewBox]=\"nodeType.iconSvg.viewBox || '0 0 24 24'\"\n [attr.fill]=\"nodeType.iconSvg.fill || 'none'\"\n [attr.stroke]=\"nodeType.iconSvg.stroke || '#1D6A96'\"\n [attr.stroke-width]=\"nodeType.iconSvg.strokeWidth || 2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n @for (pathData of splitIconPaths(nodeType.iconSvg.path); track $index) {\n <path [attr.d]=\"pathData\"/>\n }\n </svg>\n } @else if (nodeType.defaultData['imageUrl']) {\n <img\n class=\"palette-icon-img\"\n [src]=\"nodeType.defaultData['imageUrl']\"\n [alt]=\"nodeType.label || nodeType.type\"\n />\n } @else {\n <span class=\"icon\">{{ nodeType.icon || '\u25CF' }}</span>\n }\n </button>\n }\n </div>\n }\n </div>\n }\n\n <svg\n #canvasSvg\n [class.tool-line]=\"activeTool() === 'line'\"\n [attr.width]=\"'100%'\"\n [attr.height]=\"'100%'\"\n (mousedown)=\"onCanvasMouseDown($event)\"\n (mousemove)=\"onCanvasMouseMove($event)\"\n (mouseup)=\"onCanvasMouseUp($event)\"\n (wheel)=\"onWheel($event)\"\n (contextmenu)=\"onContextMenu($event)\"\n >\n <!-- Arrow marker definitions -->\n <defs>\n <marker id=\"arrow-end\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" [attr.fill]=\"resolvedTheme.edge.markerColor\"/>\n </marker>\n <marker id=\"arrow-end-selected\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 0 1 L 8 5 L 0 9 z\" [attr.fill]=\"resolvedTheme.edge.selectedMarkerColor\"/>\n </marker>\n <marker id=\"arrow-start\" viewBox=\"0 0 10 10\" refX=\"2\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" [attr.fill]=\"resolvedTheme.edge.markerColor\"/>\n </marker>\n <marker id=\"arrow-start-selected\" viewBox=\"0 0 10 10\" refX=\"2\" refY=\"5\"\n markerWidth=\"8\" markerHeight=\"8\" orient=\"auto\">\n <path d=\"M 10 1 L 2 5 L 10 9 z\" [attr.fill]=\"resolvedTheme.edge.selectedMarkerColor\"/>\n </marker>\n </defs>\n\n <!-- Main transform group (pan + zoom) -->\n <g [attr.transform]=\"transform()\">\n <!-- Grid (if enabled) -->\n <!-- Grid (if enabled) - extended to cover viewport during pan -->\n @if (config.canvas?.grid?.enabled) {\n <defs>\n @if (resolvedTheme.canvas.gridType === 'dot') {\n <pattern id=\"grid\" [attr.width]=\"config.canvas!.grid!.size\" [attr.height]=\"config.canvas!.grid!.size\" patternUnits=\"userSpaceOnUse\">\n <circle [attr.cx]=\"config.canvas!.grid!.size / 2\" [attr.cy]=\"config.canvas!.grid!.size / 2\" r=\"1\" [attr.fill]=\"resolvedTheme.canvas.gridColor\" />\n </pattern>\n } @else {\n <pattern id=\"grid\" [attr.width]=\"config.canvas!.grid!.size\" [attr.height]=\"config.canvas!.grid!.size\" patternUnits=\"userSpaceOnUse\">\n <path [attr.d]=\"'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size\" fill=\"none\" [attr.stroke]=\"resolvedTheme.canvas.gridColor\" stroke-width=\"1\" />\n </pattern>\n }\n </defs>\n <rect [attr.x]=\"gridBounds().x\" [attr.y]=\"gridBounds().y\" [attr.width]=\"gridBounds().width\" [attr.height]=\"gridBounds().height\" fill=\"url(#grid)\" />\n }\n\n <!-- Layer 0.5: Preview line for line tool (rubber-band) -->\n @if (previewLine()) {\n <line\n class=\"preview-line\"\n [attr.x1]=\"previewLine()!.source.x\"\n [attr.y1]=\"previewLine()!.source.y\"\n [attr.x2]=\"previewLine()!.target.x\"\n [attr.y2]=\"previewLine()!.target.y\"\n [attr.stroke]=\"resolvedTheme.selection.color\"\n stroke-width=\"2.5\"\n stroke-dasharray=\"8,6\"\n stroke-linecap=\"round\"\n opacity=\"0.7\"\n />\n }\n\n <!-- Layer 1: Edge paths (behind everything) -->\n @for (edge of internalGraph().edges; track edge.id) {\n <!-- Edge shadow for depth (optional) -->\n @if (shadowsEnabled()) {\n <path\n class=\"edge-shadow\"\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"rgba(0,0,0,0.06)\"\n stroke-width=\"6\"\n fill=\"none\"\n stroke-linecap=\"round\"\n [attr.transform]=\"'translate(1, 2)'\"\n />\n }\n <!-- Invisible wide hit-area for easier clicking (hand tool only) -->\n <path\n [attr.d]=\"getEdgePath(edge)\"\n stroke=\"transparent\"\n [attr.stroke-width]=\"20\"\n fill=\"none\"\n class=\"edge-hit-area\"\n [attr.pointer-events]=\"activeTool() === 'hand' ? 'stroke' : 'none'\"\n (click)=\"onEdgeClick($event, edge)\"\n (dblclick)=\"onEdgeDoubleClick($event, edge)\"\n />\n <!-- Visible edge line -->\n @if (edgeTemplate()?.templateRef) {\n <ng-container [ngTemplateOutlet]=\"edgeTemplate()!.templateRef\" [ngTemplateOutletContext]=\"getEdgeTemplateContext(edge)\" />\n } @else {\n <path class=\"edge-line\" [attr.d]=\"getEdgePath(edge)\"\n [attr.stroke]=\"selection().edges.includes(edge.id) ? resolvedTheme.edge.selectedStroke : resolvedTheme.edge.stroke\"\n [attr.stroke-width]=\"selection().edges.includes(edge.id) ? resolvedTheme.edge.selectedStrokeWidth : resolvedTheme.edge.strokeWidth\"\n fill=\"none\" stroke-linecap=\"round\" [class.selected]=\"selection().edges.includes(edge.id)\"\n [attr.marker-end]=\"getEdgeMarkerEnd(edge)\" [attr.marker-start]=\"getEdgeMarkerStart(edge)\"\n pointer-events=\"none\" />\n }\n }\n\n <!-- Layer 2: Nodes -->\n @for (node of internalGraph().nodes; track node.id) {\n <g\n [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\"\n class=\"graph-node\"\n [class.selected]=\"selection().nodes.includes(node.id)\"\n [attr.data-node-id]=\"node.id\"\n (mousedown)=\"onNodeMouseDown($event, node)\"\n (click)=\"onNodeClick($event, node)\"\n (dblclick)=\"nodeDoubleClick.emit(node)\"\n >\n <!-- Priority: nodeHtmlTemplate > nodeSvgTemplate > component > default -->\n @if (nodeHtmlTemplate()?.templateRef) {\n <foreignObject [attr.width]=\"getNodeSize(node).width\" [attr.height]=\"getNodeSize(node).height\">\n <ng-container [ngTemplateOutlet]=\"nodeHtmlTemplate()!.templateRef\" [ngTemplateOutletContext]=\"getNodeTemplateContext(node)\" />\n </foreignObject>\n } @else if (nodeSvgTemplate()?.templateRef) {\n <ng-container [ngTemplateOutlet]=\"nodeSvgTemplate()!.templateRef\" [ngTemplateOutletContext]=\"getNodeTemplateContext(node)\" />\n } @else if (getNodeComponent(node)) {\n <foreignObject [attr.width]=\"getNodeSize(node).width\" [attr.height]=\"getNodeSize(node).height\">\n <ng-container *ngComponentOutlet=\"getNodeComponent(node)!; inputs: getNodeComponentInputs(node)\" />\n </foreignObject>\n } @else {\n <!-- Default built-in rendering (same as before but with resolvedTheme) -->\n @if (shadowsEnabled()) {\n <rect class=\"node-shadow\" [attr.width]=\"getNodeSize(node).width\" [attr.height]=\"getNodeSize(node).height\"\n [attr.fill]=\"resolvedTheme.node.shadowColor\" [attr.rx]=\"resolvedTheme.node.borderRadius\"\n transform=\"translate(2, 3)\" style=\"filter: blur(4px);\" />\n }\n <rect class=\"node-bg\" [attr.width]=\"getNodeSize(node).width\" [attr.height]=\"getNodeSize(node).height\"\n [attr.fill]=\"resolvedTheme.node.background\"\n [attr.stroke]=\"selection().nodes.includes(node.id) ? resolvedTheme.node.selectedBorderColor : resolvedTheme.node.borderColor\"\n [attr.stroke-width]=\"selection().nodes.includes(node.id) ? resolvedTheme.node.selectedBorderWidth : resolvedTheme.node.borderWidth\"\n [attr.rx]=\"resolvedTheme.node.borderRadius\" />\n @if (getNodeImage(node)) {\n <image class=\"node-image\" [attr.href]=\"getNodeImage(node)\" [attr.xlink:href]=\"getNodeImage(node)\"\n [attr.x]=\"getImagePosition(node).x\" [attr.y]=\"getImagePosition(node).y\"\n [attr.width]=\"getImageSize(node)\" [attr.height]=\"getImageSize(node)\"\n preserveAspectRatio=\"xMidYMid meet\" />\n } @else {\n <text class=\"node-icon\" [attr.x]=\"getIconPosition(node).x\" [attr.y]=\"getIconPosition(node).y\"\n text-anchor=\"middle\" dominant-baseline=\"middle\" [attr.font-size]=\"getNodeSize(node).height * 0.28\">\n {{ getNodeTypeIcon(node) }}\n </text>\n }\n <text class=\"node-label\" [attr.x]=\"getLabelLineX(node)\" text-anchor=\"middle\"\n [attr.font-size]=\"getWrappedLabel(node).fontSize\" font-weight=\"500\" [attr.fill]=\"resolvedTheme.node.labelColor\">\n @for (line of getWrappedLabel(node).lines; track $index) {\n <tspan [attr.x]=\"getLabelLineX(node)\"\n [attr.y]=\"getLabelLineY(node, $index, getWrappedLabel(node).lines.length, getWrappedLabel(node).lineHeight)\">{{ line }}</tspan>\n }\n </text>\n @if (selection().nodes.includes(node.id) && activeTool() === 'hand' && selection().nodes.length === 1) {\n <rect class=\"resize-handle resize-handle-se\" [attr.x]=\"getNodeSize(node).width - 8\" [attr.y]=\"getNodeSize(node).height - 8\"\n width=\"10\" height=\"10\" [attr.fill]=\"resolvedTheme.selection.color\" rx=\"2\" cursor=\"se-resize\"\n (mousedown)=\"onResizeHandleMouseDown($event, node)\" />\n }\n }\n </g>\n }\n\n <!-- Layer 3: Attachment points (on top of nodes) -->\n @for (node of internalGraph().nodes; track node.id) {\n @if (showAttachmentPoints() === node.id) {\n <g [attr.transform]=\"'translate(' + node.position.x + ',' + node.position.y + ')'\">\n @for (port of getNodePorts(node); track port.position) {\n <circle\n [attr.cx]=\"port.x\"\n [attr.cy]=\"port.y\"\n [attr.r]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? resolvedTheme.port.hoverRadius : resolvedTheme.port.radius\"\n [attr.fill]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? resolvedTheme.port.hoverFill : resolvedTheme.port.fill\"\n [attr.stroke]=\"resolvedTheme.port.stroke\"\n [attr.stroke-width]=\"resolvedTheme.port.strokeWidth\"\n class=\"attachment-point\"\n [class.hovered]=\"hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position\"\n (mousedown)=\"$event.stopPropagation()\"\n (click)=\"onAttachmentPointClick($event, node, port.position)\"\n />\n }\n </g>\n }\n }\n\n <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->\n @for (edge of internalGraph().edges; track edge.id) {\n @if (selection().edges.includes(edge.id)) {\n <g>\n <!-- Source endpoint -->\n <circle\n [attr.cx]=\"getEdgeSourcePoint(edge).x\"\n [attr.cy]=\"getEdgeSourcePoint(edge).y\"\n r=\"6\"\n [attr.fill]=\"resolvedTheme.selection.color\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'source')\"\n />\n\n <!-- Target endpoint -->\n <circle\n [attr.cx]=\"getEdgeTargetPoint(edge).x\"\n [attr.cy]=\"getEdgeTargetPoint(edge).y\"\n r=\"6\"\n [attr.fill]=\"resolvedTheme.selection.color\"\n stroke=\"white\"\n stroke-width=\"2\"\n class=\"edge-endpoint selected\"\n (mousedown)=\"onEdgeEndpointMouseDown($event, edge, 'target')\"\n />\n </g>\n }\n }\n\n <!-- Layer 5: Selection box (Shift+drag) -->\n @if (selectionBox()) {\n <rect\n class=\"selection-box\"\n [attr.x]=\"selectionBox()!.x\"\n [attr.y]=\"selectionBox()!.y\"\n [attr.width]=\"selectionBox()!.width\"\n [attr.height]=\"selectionBox()!.height\"\n [attr.fill]=\"resolvedTheme.selection.boxFill\"\n [attr.stroke]=\"resolvedTheme.selection.boxStroke\"\n stroke-width=\"1\"\n stroke-dasharray=\"4,2\"\n />\n }\n </g>\n </svg>\n </div>\n\n <!-- Edge direction selector overlay -->\n @if (selectedEdgeMidpoint()) {\n <div\n class=\"edge-direction-selector\"\n [style.left.px]=\"selectedEdgeMidpoint()!.x\"\n [style.top.px]=\"selectedEdgeMidpoint()!.y\"\n >\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'backward'\"\n title=\"Backward\"\n (click)=\"setEdgeDirection('backward')\"\n >\u2190</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"selectedEdgeMidpoint()!.edge.direction === 'bidirectional'\"\n title=\"Bidirectional\"\n (click)=\"setEdgeDirection('bidirectional')\"\n >\u2194</button>\n <button\n class=\"direction-btn\"\n [class.active]=\"!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'\"\n title=\"Forward\"\n (click)=\"setEdgeDirection('forward')\"\n >\u2192</button>\n </div>\n }\n</div>\n", styles: [".graph-editor-container{display:flex;width:100%;height:100%;position:relative;background:var(--graph-editor-canvas-bg, #f8f9fa)}.graph-toolbar-top{position:absolute;top:12px;left:12px;display:flex;gap:4px;z-index:10;background:var(--ge-toolbar-bg, rgba(255, 255, 255, .95));padding:6px;border-radius:var(--ge-toolbar-radius, 8px);box-shadow:var(--ge-toolbar-shadow, 0 2px 8px rgba(0, 0, 0, .1));backdrop-filter:blur(4px)}.toolbar-btn{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1.5px solid var(--ge-toolbar-btn-border, #e5e7eb);border-radius:var(--ge-toolbar-radius, 8px);background:var(--ge-toolbar-btn-bg, #fff);color:var(--ge-toolbar-btn-color, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:18px}.toolbar-btn:focus-visible{outline:2px solid var(--indigo-400, #818cf8);outline-offset:2px}.toolbar-btn:hover{background:var(--ge-toolbar-btn-hover-bg, #f9fafb);border-color:var(--ge-toolbar-btn-hover-accent, #3b82f6);color:var(--ge-toolbar-btn-hover-accent, #3b82f6)}.toolbar-btn:active{transform:translateY(0);box-shadow:none}.toolbar-btn.active{background:var(--ge-toolbar-btn-active-bg, #3b82f6);border-color:var(--ge-toolbar-btn-active-bg, #3b82f6);color:var(--ge-toolbar-btn-active-color, white)}.toolbar-btn.active:hover{background:color-mix(in srgb,var(--ge-toolbar-btn-active-bg, #3b82f6) 85%,black);border-color:color-mix(in srgb,var(--ge-toolbar-btn-active-bg, #3b82f6) 85%,black);color:var(--ge-toolbar-btn-active-color, white)}.toolbar-divider{width:1px;background:var(--ge-toolbar-divider, #e5e7eb);align-self:stretch;margin:4px 2px}.graph-palette-container{position:absolute;top:72px;left:12px;display:flex;flex-direction:row;gap:8px;z-index:10}.graph-palette-column{display:flex;flex-direction:column;gap:4px;background:var(--ge-toolbar-bg, rgba(255, 255, 255, .95));padding:6px;border-radius:var(--ge-toolbar-radius, 8px);box-shadow:var(--ge-toolbar-shadow, 0 2px 8px rgba(0, 0, 0, .1));backdrop-filter:blur(4px)}.palette-item{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border:1.5px solid var(--ge-toolbar-btn-border, #e5e7eb);border-radius:var(--ge-toolbar-radius, 8px);background:var(--ge-toolbar-btn-bg, #fff);color:var(--ge-toolbar-btn-color, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:18px}.graph-canvas-wrapper{flex:1;position:relative;overflow:hidden}.graph-canvas{width:100%;height:100%;cursor:grab}.graph-canvas:active{cursor:grabbing}.graph-canvas.tool-line,.graph-canvas.tool-line .graph-node{cursor:crosshair}.graph-node{cursor:move;user-select:none;-webkit-user-select:none;transition:transform .1s ease-out}.graph-node:hover .node-bg{stroke:var(--ge-node-border, #cbd5e1)}.graph-node text{pointer-events:none}.graph-node .node-label{font-family:var(--ge-font-family, system-ui, -apple-system, sans-serif)}.graph-node.selected .node-bg{stroke:var(--ge-selection-color, #3b82f6);filter:drop-shadow(0 4px 12px rgba(59,130,246,.25))}.edge-line{transition:stroke .15s,stroke-width .15s}.edge-line.selected{filter:drop-shadow(0 2px 4px rgba(59,130,246,.3))}.edge-hit-area{cursor:pointer}.edge-endpoint{cursor:pointer;transition:r .2s,fill .2s}.edge-endpoint:hover{r:8;fill:var(--ge-selection-color, #3b82f6)}.edge-endpoint.selected{fill:var(--ge-selection-color, #3b82f6)}.attachment-point{cursor:crosshair;transition:all .2s}.attachment-point.hovered{filter:drop-shadow(0 0 4px rgba(37,99,235,.6))}.validation-panel{position:absolute;bottom:0;left:0;right:0;max-height:200px;overflow-y:auto;background:#fff;border-top:1px solid #e5e7eb;padding:16px}.error-item{padding:8px 12px;margin-bottom:8px;background:#fee2e2;border-left:3px solid #ef4444;border-radius:4px;font-size:14px}.error-item.warning{background:#fef3c7;border-left-color:#f59e0b}.edge-direction-selector{position:absolute;transform:translate(-50%,-100%);margin-top:-12px;display:flex;gap:2px;background:var(--ge-toolbar-bg, rgba(255, 255, 255, .95));padding:4px;border-radius:calc(var(--ge-toolbar-radius, 8px) * .75);box-shadow:var(--ge-toolbar-shadow, 0 2px 8px rgba(0, 0, 0, .15));backdrop-filter:blur(4px);z-index:20;pointer-events:auto}.direction-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid var(--ge-toolbar-btn-border, #e5e7eb);border-radius:calc(var(--ge-toolbar-radius, 8px) * .5);background:var(--ge-toolbar-btn-bg, white);cursor:pointer;font-size:16px;transition:all .15s;color:var(--ge-toolbar-btn-color, #6b7280)}.direction-btn:hover{background:var(--ge-toolbar-btn-hover-bg, #f3f4f6);border-color:var(--ge-toolbar-btn-hover-accent, #3b82f6);color:var(--ge-toolbar-btn-hover-accent, #3b82f6)}.direction-btn.active{background:var(--ge-toolbar-btn-active-bg, #3b82f6);border-color:var(--ge-toolbar-btn-active-bg, #3b82f6);color:var(--ge-toolbar-btn-active-color, white)}.resize-handle{cursor:se-resize;opacity:.8;transition:opacity .15s,fill .15s}.resize-handle:hover{opacity:1;fill:var(--ge-selection-color, #3b82f6)}\n"] }]
|
|
1698
2054
|
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
1699
2055
|
type: Input,
|
|
1700
2056
|
args: [{ required: true }]
|
|
@@ -1762,5 +2118,5 @@ function iconToDataUrl(icon, size = 48) {
|
|
|
1762
2118
|
* Generated bundle index. Do not edit.
|
|
1763
2119
|
*/
|
|
1764
2120
|
|
|
1765
|
-
export { GraphEditorComponent, iconToDataUrl, renderIconSvg };
|
|
2121
|
+
export { EdgeTemplateDirective, GraphEditorComponent, NodeHtmlTemplateDirective, NodeSvgTemplateDirective, iconToDataUrl, renderIconSvg };
|
|
1766
2122
|
//# sourceMappingURL=utisha-graph-editor.mjs.map
|