@ghchinoy/litflow 0.1.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.
@@ -0,0 +1,784 @@
1
+ import { LitElement, css, svg } from 'lit';
2
+ import { html, unsafeStatic } from 'lit/static-html.js';
3
+ import { customElement, property, query, state } from 'lit/decorators.js';
4
+ import { SignalWatcher } from '@lit-labs/signals';
5
+ import {
6
+ XYPanZoom,
7
+ XYDrag,
8
+ XYHandle,
9
+ ConnectionMode,
10
+ PanOnScrollMode,
11
+ Position,
12
+ type PanZoomInstance,
13
+ type XYDragInstance,
14
+ type Viewport,
15
+ type NodeBase,
16
+ adoptUserNodes,
17
+ updateAbsolutePositions,
18
+ getHandlePosition,
19
+ getBezierPath,
20
+ getSmoothStepPath,
21
+ getStraightPath,
22
+ } from '@xyflow/system';
23
+ import { createInitialState, type FlowState } from './store';
24
+ import { m3Tokens } from './theme';
25
+ import './lit-node';
26
+ import './lit-edge';
27
+
28
+ type Constructor<T> = new (...args: any[]) => T;
29
+
30
+ const boolConverter = {
31
+ fromAttribute: (value: string | null) => value !== 'false' && value !== null,
32
+ toAttribute: (value: boolean) => (value ? '' : null),
33
+ };
34
+
35
+ @customElement('lit-flow')
36
+ export class LitFlow extends (SignalWatcher as <T extends Constructor<LitElement>>(base: T) => T)(LitElement) {
37
+ static styles = [
38
+ m3Tokens,
39
+ css`
40
+ :host {
41
+ display: block;
42
+ width: 100%;
43
+ height: 100%;
44
+ overflow: hidden;
45
+ position: relative;
46
+ background-color: var(--md-sys-color-background);
47
+ color: var(--md-sys-color-on-background);
48
+ font-family: var(--md-sys-typescale-body-medium-font);
49
+ }
50
+
51
+ .xyflow__renderer {
52
+ width: 100%;
53
+ height: 100%;
54
+ position: absolute;
55
+ top: 0;
56
+ left: 0;
57
+ }
58
+
59
+ .xyflow__renderer.has-grid {
60
+ background-image: radial-gradient(var(--md-sys-color-outline-variant) 1px, transparent 0);
61
+ background-size: 20px 20px;
62
+ }
63
+
64
+ .xyflow__viewport {
65
+ transform-origin: 0 0;
66
+ width: 100%;
67
+ height: 100%;
68
+ }
69
+
70
+ .xyflow__edges {
71
+ position: absolute;
72
+ width: 100%;
73
+ height: 100%;
74
+ pointer-events: none;
75
+ overflow: visible;
76
+ }
77
+
78
+ .xyflow__nodes {
79
+ position: absolute;
80
+ width: 100%;
81
+ height: 100%;
82
+ pointer-events: none;
83
+ }
84
+
85
+ .xyflow__node {
86
+ position: absolute;
87
+ pointer-events: all;
88
+ cursor: grab;
89
+ user-select: none;
90
+ display: block;
91
+ background: var(--lit-flow-node-bg);
92
+ border: 1px solid var(--lit-flow-node-border);
93
+ padding: 12px;
94
+ border-radius: var(--md-sys-shape-corner-small);
95
+ min-width: 120px;
96
+ text-align: center;
97
+ box-shadow: var(--md-sys-elevation-1);
98
+ box-sizing: border-box;
99
+ color: var(--lit-flow-node-text);
100
+ font-size: var(--md-sys-typescale-body-medium-size);
101
+ transition: box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
102
+ }
103
+
104
+ .xyflow__node[type="group"] {
105
+ padding: 0;
106
+ background: none;
107
+ border: none;
108
+ box-shadow: none;
109
+ pointer-events: none;
110
+ }
111
+
112
+ .xyflow__node[type="group"] > * {
113
+ pointer-events: all;
114
+ }
115
+
116
+ .xyflow__node[selected] {
117
+ border-color: var(--lit-flow-node-selected-border);
118
+ border-width: 2px;
119
+ box-shadow: var(--md-sys-elevation-2);
120
+ }
121
+
122
+ .xyflow__node[type="input"] {
123
+ border-top: 4px solid var(--md-sys-color-primary);
124
+ }
125
+
126
+ .xyflow__node[type="output"] {
127
+ border-bottom: 4px solid var(--md-sys-color-secondary);
128
+ }
129
+
130
+ .xyflow__node:active {
131
+ cursor: grabbing;
132
+ }
133
+
134
+ .lit-flow__handle {
135
+ display: block;
136
+ position: absolute;
137
+ width: 10px;
138
+ height: 10px;
139
+ border-radius: 50%;
140
+ z-index: 10;
141
+ pointer-events: all;
142
+ cursor: pointer;
143
+ border: 2px solid var(--lit-flow-handle-outline);
144
+ background-clip: padding-box;
145
+ box-sizing: border-box;
146
+ transition: transform 0.1s ease-in-out;
147
+ }
148
+
149
+ .lit-flow__handle:hover {
150
+ transform: scale(1.2);
151
+ }
152
+
153
+ .lit-flow__handle.source {
154
+ background-color: var(--lit-flow-handle-source);
155
+ }
156
+
157
+ .lit-flow__handle.target {
158
+ background-color: var(--lit-flow-handle-target);
159
+ }
160
+
161
+ .lit-flow__handle[data-handlepos="top"] {
162
+ top: -5px;
163
+ left: 50%;
164
+ transform: translateX(-50%);
165
+ }
166
+
167
+ .lit-flow__handle[data-handlepos="bottom"] {
168
+ bottom: -5px;
169
+ left: 50%;
170
+ transform: translateX(-50%);
171
+ }
172
+
173
+ .lit-flow__handle[data-handlepos="left"] {
174
+ left: -5px;
175
+ top: 50%;
176
+ transform: translateY(-50%);
177
+ }
178
+
179
+ .lit-flow__handle[data-handlepos="right"] {
180
+ right: -5px;
181
+ top: 50%;
182
+ transform: translateY(-50%);
183
+ }
184
+
185
+ .xyflow__connection-path {
186
+ fill: none;
187
+ stroke: var(--md-sys-color-outline);
188
+ stroke-width: 2;
189
+ stroke-dasharray: 5,5;
190
+ pointer-events: none;
191
+ }
192
+ `
193
+ ];
194
+
195
+ @query('.xyflow__renderer')
196
+ _renderer?: HTMLElement;
197
+
198
+ @query('.xyflow__viewport')
199
+ _viewport?: HTMLElement;
200
+
201
+ @state()
202
+ private _panZoom?: PanZoomInstance;
203
+
204
+ private _drags = new Map<string, XYDragInstance>();
205
+ private _resizeObserver?: ResizeObserver;
206
+
207
+ @state()
208
+ private _state: FlowState = createInitialState();
209
+
210
+ @property({ type: Object })
211
+ nodeTypes: Record<string, string> = {
212
+ default: 'lit-node',
213
+ input: 'lit-node',
214
+ output: 'lit-node',
215
+ };
216
+
217
+ @property({ type: Boolean, attribute: 'show-controls', reflect: true })
218
+ showControls = false;
219
+
220
+ @property({ type: Boolean, attribute: 'show-minimap', reflect: true, converter: boolConverter })
221
+ showMinimap = false;
222
+
223
+ @property({ type: Boolean, attribute: 'show-grid', reflect: true, converter: boolConverter })
224
+ showGrid = true;
225
+
226
+ @property({ type: Boolean, attribute: 'nodes-draggable', reflect: true, converter: boolConverter })
227
+ nodesDraggable = true;
228
+
229
+ @property({ type: Boolean, attribute: 'nodes-connectable', reflect: true, converter: boolConverter })
230
+ nodesConnectable = true;
231
+
232
+ @property({ type: Boolean, attribute: 'pan-on-drag', reflect: true, converter: boolConverter })
233
+ panOnDrag = true;
234
+
235
+ @property({ type: Boolean, attribute: 'zoom-on-scroll', reflect: true, converter: boolConverter })
236
+ zoomOnScroll = true;
237
+
238
+ @property({ type: Boolean, attribute: 'zoom-on-pinch', reflect: true, converter: boolConverter })
239
+ zoomOnPinch = true;
240
+
241
+ @property({ type: Boolean, attribute: 'zoom-on-double-click', reflect: true, converter: boolConverter })
242
+ zoomOnDoubleClick = true;
243
+
244
+ @state()
245
+ private _width = 0;
246
+
247
+ @state()
248
+ private _height = 0;
249
+
250
+ @property({ type: Array })
251
+ set nodes(nodes: NodeBase[]) {
252
+ this._state.nodes.set(nodes);
253
+ adoptUserNodes(nodes, this._state.nodeLookup, this._state.parentLookup, {
254
+ nodeOrigin: this._state.nodeOrigin,
255
+ nodeExtent: this._state.nodeExtent,
256
+ });
257
+ updateAbsolutePositions(this._state.nodeLookup, this._state.parentLookup, {
258
+ nodeOrigin: this._state.nodeOrigin,
259
+ nodeExtent: this._state.nodeExtent,
260
+ });
261
+ }
262
+
263
+ get nodes() {
264
+ return this._state.nodes.get();
265
+ }
266
+
267
+ @property({ type: Array })
268
+ set edges(edges: any[]) {
269
+ this._state.edges.set(edges);
270
+ }
271
+
272
+ get edges() {
273
+ return this._state.edges.get();
274
+ }
275
+
276
+ @property({ type: Object })
277
+ viewport: Viewport = { x: 0, y: 0, zoom: 1 };
278
+
279
+ connectedCallback() {
280
+ super.connectedCallback();
281
+ this._resizeObserver = new ResizeObserver((entries) => {
282
+ for (const entry of entries) {
283
+ if (entry.target === this) {
284
+ this._width = entry.contentRect.width;
285
+ this._height = entry.contentRect.height;
286
+ } else if (entry.target === this._renderer) {
287
+ // Handle renderer resize if needed
288
+ } else {
289
+ const id = (entry.target as HTMLElement).dataset.id;
290
+ if (id) {
291
+ this._updateNodeDimensions(id, entry.target as HTMLElement);
292
+ }
293
+ }
294
+ }
295
+ });
296
+ this._resizeObserver.observe(this);
297
+ }
298
+
299
+ disconnectedCallback() {
300
+ super.disconnectedCallback();
301
+ this._resizeObserver?.disconnect();
302
+ }
303
+
304
+ private async _updateNodeDimensions(id: string, element: HTMLElement) {
305
+ const node = this._state.nodeLookup.get(id);
306
+ if (node) {
307
+ // Wait for Lit element to finish rendering its shadow DOM
308
+ if ('updateComplete' in element) {
309
+ await (element as any).updateComplete;
310
+ }
311
+
312
+ const { width, height } = element.getBoundingClientRect();
313
+ const zoom = this._state.transform.get()[2];
314
+ node.measured = {
315
+ width: width / zoom,
316
+ height: height / zoom,
317
+ };
318
+
319
+ // Sync back to user node to preserve dimensions across adoptUserNodes calls
320
+ const userNode = this.nodes.find((n) => n.id === id);
321
+ if (userNode) {
322
+ userNode.measured = node.measured;
323
+ }
324
+
325
+ // Update handle bounds
326
+ // Since lit-node is now light-DOM, we look in the element itself, not shadowRoot
327
+ const handles = element.querySelectorAll('lit-handle');
328
+ if (handles && handles.length > 0) {
329
+ const sourceBounds: any[] = [];
330
+ const targetBounds: any[] = [];
331
+
332
+ handles.forEach((h: any) => {
333
+ const bounds = h.getBoundingClientRect();
334
+ const nodeRect = element.getBoundingClientRect();
335
+ const handleData = {
336
+ id: h.handleId || null,
337
+ type: h.type,
338
+ position: h.position,
339
+ x: (bounds.left - nodeRect.left) / zoom,
340
+ y: (bounds.top - nodeRect.top) / zoom,
341
+ width: bounds.width / zoom,
342
+ height: bounds.height / zoom,
343
+ };
344
+
345
+ if (h.type === 'source') sourceBounds.push(handleData);
346
+ else targetBounds.push(handleData);
347
+ });
348
+
349
+ node.internals.handleBounds = {
350
+ source: sourceBounds,
351
+ target: targetBounds,
352
+ };
353
+ console.log(`Node ${id} handleBounds:`, node.internals.handleBounds);
354
+ }
355
+
356
+ // Update absolute positions without clearing the lookup
357
+ updateAbsolutePositions(this._state.nodeLookup, this._state.parentLookup, {
358
+ nodeOrigin: this._state.nodeOrigin,
359
+ nodeExtent: this._state.nodeExtent,
360
+ });
361
+
362
+ // Trigger update via signal
363
+ this._state.nodes.set([...this.nodes]);
364
+ }
365
+ }
366
+
367
+ private _selectNode(id: string, multi: boolean) {
368
+ const newNodes = this.nodes.map((node) => {
369
+ if (node.id === id) {
370
+ return { ...node, selected: !node.selected };
371
+ }
372
+ return multi ? node : { ...node, selected: false };
373
+ });
374
+ this.nodes = newNodes;
375
+ }
376
+
377
+ firstUpdated() {
378
+ if (this._renderer) {
379
+ this._state.domNode = this._renderer;
380
+ this._resizeObserver?.observe(this._renderer);
381
+
382
+ // Clear selection on background click
383
+ this._renderer.onclick = () => {
384
+ this.nodes = this.nodes.map(n => ({ ...n, selected: false }));
385
+ };
386
+
387
+ this._panZoom = XYPanZoom({
388
+ domNode: this._renderer,
389
+ minZoom: 0.5,
390
+ maxZoom: 2,
391
+ translateExtent: this._state.nodeExtent,
392
+ viewport: this.viewport,
393
+ onDraggingChange: () => {},
394
+ onPanZoom: (_, { x, y, zoom }) => {
395
+ this.viewport = { x, y, zoom };
396
+ this._state.transform.set([x, y, zoom]);
397
+ if (this._viewport) {
398
+ this._viewport.style.transform = `translate(${x}px,${y}px) scale(${zoom})`;
399
+ }
400
+ },
401
+ });
402
+
403
+ this._panZoom.update({
404
+ noWheelClassName: 'nowheel',
405
+ noPanClassName: 'nopan',
406
+ preventScrolling: true,
407
+ panOnScroll: false,
408
+ panOnDrag: this.panOnDrag,
409
+ panOnScrollMode: PanOnScrollMode.Free,
410
+ panOnScrollSpeed: 0.5,
411
+ userSelectionActive: false,
412
+ zoomOnPinch: this.zoomOnPinch,
413
+ zoomOnScroll: this.zoomOnScroll,
414
+ zoomOnDoubleClick: this.zoomOnDoubleClick,
415
+ zoomActivationKeyPressed: false,
416
+ lib: 'lit',
417
+ onTransformChange: () => {},
418
+ connectionInProgress: false,
419
+ paneClickDistance: 0,
420
+ });
421
+
422
+ this._state.panZoom = this._panZoom;
423
+ }
424
+ }
425
+
426
+ updated(changedProperties: Map<string, any>) {
427
+ if (changedProperties.has('nodes') || changedProperties.has('nodesDraggable')) {
428
+ this._setupDrags();
429
+ }
430
+
431
+ if (this._panZoom && (
432
+ changedProperties.has('panOnDrag') ||
433
+ changedProperties.has('zoomOnScroll') ||
434
+ changedProperties.has('zoomOnPinch') ||
435
+ changedProperties.has('zoomOnDoubleClick')
436
+ )) {
437
+ this._panZoom.update({
438
+ noWheelClassName: 'nowheel',
439
+ noPanClassName: 'nopan',
440
+ preventScrolling: true,
441
+ panOnScroll: false,
442
+ panOnDrag: this.panOnDrag,
443
+ panOnScrollMode: PanOnScrollMode.Free,
444
+ panOnScrollSpeed: 0.5,
445
+ userSelectionActive: false,
446
+ zoomOnPinch: this.zoomOnPinch,
447
+ zoomOnScroll: this.zoomOnScroll,
448
+ zoomOnDoubleClick: this.zoomOnDoubleClick,
449
+ zoomActivationKeyPressed: false,
450
+ lib: 'lit',
451
+ onTransformChange: () => {},
452
+ connectionInProgress: false,
453
+ paneClickDistance: 0,
454
+ });
455
+ }
456
+ }
457
+
458
+ private _setupDrags() {
459
+ const nodeElements = this.shadowRoot?.querySelectorAll('.xyflow__node');
460
+ const currentIds = new Set<string>();
461
+
462
+ nodeElements?.forEach((el) => {
463
+ const id = (el as HTMLElement).dataset.id;
464
+ if (id) {
465
+ currentIds.add(id);
466
+ this._resizeObserver?.observe(el);
467
+
468
+ // Add click listener for selection
469
+ (el as HTMLElement).onclick = (e) => {
470
+ e.stopPropagation();
471
+ this._selectNode(id, e.shiftKey || e.metaKey);
472
+ };
473
+
474
+ // Update cursor based on draggability
475
+ (el as HTMLElement).style.cursor = this.nodesDraggable ? 'grab' : 'default';
476
+
477
+ if (!this.nodesDraggable) {
478
+ this._drags.delete(id);
479
+ return;
480
+ }
481
+
482
+ let dragInstance = this._drags.get(id);
483
+ if (!dragInstance) {
484
+ dragInstance = XYDrag({
485
+ getStoreItems: () => ({
486
+ ...this._state,
487
+ nodes: this._state.nodes.get(),
488
+ edges: this._state.edges.get(),
489
+ transform: this._state.transform.get(),
490
+ panBy: async (delta) => {
491
+ const { panZoom, nodeExtent } = this._state;
492
+ const transform = this._state.transform.get();
493
+ if (!panZoom) return false;
494
+ const nextViewport = await panZoom.setViewportConstrained(
495
+ {
496
+ x: transform[0] + delta.x,
497
+ y: transform[1] + delta.y,
498
+ zoom: transform[2],
499
+ },
500
+ [[0, 0], [this.offsetWidth, this.offsetHeight]],
501
+ nodeExtent
502
+ );
503
+ return !!nextViewport;
504
+ },
505
+ updateNodePositions: (dragItems) => {
506
+ dragItems.forEach((item, id) => {
507
+ const node = this._state.nodeLookup.get(id);
508
+ if (node) {
509
+ node.position = item.position;
510
+ node.internals.positionAbsolute = item.internals.positionAbsolute;
511
+ const userNode = this.nodes.find((n) => n.id === id);
512
+ if (userNode) userNode.position = item.position;
513
+ }
514
+ });
515
+
516
+ // Recalculate all absolute positions to ensure children follow parents
517
+ updateAbsolutePositions(this._state.nodeLookup, this._state.parentLookup, {
518
+ nodeOrigin: this._state.nodeOrigin,
519
+ nodeExtent: this._state.nodeExtent,
520
+ });
521
+
522
+ // Trigger update via signal
523
+ this._state.nodes.set([...this.nodes]);
524
+ },
525
+ unselectNodesAndEdges: () => {},
526
+ }),
527
+ });
528
+ this._drags.set(id, dragInstance);
529
+ }
530
+
531
+ dragInstance.update({
532
+ domNode: el as HTMLElement,
533
+ nodeId: id,
534
+ });
535
+ }
536
+ });
537
+
538
+ // Clean up stale drag instances
539
+ for (const id of this._drags.keys()) {
540
+ if (!currentIds.has(id)) {
541
+ this._drags.delete(id);
542
+ }
543
+ }
544
+ }
545
+
546
+ private _renderEdge(edge: any) {
547
+ const sourceNode = this._state.nodeLookup.get(edge.source);
548
+ const targetNode = this._state.nodeLookup.get(edge.target);
549
+ if (!sourceNode || !targetNode) return null;
550
+
551
+ // Check if either node is hidden in the user-facing nodes array
552
+ const userSource = this.nodes.find(n => n.id === edge.source);
553
+ const userTarget = this.nodes.find(n => n.id === edge.target);
554
+ if (userSource?.hidden || userTarget?.hidden) return null;
555
+
556
+ const sourceHandle = (sourceNode.internals.handleBounds?.source || []).find(
557
+ (h: any) => h.id === (edge.sourceHandle || null)
558
+ ) || sourceNode.internals.handleBounds?.source?.[0] || {
559
+ id: null,
560
+ type: 'source',
561
+ nodeId: edge.source,
562
+ position: Position.Bottom,
563
+ x: (sourceNode.measured.width || 0) / 2,
564
+ y: sourceNode.measured.height || 0,
565
+ width: 1,
566
+ height: 1
567
+ };
568
+
569
+ const targetHandle = (targetNode.internals.handleBounds?.target || []).find(
570
+ (h: any) => h.id === (edge.targetHandle || null)
571
+ ) || targetNode.internals.handleBounds?.target?.[0] || {
572
+ id: null,
573
+ type: 'target',
574
+ nodeId: edge.target,
575
+ position: Position.Top,
576
+ x: (targetNode.measured.width || 0) / 2,
577
+ y: 0,
578
+ width: 1,
579
+ height: 1
580
+ };
581
+
582
+ const sPos = getHandlePosition(sourceNode, sourceHandle, sourceHandle.position, true);
583
+ const tPos = getHandlePosition(targetNode, targetHandle, targetHandle.position, true);
584
+
585
+ let path = '';
586
+ const pathParams = {
587
+ sourceX: sPos.x,
588
+ sourceY: sPos.y,
589
+ sourcePosition: sourceHandle.position,
590
+ targetX: tPos.x,
591
+ targetY: tPos.y,
592
+ targetPosition: targetHandle.position,
593
+ };
594
+
595
+ switch (edge.type) {
596
+ case 'straight':
597
+ [path] = getStraightPath(pathParams);
598
+ break;
599
+ case 'smoothstep':
600
+ [path] = getSmoothStepPath(pathParams);
601
+ break;
602
+ case 'step':
603
+ [path] = getSmoothStepPath({ ...pathParams, borderRadius: 0 });
604
+ break;
605
+ case 'bezier':
606
+ default:
607
+ [path] = getBezierPath(pathParams);
608
+ break;
609
+ }
610
+
611
+ return svg`
612
+ <path
613
+ d="${path}"
614
+ fill="none"
615
+ stroke="${edge.selected ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline-variant)'}"
616
+ stroke-width="2"
617
+ style="pointer-events: none;"
618
+ />
619
+ `;
620
+ }
621
+
622
+ private _onHandlePointerDown(e: CustomEvent) {
623
+ const { event, handleId, nodeId, type, handleDomNode } = e.detail;
624
+ const isTarget = type === 'target';
625
+
626
+ // Prevent starting a new connection if one is already in progress or if connectable is false
627
+ if (this._state.connectionInProgress.get() || !this.nodesConnectable) {
628
+ return;
629
+ }
630
+
631
+ const bounds = handleDomNode.getBoundingClientRect();
632
+ const nodeRect = handleDomNode.parentElement?.getBoundingClientRect();
633
+ const zoom = this._state.transform.get()[2];
634
+
635
+ const fromHandle = {
636
+ id: handleId,
637
+ nodeId,
638
+ type,
639
+ position: handleDomNode.position,
640
+ x: (bounds.left - (nodeRect?.left ?? 0)) / zoom,
641
+ y: (bounds.top - (nodeRect?.top ?? 0)) / zoom,
642
+ width: bounds.width / zoom,
643
+ height: bounds.height / zoom,
644
+ };
645
+
646
+ XYHandle.onPointerDown(event, {
647
+ handleId,
648
+ nodeId,
649
+ isTarget,
650
+ domNode: this._renderer as HTMLDivElement,
651
+ handleDomNode,
652
+ nodeLookup: this._state.nodeLookup,
653
+ connectionMode: ConnectionMode.Strict,
654
+ lib: 'lit',
655
+ autoPanOnConnect: true,
656
+ flowId: 'lit-flow',
657
+ dragThreshold: 0,
658
+ panBy: async (delta) => {
659
+ const viewport = this._panZoom?.getViewport();
660
+ if (!viewport) return false;
661
+ await this._panZoom?.setViewport({
662
+ x: viewport.x + delta.x,
663
+ y: viewport.y + delta.y,
664
+ zoom: viewport.zoom,
665
+ });
666
+ return true;
667
+ },
668
+ getTransform: () => this._state.transform.get(),
669
+ getFromHandle: () => fromHandle,
670
+ updateConnection: (conn) => {
671
+ if (conn.inProgress) {
672
+ this._state.connectionInProgress.set(conn);
673
+ } else {
674
+ this._state.connectionInProgress.set(null);
675
+ }
676
+ },
677
+ cancelConnection: () => {
678
+ this._state.connectionInProgress.set(null);
679
+ },
680
+ onConnect: (connection) => {
681
+ this.dispatchEvent(new CustomEvent('connect', {
682
+ detail: connection
683
+ }));
684
+ // Default behavior: add the edge
685
+ const id = `e-${connection.source}${connection.sourceHandle || ''}-${connection.target}${connection.targetHandle || ''}`;
686
+ this.edges = [...this.edges, { ...connection, id }];
687
+ },
688
+ connectionRadius: 20,
689
+ });
690
+ }
691
+
692
+ private _renderConnectionLine(conn: any) {
693
+ if (!conn) return null;
694
+
695
+ const [path] = getBezierPath({
696
+ sourceX: conn.from.x,
697
+ sourceY: conn.from.y,
698
+ sourcePosition: conn.fromPosition,
699
+ targetX: conn.to.x,
700
+ targetY: conn.to.y,
701
+ targetPosition: conn.toPosition,
702
+ });
703
+
704
+ return svg`
705
+ <path
706
+ class="xyflow__connection-path"
707
+ d="${path}"
708
+ fill="none"
709
+ stroke="#b1b1b7"
710
+ stroke-width="2"
711
+ stroke-dasharray="5,5"
712
+ />
713
+ `;
714
+ }
715
+
716
+ render() {
717
+ const transform = this._state.transform.get();
718
+ const connectionInProgress = this._state.connectionInProgress.get();
719
+
720
+ return html`
721
+ <div class="xyflow__renderer ${this.showGrid ? 'has-grid' : ''}">
722
+ <div
723
+ class="xyflow__viewport"
724
+ style="transform: translate(${transform[0]}px, ${transform[1]}px) scale(${transform[2]})"
725
+ >
726
+ <div class="xyflow__nodes" @handle-pointer-down="${this._onHandlePointerDown}">
727
+ ${this.nodes.map((node) => {
728
+ if (node.hidden) return null;
729
+ const internalNode = this._state.nodeLookup.get(node.id);
730
+ const pos = internalNode?.internals.positionAbsolute || node.position;
731
+ const tagName = this.nodeTypes[node.type || 'default'] || this.nodeTypes.default;
732
+ const tag = unsafeStatic(tagName);
733
+
734
+ const style = (node as any).style || {};
735
+ const styleString = Object.entries(style)
736
+ .map(([k, v]) => `${k.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${typeof v === 'number' ? `${v}px` : v}`)
737
+ .join('; ');
738
+
739
+ const width = (node as any).width || style.width;
740
+ const height = (node as any).height || style.height;
741
+ const widthStyle = width ? `width: ${typeof width === 'number' ? `${width}px` : width};` : '';
742
+ const heightStyle = height ? `height: ${typeof height === 'number' ? `${height}px` : height};` : '';
743
+ const zIndex = (node as any).zIndex ? `z-index: ${(node as any).zIndex};` : '';
744
+
745
+ return html`
746
+ <${tag}
747
+ class="xyflow__node"
748
+ data-id="${node.id}"
749
+ type="${node.type || 'default'}"
750
+ .nodeId="${node.id}"
751
+ style="transform: translate(${pos.x}px, ${pos.y}px); ${styleString} ${widthStyle} ${heightStyle} ${zIndex}"
752
+ .data="${node.data}"
753
+ .label="${(node.data as any).label}"
754
+ .type="${node.type || 'default'}"
755
+ ?selected="${node.selected}"
756
+ >
757
+ </${tag}>
758
+ `;
759
+ })}
760
+ </div>
761
+ <svg class="xyflow__edges">
762
+ ${this.edges.map((edge) => this._renderEdge(edge))}
763
+ ${this._renderConnectionLine(connectionInProgress)}
764
+ </svg>
765
+ </div>
766
+ </div>
767
+ ${this.showControls
768
+ ? html`<lit-controls .panZoom="${this._panZoom}"></lit-controls>`
769
+ : ''}
770
+ ${this.showMinimap
771
+ ? html`
772
+ <lit-minimap
773
+ .panZoom="${this._panZoom}"
774
+ .nodeLookup="${this._state.nodeLookup}"
775
+ .transform="${this._state.transform.get()}"
776
+ .translateExtent="${this._state.nodeExtent}"
777
+ .width="${this._width}"
778
+ .height="${this._height}"
779
+ ></lit-minimap>
780
+ `
781
+ : ''}
782
+ `;
783
+ }
784
+ }