@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghchinoy/litflow",
3
- "version": "0.4.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
@@ -1,5 +1,6 @@
1
1
  export * from './lit-flow';
2
2
  export * from './lit-node';
3
+ export * from './lit-node-toolbar';
3
4
  export * from './lit-edge';
4
5
  export * from './lit-handle';
5
6
  export * from './lit-controls';
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
- // Check if layout is enabled and any node is missing a position
316
- const needsLayout = this.layoutEnabled && nodes.some(node => !node.position || (node.position.x === undefined || node.position.y === undefined));
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
- let nodesToSet = nodes;
319
- if (needsLayout) {
320
- nodesToSet = this._performLayout(nodes, this.edges);
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(nodesToSet);
324
- adoptUserNodes(nodesToSet, this._state.nodeLookup, this._state.parentLookup, {
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
- const g = new dagre.graphlib.Graph();
377
- g.setGraph({}); // Set graph defaults, e.g., { rankdir: 'LR' } for left-to-right
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
- // Default to a left-to-right layout for dependency graphs
380
- g.graph().rankdir = 'LR';
381
- g.graph().nodesep = this.layoutPadding; // Horizontal spacing
382
- g.graph().edgesep = this.layoutPadding; // Spacing between parallel edges
383
- g.graph().ranksep = this.layoutPadding * 2; // Vertical spacing between ranks/layers
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
- // Use estimated or default dimensions if not measured yet
388
- const width = node.measured?.width || 150; // Default width
389
- const height = node.measured?.height || 50; // Default height
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
- // Add edges to the graphlib
394
- edgesToLayout.forEach((edge) => {
395
- g.setEdge(edge.source, edge.target);
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
- dagre.layout(g);
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
- const newNodes = nodesToLayout.map((node) => {
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 nodes = Array.from(this._state.nodeLookup.values());
547
- if (nodes.length === 0) return;
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
- nodes.forEach((node) => {
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
- const zoom = Math.min(zoomX, zoomY, 1); // Don't zoom in past 1:1
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: 400 });
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
- // Trigger layout if layout is enabled and nodes/edges change or layout properties change
708
- if (this.layoutEnabled && (
709
- changedProperties.has('nodes') ||
710
- changedProperties.has('edges') ||
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
- if (JSON.stringify(newNodes.map(n => n.position)) !== JSON.stringify(this.nodes.map(n => n.position))) {
716
- this._state.nodes.set(newNodes);
717
- this._notifyChange();
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="xyflow__viewport"
1288
- style="transform: translate(${transform[0]}px, ${transform[1]}px) scale(${transform[2]})"
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="xyflow__nodes" @handle-pointer-down="${this._onHandlePointerDown}">
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
  }