@ghchinoy/litflow 0.4.0 → 0.5.0
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/CHANGELOG.md +20 -20
- package/dist/index.d.ts +1 -0
- package/dist/lit-flow.d.ts +9 -1
- package/dist/lit-node-toolbar.d.ts +5 -0
- package/dist/lit-node.d.ts +4 -0
- package/dist/litflow.js +3281 -3080
- package/dist/litflow.js.map +1 -1
- package/package.json +5 -1
- package/src/index.ts +1 -0
- package/src/lit-flow.ts +323 -49
- package/src/lit-node-toolbar.ts +38 -0
- package/src/lit-node.ts +40 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ghchinoy/litflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "xyflow core integrated with Lit WebComponents",
|
|
5
5
|
"homepage": "https://litflow.dev",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"@lit-labs/signals": "^0.2.0",
|
|
52
52
|
"@xyflow/system": "^0.0.74",
|
|
53
53
|
"d3-drag": "^3.0.0",
|
|
54
|
+
"d3-force": "^3.0.0",
|
|
55
|
+
"d3-hierarchy": "^3.1.2",
|
|
54
56
|
"d3-interpolate": "^3.0.1",
|
|
55
57
|
"d3-selection": "^3.0.0",
|
|
56
58
|
"d3-zoom": "^3.0.0",
|
|
@@ -64,6 +66,8 @@
|
|
|
64
66
|
"devDependencies": {
|
|
65
67
|
"@open-wc/testing": "^4.0.0",
|
|
66
68
|
"@types/d3-drag": "^3.0.7",
|
|
69
|
+
"@types/d3-force": "^3.0.10",
|
|
70
|
+
"@types/d3-hierarchy": "^3.1.7",
|
|
67
71
|
"@types/d3-interpolate": "^3.0.4",
|
|
68
72
|
"@types/d3-selection": "^3.0.11",
|
|
69
73
|
"@types/d3-zoom": "^3.0.8",
|
package/src/index.ts
CHANGED
package/src/lit-flow.ts
CHANGED
|
@@ -25,6 +25,8 @@ import { m3Tokens } from './theme';
|
|
|
25
25
|
import './lit-node';
|
|
26
26
|
import './lit-edge';
|
|
27
27
|
import dagre from 'dagre';
|
|
28
|
+
import * as d3 from 'd3-force';
|
|
29
|
+
import * as d3h from 'd3-hierarchy';
|
|
28
30
|
|
|
29
31
|
type Constructor<T> = new (...args: any[]) => T;
|
|
30
32
|
|
|
@@ -108,7 +110,12 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
108
110
|
box-sizing: border-box;
|
|
109
111
|
color: var(--lit-flow-node-text);
|
|
110
112
|
font-size: var(--md-sys-typescale-body-medium-size);
|
|
111
|
-
transition: box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out, border-width 0.1s ease-in-out;
|
|
113
|
+
transition: transform 0.4s ease-out, opacity 0.4s ease-in, box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out, border-width 0.1s ease-in-out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.xyflow__node:active,
|
|
117
|
+
.xyflow__node.dragging {
|
|
118
|
+
transition: none !important;
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
.xyflow__node[type="group"] {
|
|
@@ -299,6 +306,24 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
299
306
|
@property({ type: Number, attribute: 'layout-padding' })
|
|
300
307
|
layoutPadding = 40;
|
|
301
308
|
|
|
309
|
+
@property({ type: String, attribute: 'layout-strategy' })
|
|
310
|
+
layoutStrategy: 'hierarchical' | 'organic' | 'tree' = 'hierarchical';
|
|
311
|
+
|
|
312
|
+
@property({ type: String, attribute: 'layout-direction' })
|
|
313
|
+
layoutDirection: 'LR' | 'TB' = 'LR';
|
|
314
|
+
|
|
315
|
+
@property({ type: Boolean, attribute: 'auto-fit', reflect: true, converter: boolConverter })
|
|
316
|
+
autoFit = false;
|
|
317
|
+
|
|
318
|
+
@property({ type: String, attribute: 'focus-node' })
|
|
319
|
+
focusNode: string | null = null;
|
|
320
|
+
|
|
321
|
+
@property({ type: String, attribute: 'focus-direction' })
|
|
322
|
+
focusDirection: 'downstream' | 'upstream' | 'both' = 'downstream';
|
|
323
|
+
|
|
324
|
+
@state()
|
|
325
|
+
private _isMeasuring = false;
|
|
326
|
+
|
|
302
327
|
@state()
|
|
303
328
|
private _selectionRect: { x: number; y: number; width: number; height: number } | null = null;
|
|
304
329
|
|
|
@@ -312,16 +337,24 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
312
337
|
|
|
313
338
|
@property({ type: Array })
|
|
314
339
|
set nodes(nodes: NodeBase[]) {
|
|
315
|
-
|
|
316
|
-
|
|
340
|
+
if (!Array.isArray(nodes)) return;
|
|
341
|
+
|
|
342
|
+
// Filter out any null/undefined entries and ensure all nodes have a position
|
|
343
|
+
const validNodes = nodes.filter(n => n !== null && n !== undefined);
|
|
344
|
+
const nodesWithPosition = validNodes.map(node => ({
|
|
345
|
+
...node,
|
|
346
|
+
position: node.position || { x: 0, y: 0 }
|
|
347
|
+
}));
|
|
317
348
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
349
|
+
// If layout is enabled and any node is missing measurement, enter measuring mode
|
|
350
|
+
const needsMeasurement = this.layoutEnabled && nodesWithPosition.some(node => !node.measured || !node.measured.width);
|
|
351
|
+
|
|
352
|
+
if (needsMeasurement) {
|
|
353
|
+
this._isMeasuring = true;
|
|
321
354
|
}
|
|
322
355
|
|
|
323
|
-
this._state.nodes.set(
|
|
324
|
-
adoptUserNodes(
|
|
356
|
+
this._state.nodes.set(nodesWithPosition);
|
|
357
|
+
adoptUserNodes(nodesWithPosition, this._state.nodeLookup, this._state.parentLookup, {
|
|
325
358
|
nodeOrigin: this._state.nodeOrigin,
|
|
326
359
|
nodeExtent: this._state.nodeExtent,
|
|
327
360
|
});
|
|
@@ -333,7 +366,7 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
333
366
|
}
|
|
334
367
|
|
|
335
368
|
get nodes() {
|
|
336
|
-
return this._state.nodes.get();
|
|
369
|
+
return this._state.nodes.get() || [];
|
|
337
370
|
}
|
|
338
371
|
|
|
339
372
|
private _notifyChange() {
|
|
@@ -373,32 +406,151 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
373
406
|
}
|
|
374
407
|
|
|
375
408
|
private _performLayout(nodesToLayout: NodeBase[], edgesToLayout: any[]): LayoutNode[] {
|
|
376
|
-
|
|
377
|
-
|
|
409
|
+
if (nodesToLayout.length === 0) return [];
|
|
410
|
+
console.log(`LitFlow: Performing layout using ${this.layoutStrategy}`, {
|
|
411
|
+
nodes: nodesToLayout.length,
|
|
412
|
+
edges: edgesToLayout.length,
|
|
413
|
+
measuring: this._isMeasuring
|
|
414
|
+
});
|
|
378
415
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
416
|
+
if (this.layoutStrategy === 'tree') {
|
|
417
|
+
try {
|
|
418
|
+
const stratifier = d3h.stratify()
|
|
419
|
+
.id((d: any) => d.id)
|
|
420
|
+
.parentId((d: any) => {
|
|
421
|
+
const edge = edgesToLayout.find(e => e.target === d.id);
|
|
422
|
+
return edge ? edge.source : undefined;
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const root = stratifier(nodesToLayout);
|
|
426
|
+
|
|
427
|
+
const isHorizontal = this.layoutDirection === 'LR';
|
|
428
|
+
const verticalSpacing = this.layoutPadding * 2;
|
|
429
|
+
const crossSpacing = 250;
|
|
430
|
+
|
|
431
|
+
const treeLayout = d3h.tree().nodeSize(
|
|
432
|
+
isHorizontal ? [verticalSpacing, crossSpacing] : [crossSpacing, verticalSpacing]
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
treeLayout(root);
|
|
436
|
+
|
|
437
|
+
const nodeMap = new Map();
|
|
438
|
+
root.descendants().forEach(d => {
|
|
439
|
+
if (isHorizontal) {
|
|
440
|
+
nodeMap.set(d.id, { x: d.y, y: d.x });
|
|
441
|
+
} else {
|
|
442
|
+
nodeMap.set(d.id, { x: d.x, y: d.y });
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
return nodesToLayout.map(node => {
|
|
447
|
+
const pos = nodeMap.get(node.id) || { x: 0, y: 0 };
|
|
448
|
+
return {
|
|
449
|
+
...node,
|
|
450
|
+
position: pos
|
|
451
|
+
} as LayoutNode;
|
|
452
|
+
});
|
|
453
|
+
} catch (e) {
|
|
454
|
+
console.warn('Tree layout failed (multiple parents or cycles detected). Falling back to hierarchical.', e);
|
|
455
|
+
// Fallback to dagre below
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (this.layoutStrategy === 'organic') {
|
|
460
|
+
// Organic Layout (D3-Force)
|
|
461
|
+
const simulationNodes = nodesToLayout.map(n => ({
|
|
462
|
+
id: n.id,
|
|
463
|
+
type: n.type,
|
|
464
|
+
x: n.position?.x ?? 0,
|
|
465
|
+
y: n.position?.y ?? 0,
|
|
466
|
+
width: n.measured?.width || 150,
|
|
467
|
+
height: n.measured?.height || 50
|
|
468
|
+
}));
|
|
469
|
+
|
|
470
|
+
// Only include links where both source and target exist in the current set
|
|
471
|
+
const nodeIds = new Set(nodesToLayout.map(n => n.id));
|
|
472
|
+
const validLinks = edgesToLayout
|
|
473
|
+
.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
|
|
474
|
+
.map(e => ({
|
|
475
|
+
source: e.source,
|
|
476
|
+
target: e.target
|
|
477
|
+
}));
|
|
478
|
+
|
|
479
|
+
const simulation = d3.forceSimulation(simulationNodes as any)
|
|
480
|
+
.force('link', d3.forceLink(validLinks).id((d: any) => d.id).distance(this.layoutPadding * 4))
|
|
481
|
+
.force('charge', d3.forceManyBody().strength(-800))
|
|
482
|
+
.force('collide', d3.forceCollide().radius((d: any) => Math.max(d.width, d.height) / 2 + this.layoutPadding))
|
|
483
|
+
.force('center', d3.forceCenter(400, 300))
|
|
484
|
+
// Add horizontal bias to prevent tangling:
|
|
485
|
+
// pull inputs to the left, outputs to the right, and others to the center
|
|
486
|
+
.force('x', d3.forceX().x((d: any) => {
|
|
487
|
+
if (d.type === 'input') return 100;
|
|
488
|
+
if (d.type === 'output') return 700;
|
|
489
|
+
return 400;
|
|
490
|
+
}).strength(0.1))
|
|
491
|
+
.stop();
|
|
492
|
+
|
|
493
|
+
// Run simulation synchronously
|
|
494
|
+
for (let i = 0; i < 300; ++i) simulation.tick();
|
|
495
|
+
|
|
496
|
+
return nodesToLayout.map((node, i) => {
|
|
497
|
+
const simNode = simulationNodes[i];
|
|
498
|
+
return {
|
|
499
|
+
...node,
|
|
500
|
+
position: {
|
|
501
|
+
x: simNode.x - (simNode.width / 2),
|
|
502
|
+
y: simNode.y - (simNode.height / 2)
|
|
503
|
+
}
|
|
504
|
+
} as LayoutNode;
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Hierarchical Layout (Dagre) - Default
|
|
509
|
+
const g = new dagre.graphlib.Graph({ multigraph: true });
|
|
510
|
+
g.setGraph({});
|
|
511
|
+
|
|
512
|
+
g.graph().rankdir = this.layoutDirection;
|
|
513
|
+
g.graph().nodesep = this.layoutPadding;
|
|
514
|
+
g.graph().edgesep = this.layoutPadding;
|
|
515
|
+
g.graph().ranksep = this.layoutPadding * 2;
|
|
384
516
|
|
|
385
|
-
// Add nodes to the graphlib. Each node must have a width and height for dagre to work.
|
|
386
517
|
nodesToLayout.forEach((node) => {
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
g.setNode(node.id, { label: node.id, width, height });
|
|
518
|
+
const width = node.measured?.width || 150;
|
|
519
|
+
const height = node.measured?.height || 50;
|
|
520
|
+
g.setNode(String(node.id), { label: node.id, width, height });
|
|
391
521
|
});
|
|
392
522
|
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
523
|
+
// CRITICAL STABILITY CHECK:
|
|
524
|
+
// Only add edges if all nodes have been added to the graph.
|
|
525
|
+
const allNodesHaveDimensions = nodesToLayout.every(n => n.measured?.width && n.measured.width > 0);
|
|
526
|
+
|
|
527
|
+
if (allNodesHaveDimensions && edgesToLayout.length > 0) {
|
|
528
|
+
edgesToLayout.forEach((edge) => {
|
|
529
|
+
const v = String(edge.source);
|
|
530
|
+
const w = String(edge.target);
|
|
531
|
+
const name = String(edge.id);
|
|
532
|
+
// Ensure BOTH nodes exist in Dagre graph before adding edge
|
|
533
|
+
if (g.hasNode(v) && g.hasNode(w)) {
|
|
534
|
+
// Providing {} as the value is required for some dagre versions to avoid 'points' error
|
|
535
|
+
g.setEdge(v, w, {}, name);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
}
|
|
397
539
|
|
|
398
|
-
|
|
540
|
+
try {
|
|
541
|
+
dagre.layout(g);
|
|
542
|
+
} catch (e) {
|
|
543
|
+
console.error('LitFlow: Dagre layout engine failed', e);
|
|
544
|
+
return nodesToLayout as LayoutNode[];
|
|
545
|
+
}
|
|
399
546
|
|
|
400
|
-
|
|
401
|
-
const graphNode = g.node(node.id);
|
|
547
|
+
return nodesToLayout.map((node) => {
|
|
548
|
+
const graphNode = g.node(String(node.id));
|
|
549
|
+
// Fallback if dagre didn't position the node
|
|
550
|
+
if (!graphNode || graphNode.x === undefined) {
|
|
551
|
+
return { ...node, position: node.position || { x: 0, y: 0 } } as LayoutNode;
|
|
552
|
+
}
|
|
553
|
+
|
|
402
554
|
return {
|
|
403
555
|
...node,
|
|
404
556
|
position: {
|
|
@@ -407,8 +559,6 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
407
559
|
},
|
|
408
560
|
} as LayoutNode;
|
|
409
561
|
});
|
|
410
|
-
|
|
411
|
-
return newNodes;
|
|
412
562
|
}
|
|
413
563
|
|
|
414
564
|
@property({ type: Array })
|
|
@@ -539,19 +689,20 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
539
689
|
/**
|
|
540
690
|
* Fits the view to the current nodes.
|
|
541
691
|
* @param padding Optional padding in pixels (default: 50)
|
|
692
|
+
* @param duration Optional animation duration in ms (default: 400)
|
|
542
693
|
*/
|
|
543
|
-
async fitView(padding = 50) {
|
|
694
|
+
async fitView(padding = 50, duration = 400) {
|
|
544
695
|
if (!this._panZoom || this.nodes.length === 0) return;
|
|
545
696
|
|
|
546
|
-
const
|
|
547
|
-
if (
|
|
697
|
+
const visibleNodes = Array.from(this._state.nodeLookup.values()).filter(n => !n.hidden);
|
|
698
|
+
if (visibleNodes.length === 0) return;
|
|
548
699
|
|
|
549
700
|
let minX = Infinity;
|
|
550
701
|
let minY = Infinity;
|
|
551
702
|
let maxX = -Infinity;
|
|
552
703
|
let maxY = -Infinity;
|
|
553
704
|
|
|
554
|
-
|
|
705
|
+
visibleNodes.forEach((node) => {
|
|
555
706
|
const { x, y } = node.internals.positionAbsolute;
|
|
556
707
|
const width = node.measured.width || 0;
|
|
557
708
|
const height = node.measured.height || 0;
|
|
@@ -571,12 +722,14 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
571
722
|
|
|
572
723
|
const zoomX = (containerWidth - padding * 2) / graphWidth;
|
|
573
724
|
const zoomY = (containerHeight - padding * 2) / graphHeight;
|
|
574
|
-
|
|
725
|
+
|
|
726
|
+
// Smart Zoom: Only zoom out if it doesn't fit at 1:1. Never zoom in past 1:1.
|
|
727
|
+
const zoom = Math.min(zoomX, zoomY, 1);
|
|
575
728
|
|
|
576
729
|
const x = (containerWidth - graphWidth * zoom) / 2 - minX * zoom;
|
|
577
730
|
const y = (containerHeight - graphHeight * zoom) / 2 - minY * zoom;
|
|
578
731
|
|
|
579
|
-
await this._panZoom.setViewport({ x, y, zoom }, { duration
|
|
732
|
+
await this._panZoom.setViewport({ x, y, zoom }, { duration });
|
|
580
733
|
}
|
|
581
734
|
|
|
582
735
|
/**
|
|
@@ -704,17 +857,70 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
704
857
|
this._updatePanZoom();
|
|
705
858
|
}
|
|
706
859
|
|
|
707
|
-
//
|
|
708
|
-
if (this.
|
|
709
|
-
|
|
710
|
-
|
|
860
|
+
// Handle Render-Measure-Reflow completion
|
|
861
|
+
if (this._isMeasuring) {
|
|
862
|
+
// Use double rAF to ensure browser has painted and ResizeObserver has updated
|
|
863
|
+
requestAnimationFrame(() => {
|
|
864
|
+
requestAnimationFrame(() => {
|
|
865
|
+
const allMeasured = this.nodes.every(n => n.measured?.width && n.measured.width > 0);
|
|
866
|
+
if (allMeasured) {
|
|
867
|
+
this._isMeasuring = false;
|
|
868
|
+
const newNodes = this._performLayout(this.nodes, this.edges);
|
|
869
|
+
this.nodes = newNodes;
|
|
870
|
+
|
|
871
|
+
if (this.autoFit) {
|
|
872
|
+
setTimeout(() => this.fitView(), 50);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
this.dispatchEvent(new CustomEvent('layout-complete', {
|
|
876
|
+
detail: { strategy: this.layoutStrategy }
|
|
877
|
+
}));
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Trigger layout if layout properties change (and we aren't already measuring)
|
|
885
|
+
if (!this._isMeasuring && this.layoutEnabled && (
|
|
711
886
|
changedProperties.has('layoutEnabled') ||
|
|
712
|
-
changedProperties.has('layoutPadding')
|
|
887
|
+
changedProperties.has('layoutPadding') ||
|
|
888
|
+
changedProperties.has('layoutStrategy') ||
|
|
889
|
+
changedProperties.has('layoutDirection')
|
|
713
890
|
)) {
|
|
714
891
|
const newNodes = this._performLayout(this.nodes, this.edges);
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
892
|
+
this.nodes = newNodes;
|
|
893
|
+
|
|
894
|
+
if (this.autoFit) {
|
|
895
|
+
setTimeout(() => this.fitView(), 50);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// If strategy or direction changed, handles moved. Force re-measurement of all nodes.
|
|
899
|
+
if (changedProperties.has('layoutStrategy') || changedProperties.has('layoutDirection')) {
|
|
900
|
+
this.shadowRoot?.querySelectorAll('.xyflow__node').forEach((el) => {
|
|
901
|
+
const id = (el as HTMLElement).dataset.id;
|
|
902
|
+
if (id) this._updateNodeDimensions(id, el as HTMLElement);
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
this.dispatchEvent(new CustomEvent('layout-complete', {
|
|
907
|
+
detail: { strategy: this.layoutStrategy, direction: this.layoutDirection }
|
|
908
|
+
}));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Handle Auto-Fit on graph changes
|
|
912
|
+
if (this.autoFit && !this._isMeasuring && (changedProperties.has('nodes') || changedProperties.has('edges'))) {
|
|
913
|
+
// Use a small delay to ensure rendering is stable
|
|
914
|
+
setTimeout(() => this.fitView(), 100);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Reactive Subgraph Isolation
|
|
918
|
+
if (changedProperties.has('focusNode') || changedProperties.has('focusDirection')) {
|
|
919
|
+
if (this.focusNode) {
|
|
920
|
+
this.isolateSubgraph(this.focusNode, this.focusDirection);
|
|
921
|
+
} else if (changedProperties.get('focusNode')) {
|
|
922
|
+
// Only clear if it was previously set
|
|
923
|
+
this.clearIsolation();
|
|
718
924
|
}
|
|
719
925
|
}
|
|
720
926
|
}
|
|
@@ -774,6 +980,12 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
774
980
|
node.internals.positionAbsolute = item.internals.positionAbsolute;
|
|
775
981
|
const userNode = this.nodes.find((n) => n.id === id);
|
|
776
982
|
if (userNode) userNode.position = item.position;
|
|
983
|
+
|
|
984
|
+
// Performance: Directly update DOM transform to eliminate lag vs edges
|
|
985
|
+
const el = this.shadowRoot?.querySelector(`.xyflow__node[data-id="${id}"]`) as HTMLElement;
|
|
986
|
+
if (el) {
|
|
987
|
+
el.style.transform = `translate(${item.position.x}px, ${item.position.y}px)`;
|
|
988
|
+
}
|
|
777
989
|
}
|
|
778
990
|
});
|
|
779
991
|
|
|
@@ -783,7 +995,7 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
783
995
|
nodeExtent: this._state.nodeExtent,
|
|
784
996
|
});
|
|
785
997
|
|
|
786
|
-
// Trigger update via signal
|
|
998
|
+
// Trigger update via signal (asynchronously)
|
|
787
999
|
this._state.nodes.set([...this.nodes]);
|
|
788
1000
|
this._notifyChange();
|
|
789
1001
|
},
|
|
@@ -1135,6 +1347,60 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
1135
1347
|
});
|
|
1136
1348
|
}
|
|
1137
1349
|
|
|
1350
|
+
private _onNodeResizeStart(e: CustomEvent) {
|
|
1351
|
+
const { nodeId, event } = e.detail;
|
|
1352
|
+
const startX = event.clientX;
|
|
1353
|
+
const startY = event.clientY;
|
|
1354
|
+
|
|
1355
|
+
const node = this._state.nodeLookup.get(nodeId);
|
|
1356
|
+
if (!node) return;
|
|
1357
|
+
|
|
1358
|
+
const startWidth = node.measured.width || 150;
|
|
1359
|
+
const startHeight = node.measured.height || 50;
|
|
1360
|
+
const zoom = this._state.transform.get()[2];
|
|
1361
|
+
|
|
1362
|
+
const onPointerMove = (moveEvent: PointerEvent) => {
|
|
1363
|
+
const deltaX = (moveEvent.clientX - startX) / zoom;
|
|
1364
|
+
const deltaY = (moveEvent.clientY - startY) / zoom;
|
|
1365
|
+
|
|
1366
|
+
const newWidth = Math.max(50, startWidth + deltaX);
|
|
1367
|
+
const newHeight = Math.max(30, startHeight + deltaY);
|
|
1368
|
+
|
|
1369
|
+
// Update node dimensions
|
|
1370
|
+
node.measured = { width: newWidth, height: newHeight };
|
|
1371
|
+
|
|
1372
|
+
// Sync back to user node
|
|
1373
|
+
const userNode = this.nodes.find(n => n.id === nodeId) as any;
|
|
1374
|
+
if (userNode) {
|
|
1375
|
+
userNode.width = newWidth;
|
|
1376
|
+
userNode.height = newHeight;
|
|
1377
|
+
if (!userNode.style) userNode.style = {};
|
|
1378
|
+
userNode.style.width = `${newWidth}px`;
|
|
1379
|
+
userNode.style.height = `${newHeight}px`;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Update absolute positions and signal
|
|
1383
|
+
updateAbsolutePositions(this._state.nodeLookup, this._state.parentLookup, {
|
|
1384
|
+
nodeOrigin: this._state.nodeOrigin,
|
|
1385
|
+
nodeExtent: this._state.nodeExtent,
|
|
1386
|
+
});
|
|
1387
|
+
this._state.nodes.set([...this.nodes]);
|
|
1388
|
+
this._notifyChange();
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
const onPointerUp = () => {
|
|
1392
|
+
window.removeEventListener('pointermove', onPointerMove);
|
|
1393
|
+
window.removeEventListener('pointerup', onPointerUp);
|
|
1394
|
+
|
|
1395
|
+
this.dispatchEvent(new CustomEvent('node-resize-end', {
|
|
1396
|
+
detail: { nodeId, width: node.measured.width, height: node.measured.height }
|
|
1397
|
+
}));
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
1401
|
+
window.addEventListener('pointerup', onPointerUp);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1138
1404
|
private _onDragOver(e: DragEvent) {
|
|
1139
1405
|
e.preventDefault();
|
|
1140
1406
|
if (e.dataTransfer) {
|
|
@@ -1284,14 +1550,17 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
1284
1550
|
}}"
|
|
1285
1551
|
>
|
|
1286
1552
|
<div
|
|
1287
|
-
class="
|
|
1288
|
-
style="transform:
|
|
1553
|
+
class="xyflow__reveal-container"
|
|
1554
|
+
style="width: 100%; height: 100%; transition: opacity 0.4s ease-in-out, transform 0.4s ease-out; opacity: ${this._isMeasuring ? '0' : '1'}; transform: ${this._isMeasuring ? 'scale(0.98)' : 'scale(1)'};"
|
|
1289
1555
|
>
|
|
1290
|
-
<div class="
|
|
1556
|
+
<div class="xyflow__viewport"
|
|
1557
|
+
style="transform: translate(${transform[0]}px, ${transform[1]}px) scale(${transform[2]})"
|
|
1558
|
+
>
|
|
1559
|
+
<div class="xyflow__nodes" @handle-pointer-down="${this._onHandlePointerDown}" @node-resize-start="${this._onNodeResizeStart}">
|
|
1291
1560
|
${this.nodes.map((node) => {
|
|
1292
1561
|
if (node.hidden) return null;
|
|
1293
1562
|
const internalNode = this._state.nodeLookup.get(node.id);
|
|
1294
|
-
const pos = internalNode?.internals.positionAbsolute || node.position;
|
|
1563
|
+
const pos = internalNode?.internals.positionAbsolute || node.position || { x: 0, y: 0 };
|
|
1295
1564
|
const tagName = this.nodeTypes[node.type || 'default'] || this.nodeTypes.default;
|
|
1296
1565
|
const tag = unsafeStatic(tagName);
|
|
1297
1566
|
|
|
@@ -1306,6 +1575,9 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
1306
1575
|
const heightStyle = height ? `height: ${typeof height === 'number' ? `${height}px` : height};` : '';
|
|
1307
1576
|
const zIndex = (node as any).zIndex ? `z-index: ${(node as any).zIndex};` : '';
|
|
1308
1577
|
|
|
1578
|
+
const autoOrientation = (this.layoutDirection === 'LR') ? 'horizontal' : 'vertical';
|
|
1579
|
+
const orientation = (node as any).orientation || autoOrientation;
|
|
1580
|
+
|
|
1309
1581
|
return html`
|
|
1310
1582
|
<${tag}
|
|
1311
1583
|
class="xyflow__node"
|
|
@@ -1317,6 +1589,8 @@ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
1317
1589
|
.label="${(node.data as any).label}"
|
|
1318
1590
|
.type="${node.type || 'default'}"
|
|
1319
1591
|
?selected="${node.selected}"
|
|
1592
|
+
?resizable="${(node as any).resizable}"
|
|
1593
|
+
.orientation="${orientation}"
|
|
1320
1594
|
>
|
|
1321
1595
|
</${tag}>
|
|
1322
1596
|
`;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit';
|
|
2
|
+
import { customElement } from 'lit/decorators.js';
|
|
3
|
+
|
|
4
|
+
@customElement('lit-node-toolbar')
|
|
5
|
+
export class LitNodeToolbar extends LitElement {
|
|
6
|
+
static styles = css`
|
|
7
|
+
:host {
|
|
8
|
+
display: flex;
|
|
9
|
+
gap: 4px;
|
|
10
|
+
padding: 4px;
|
|
11
|
+
background-color: var(--md-sys-color-surface-container-high);
|
|
12
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
13
|
+
border-radius: var(--md-sys-shape-corner-small);
|
|
14
|
+
box-shadow: var(--md-sys-elevation-2);
|
|
15
|
+
pointer-events: all;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
::slotted(button) {
|
|
19
|
+
background: none;
|
|
20
|
+
border: none;
|
|
21
|
+
padding: 4px 8px;
|
|
22
|
+
cursor: pointer;
|
|
23
|
+
color: var(--md-sys-color-on-surface);
|
|
24
|
+
font-family: var(--md-sys-typescale-label-medium-font);
|
|
25
|
+
font-size: var(--md-sys-typescale-label-medium-size);
|
|
26
|
+
border-radius: var(--md-sys-shape-corner-extra-small);
|
|
27
|
+
transition: background-color 0.2s;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
::slotted(button:hover) {
|
|
31
|
+
background-color: var(--md-sys-color-surface-container-highest);
|
|
32
|
+
}
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
render() {
|
|
36
|
+
return html`<slot></slot>`;
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/lit-node.ts
CHANGED
|
@@ -22,6 +22,12 @@ export class LitNode extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
22
22
|
@property({ type: Boolean, reflect: true })
|
|
23
23
|
selected = false;
|
|
24
24
|
|
|
25
|
+
@property({ type: Boolean, reflect: true })
|
|
26
|
+
resizable = false;
|
|
27
|
+
|
|
28
|
+
@property({ type: String, reflect: true })
|
|
29
|
+
orientation: 'vertical' | 'horizontal' = 'vertical';
|
|
30
|
+
|
|
25
31
|
@property({ type: String, attribute: 'data-id', reflect: true })
|
|
26
32
|
nodeId = '';
|
|
27
33
|
|
|
@@ -31,8 +37,24 @@ export class LitNode extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
31
37
|
@property({ type: Number, attribute: 'position-y' })
|
|
32
38
|
positionY = 0;
|
|
33
39
|
|
|
40
|
+
willUpdate(changedProperties: Map<string, any>) {
|
|
41
|
+
if (changedProperties.has('selected')) {
|
|
42
|
+
console.log(`LitNode ${this.nodeId} selected changed:`, this.selected);
|
|
43
|
+
}
|
|
44
|
+
// In Light DOM, sometimes boolean attributes need manual sync from the element attribute
|
|
45
|
+
if (this.hasAttribute('selected') !== this.selected) {
|
|
46
|
+
this.selected = this.hasAttribute('selected');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
render() {
|
|
35
51
|
return html`
|
|
52
|
+
<div
|
|
53
|
+
class="node-toolbar-container"
|
|
54
|
+
style="position: absolute; top: -45px; left: 50%; transform: translateX(-50%); z-index: 100; pointer-events: all; display: ${this.selected ? 'block' : 'none'};"
|
|
55
|
+
>
|
|
56
|
+
<slot name="toolbar"></slot>
|
|
57
|
+
</div>
|
|
36
58
|
<div class="content-wrapper" style="padding: 12px; display: flex; flex-direction: column; gap: 4px; pointer-events: none;">
|
|
37
59
|
<div class="headline" style="font-size: var(--md-sys-typescale-title-small-size); font-weight: var(--md-sys-typescale-title-small-weight); color: var(--md-sys-color-on-surface); font-family: var(--md-sys-typescale-title-small-font);">
|
|
38
60
|
<slot name="headline">${this.label}</slot>
|
|
@@ -42,12 +64,28 @@ export class LitNode extends (SignalWatcher as <T extends Constructor<LitElement
|
|
|
42
64
|
</div>
|
|
43
65
|
<slot></slot>
|
|
44
66
|
</div>
|
|
67
|
+
${this.resizable && this.selected
|
|
68
|
+
? html`<div
|
|
69
|
+
class="resize-handle"
|
|
70
|
+
style="position: absolute; right: 4px; bottom: 4px; width: 10px; height: 10px; border-right: 2px solid var(--md-sys-color-primary); border-bottom: 2px solid var(--md-sys-color-primary); cursor: nwse-resize; pointer-events: all; z-index: 100;"
|
|
71
|
+
@pointerdown="${this._onResizeStart}"
|
|
72
|
+
></div>`
|
|
73
|
+
: ''}
|
|
45
74
|
${this.type === 'input' || this.type === 'default'
|
|
46
|
-
? html`<lit-handle type="source" data-handlepos="bottom" data-nodeid="${this.nodeId}"></lit-handle>`
|
|
75
|
+
? html`<lit-handle type="source" .position="${this.orientation === 'horizontal' ? 'right' : 'bottom'}" data-handlepos="${this.orientation === 'horizontal' ? 'right' : 'bottom'}" data-nodeid="${this.nodeId}"></lit-handle>`
|
|
47
76
|
: ''}
|
|
48
77
|
${this.type === 'output' || this.type === 'default'
|
|
49
|
-
? html`<lit-handle type="target" data-handlepos="top" data-nodeid="${this.nodeId}"></lit-handle>`
|
|
78
|
+
? html`<lit-handle type="target" .position="${this.orientation === 'horizontal' ? 'left' : 'top'}" data-handlepos="${this.orientation === 'horizontal' ? 'left' : 'top'}" data-nodeid="${this.nodeId}"></lit-handle>`
|
|
50
79
|
: ''}
|
|
51
80
|
`;
|
|
52
81
|
}
|
|
82
|
+
|
|
83
|
+
private _onResizeStart(e: PointerEvent) {
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
this.dispatchEvent(new CustomEvent('node-resize-start', {
|
|
86
|
+
detail: { nodeId: this.nodeId, event: e },
|
|
87
|
+
bubbles: true,
|
|
88
|
+
composed: true
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
53
91
|
}
|