@bian-womp/spark-workbench 0.2.84 → 0.2.86
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/lib/cjs/index.cjs +665 -36
- package/lib/cjs/index.cjs.map +1 -1
- package/lib/cjs/src/core/InMemoryWorkbench.d.ts +11 -3
- package/lib/cjs/src/core/InMemoryWorkbench.d.ts.map +1 -1
- package/lib/cjs/src/index.d.ts +1 -0
- package/lib/cjs/src/index.d.ts.map +1 -1
- package/lib/cjs/src/misc/DefaultNodeContent.d.ts.map +1 -1
- package/lib/cjs/src/misc/WorkbenchStudio.d.ts.map +1 -1
- package/lib/cjs/src/misc/thumbnail-utils.d.ts +17 -0
- package/lib/cjs/src/misc/thumbnail-utils.d.ts.map +1 -0
- package/lib/esm/index.js +665 -38
- package/lib/esm/index.js.map +1 -1
- package/lib/esm/src/core/InMemoryWorkbench.d.ts +11 -3
- package/lib/esm/src/core/InMemoryWorkbench.d.ts.map +1 -1
- package/lib/esm/src/index.d.ts +1 -0
- package/lib/esm/src/index.d.ts.map +1 -1
- package/lib/esm/src/misc/DefaultNodeContent.d.ts.map +1 -1
- package/lib/esm/src/misc/WorkbenchStudio.d.ts.map +1 -1
- package/lib/esm/src/misc/thumbnail-utils.d.ts +17 -0
- package/lib/esm/src/misc/thumbnail-utils.d.ts.map +1 -0
- package/package.json +4 -4
package/lib/cjs/index.cjs
CHANGED
|
@@ -159,9 +159,11 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
159
159
|
const defNodeIds = new Set(this._def.nodes.map((n) => n.nodeId));
|
|
160
160
|
const defEdgeIds = new Set(this._def.edges.map((e) => e.id));
|
|
161
161
|
const filteredPositions = Object.fromEntries(Object.entries(this.positions).filter(([id]) => defNodeIds.has(id)));
|
|
162
|
+
const filteredSizes = Object.fromEntries(Object.entries(this.sizes).filter(([id]) => defNodeIds.has(id)));
|
|
162
163
|
const filteredNodes = this.selection.nodes.filter((id) => defNodeIds.has(id));
|
|
163
164
|
const filteredEdges = this.selection.edges.filter((id) => defEdgeIds.has(id));
|
|
164
165
|
this.positions = filteredPositions;
|
|
166
|
+
this.sizes = filteredSizes;
|
|
165
167
|
this.selection = { nodes: filteredNodes, edges: filteredEdges };
|
|
166
168
|
this.emit("graphChanged", { def: this._def });
|
|
167
169
|
this.refreshValidation();
|
|
@@ -218,8 +220,10 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
218
220
|
params: node.params,
|
|
219
221
|
resolvedHandles: node.resolvedHandles,
|
|
220
222
|
});
|
|
221
|
-
if (
|
|
222
|
-
this.positions[id] =
|
|
223
|
+
if (options?.position)
|
|
224
|
+
this.positions[id] = options.position;
|
|
225
|
+
if (options?.size)
|
|
226
|
+
this.sizes[id] = options.size;
|
|
223
227
|
this.emit("graphChanged", {
|
|
224
228
|
def: this._def,
|
|
225
229
|
change: {
|
|
@@ -237,6 +241,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
237
241
|
this._def.nodes = this._def.nodes.filter((n) => n.nodeId !== nodeId);
|
|
238
242
|
this._def.edges = this._def.edges.filter((e) => e.source.nodeId !== nodeId && e.target.nodeId !== nodeId);
|
|
239
243
|
delete this.positions[nodeId];
|
|
244
|
+
delete this.sizes[nodeId];
|
|
240
245
|
delete this.nodeNames[nodeId];
|
|
241
246
|
this.emit("graphChanged", {
|
|
242
247
|
def: this._def,
|
|
@@ -303,14 +308,16 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
303
308
|
return { ...this.positions };
|
|
304
309
|
}
|
|
305
310
|
setSizes(sizes, options) {
|
|
311
|
+
const updatedSizes = { ...this.sizes };
|
|
306
312
|
for (const [nodeId, size] of Object.entries(sizes)) {
|
|
307
313
|
if (size) {
|
|
308
|
-
|
|
314
|
+
updatedSizes[nodeId] = size;
|
|
309
315
|
}
|
|
310
316
|
else {
|
|
311
|
-
|
|
317
|
+
delete updatedSizes[nodeId];
|
|
312
318
|
}
|
|
313
319
|
}
|
|
320
|
+
this.sizes = updatedSizes;
|
|
314
321
|
this.emit("graphUiChanged", {
|
|
315
322
|
change: { type: "resizeNodes" },
|
|
316
323
|
...options,
|
|
@@ -468,6 +475,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
468
475
|
if (selection.nodes.length === 0)
|
|
469
476
|
return null;
|
|
470
477
|
const positions = this.getPositions();
|
|
478
|
+
const sizes = this.getSizes();
|
|
471
479
|
const selectedNodeSet = new Set(selection.nodes);
|
|
472
480
|
// Collect nodes to copy
|
|
473
481
|
const nodesToCopy = this.def.nodes.filter((n) => selectedNodeSet.has(n.nodeId));
|
|
@@ -479,15 +487,16 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
479
487
|
let maxX = -Infinity;
|
|
480
488
|
let maxY = -Infinity;
|
|
481
489
|
nodesToCopy.forEach((node) => {
|
|
482
|
-
const pos = positions[node.nodeId]
|
|
483
|
-
const size = getNodeSize?.(node.nodeId, node.typeId)
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
490
|
+
const pos = positions[node.nodeId];
|
|
491
|
+
const size = sizes[node.nodeId] || getNodeSize?.(node.nodeId, node.typeId);
|
|
492
|
+
if (pos) {
|
|
493
|
+
minX = Math.min(minX, pos.x);
|
|
494
|
+
minY = Math.min(minY, pos.y);
|
|
495
|
+
if (size) {
|
|
496
|
+
maxX = Math.max(maxX, pos.x + size.width);
|
|
497
|
+
maxY = Math.max(maxY, pos.y + size.height);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
491
500
|
});
|
|
492
501
|
const bounds = { minX, minY, maxX, maxY };
|
|
493
502
|
const centerX = (bounds.minX + bounds.maxX) / 2;
|
|
@@ -497,7 +506,8 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
497
506
|
const allInputs = runner.getInputs(this.def);
|
|
498
507
|
const selectedEdgeSet = new Set(selection.edges);
|
|
499
508
|
const copiedNodes = nodesToCopy.map((node) => {
|
|
500
|
-
const pos = positions[node.nodeId]
|
|
509
|
+
const pos = positions[node.nodeId];
|
|
510
|
+
const size = sizes[node.nodeId];
|
|
501
511
|
// Get all inbound edges for this node
|
|
502
512
|
const inboundEdges = this.def.edges.filter((e) => e.target.nodeId === node.nodeId);
|
|
503
513
|
// Build set of handles that have inbound edges
|
|
@@ -514,10 +524,13 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
514
524
|
typeId: node.typeId,
|
|
515
525
|
params: node.params,
|
|
516
526
|
resolvedHandles: node.resolvedHandles,
|
|
517
|
-
position:
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
527
|
+
position: pos
|
|
528
|
+
? {
|
|
529
|
+
x: pos.x - centerX,
|
|
530
|
+
y: pos.y - centerY,
|
|
531
|
+
}
|
|
532
|
+
: undefined,
|
|
533
|
+
size,
|
|
521
534
|
inputs: inputsToCopy,
|
|
522
535
|
originalNodeId: node.nodeId,
|
|
523
536
|
};
|
|
@@ -552,6 +565,7 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
552
565
|
if (selection.nodes.length === 0)
|
|
553
566
|
return [];
|
|
554
567
|
const positions = this.getPositions();
|
|
568
|
+
const sizes = this.getSizes();
|
|
555
569
|
const newNodes = [];
|
|
556
570
|
// Get inputs without bindings (literal values only)
|
|
557
571
|
const allInputs = runner.getInputs(this.def) || {};
|
|
@@ -560,7 +574,8 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
560
574
|
const n = this.def.nodes.find((n) => n.nodeId === nodeId);
|
|
561
575
|
if (!n)
|
|
562
576
|
continue;
|
|
563
|
-
const pos = positions[nodeId]
|
|
577
|
+
const pos = positions[nodeId];
|
|
578
|
+
const size = sizes[nodeId];
|
|
564
579
|
const inboundHandles = new Set(this.def.edges
|
|
565
580
|
.filter((e) => e.target.nodeId === nodeId)
|
|
566
581
|
.map((e) => e.target.handle));
|
|
@@ -568,10 +583,11 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
568
583
|
const newNodeId = this.addNode({
|
|
569
584
|
typeId: n.typeId,
|
|
570
585
|
params: n.params,
|
|
571
|
-
position: { x: pos.x + 24, y: pos.y + 24 },
|
|
572
586
|
resolvedHandles: n.resolvedHandles,
|
|
573
587
|
}, {
|
|
574
588
|
inputs: inputsWithoutBindings,
|
|
589
|
+
position: pos ? { x: pos.x + 24, y: pos.y + 24 } : undefined,
|
|
590
|
+
size,
|
|
575
591
|
copyOutputsFrom: nodeId,
|
|
576
592
|
dry: true,
|
|
577
593
|
});
|
|
@@ -618,8 +634,10 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
618
634
|
const coerced = await coerceIfNeeded(outputTypeId, inType, unwrap(outputValue));
|
|
619
635
|
newNodeId = this.addNode({
|
|
620
636
|
typeId: singleTarget.nodeTypeId,
|
|
637
|
+
}, {
|
|
638
|
+
inputs: { [singleTarget.inputHandle]: coerced },
|
|
621
639
|
position: { x: pos.x + 180, y: pos.y },
|
|
622
|
-
}
|
|
640
|
+
});
|
|
623
641
|
}
|
|
624
642
|
else if (isArray && arrTarget) {
|
|
625
643
|
const nodeDesc = registry.nodes.get(arrTarget.nodeTypeId);
|
|
@@ -627,8 +645,10 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
627
645
|
const coerced = await coerceIfNeeded(outputTypeId, inType, unwrap(outputValue));
|
|
628
646
|
newNodeId = this.addNode({
|
|
629
647
|
typeId: arrTarget.nodeTypeId,
|
|
648
|
+
}, {
|
|
630
649
|
position: { x: pos.x + 180, y: pos.y },
|
|
631
|
-
|
|
650
|
+
inputs: { [arrTarget.inputHandle]: coerced },
|
|
651
|
+
});
|
|
632
652
|
}
|
|
633
653
|
else if (isArray && elemTarget) {
|
|
634
654
|
const nodeDesc = registry.nodes.get(elemTarget.nodeTypeId);
|
|
@@ -644,8 +664,10 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
644
664
|
const row = Math.floor(idx / COLS);
|
|
645
665
|
newNodeId = this.addNode({
|
|
646
666
|
typeId: elemTarget.nodeTypeId,
|
|
667
|
+
}, {
|
|
647
668
|
position: { x: pos.x + (col + 1) * DX, y: pos.y + row * DY },
|
|
648
|
-
|
|
669
|
+
inputs: { [elemTarget.inputHandle]: coercedItems[idx] },
|
|
670
|
+
});
|
|
649
671
|
}
|
|
650
672
|
}
|
|
651
673
|
if (newNodeId) {
|
|
@@ -667,7 +689,8 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
667
689
|
const n = this.def.nodes.find((n) => n.nodeId === nodeId);
|
|
668
690
|
if (!n)
|
|
669
691
|
return undefined;
|
|
670
|
-
const pos = this.getPositions()[nodeId]
|
|
692
|
+
const pos = this.getPositions()[nodeId];
|
|
693
|
+
const size = this.getSizes()[nodeId];
|
|
671
694
|
// Get inputs without bindings (literal values only)
|
|
672
695
|
const allInputs = runner.getInputs(this.def)[nodeId] || {};
|
|
673
696
|
const inboundHandles = new Set(this.def.edges
|
|
@@ -677,10 +700,11 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
677
700
|
const newNodeId = this.addNode({
|
|
678
701
|
typeId: n.typeId,
|
|
679
702
|
params: n.params,
|
|
680
|
-
position: { x: pos.x + 24, y: pos.y + 24 },
|
|
681
703
|
resolvedHandles: n.resolvedHandles,
|
|
682
704
|
}, {
|
|
683
705
|
inputs: inputsWithoutBindings,
|
|
706
|
+
position: pos ? { x: pos.x + 24, y: pos.y + 24 } : undefined,
|
|
707
|
+
size,
|
|
684
708
|
copyOutputsFrom: nodeId,
|
|
685
709
|
dry: true,
|
|
686
710
|
});
|
|
@@ -698,17 +722,19 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
698
722
|
const n = this.def.nodes.find((n) => n.nodeId === nodeId);
|
|
699
723
|
if (!n)
|
|
700
724
|
return undefined;
|
|
701
|
-
const pos = this.getPositions()[nodeId]
|
|
725
|
+
const pos = this.getPositions()[nodeId];
|
|
726
|
+
const size = this.getSizes()[nodeId];
|
|
702
727
|
// Get all inputs (including those with bindings, since edges will be duplicated)
|
|
703
728
|
const inputs = runner.getInputs(this.def)[nodeId] || {};
|
|
704
729
|
// Add the duplicated node
|
|
705
730
|
const newNodeId = this.addNode({
|
|
706
731
|
typeId: n.typeId,
|
|
707
732
|
params: n.params,
|
|
708
|
-
position: { x: pos.x + 24, y: pos.y + 24 },
|
|
709
733
|
resolvedHandles: n.resolvedHandles,
|
|
710
734
|
}, {
|
|
711
735
|
inputs,
|
|
736
|
+
position: pos ? { x: pos.x + 24, y: pos.y + 24 } : undefined,
|
|
737
|
+
size,
|
|
712
738
|
copyOutputsFrom: nodeId,
|
|
713
739
|
dry: true,
|
|
714
740
|
});
|
|
@@ -742,12 +768,15 @@ class InMemoryWorkbench extends AbstractWorkbench {
|
|
|
742
768
|
typeId: nodeData.typeId,
|
|
743
769
|
params: nodeData.params,
|
|
744
770
|
resolvedHandles: nodeData.resolvedHandles,
|
|
745
|
-
position: {
|
|
746
|
-
x: nodeData.position.x + center.x,
|
|
747
|
-
y: nodeData.position.y + center.y,
|
|
748
|
-
},
|
|
749
771
|
}, {
|
|
750
772
|
inputs: nodeData.inputs,
|
|
773
|
+
position: nodeData.position
|
|
774
|
+
? {
|
|
775
|
+
x: nodeData.position.x + center.x,
|
|
776
|
+
y: nodeData.position.y + center.y,
|
|
777
|
+
}
|
|
778
|
+
: undefined,
|
|
779
|
+
size: nodeData.size,
|
|
751
780
|
copyOutputsFrom: nodeData.originalNodeId,
|
|
752
781
|
dry: true,
|
|
753
782
|
});
|
|
@@ -4424,7 +4453,7 @@ function DefaultNodeHeader({ id, typeId, title, validation, right, showId, onInv
|
|
|
4424
4453
|
return (jsxRuntime.jsxs("div", { className: "flex items-center justify-center px-2 border-b border-solid border-gray-500 dark:border-gray-400 text-gray-600 dark:text-gray-300", style: {
|
|
4425
4454
|
maxHeight: NODE_HEADER_HEIGHT_PX,
|
|
4426
4455
|
minHeight: NODE_HEADER_HEIGHT_PX,
|
|
4427
|
-
}, children: [isEditing ? (jsxRuntime.jsx("input", { ref: inputRef, type: "text", value: editValue, onChange: (e) => setEditValue(e.target.value), onBlur: handleSave, onKeyDown: handleKeyDown, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), className: "flex-1 h-full text-sm bg-transparent border border-blue-500 rounded px-1 outline-none wb-nodrag", style: { lineHeight: `${NODE_HEADER_HEIGHT_PX}px` } })) : (jsxRuntime.jsx("strong", { className: `flex-1 h-full text-sm select-none truncate ${typeId ? "cursor-text" : ""}`, style: { lineHeight: `${NODE_HEADER_HEIGHT_PX}px` }, onDoubleClick: handleDoubleClick, title: typeId ? "Double-click to rename" : undefined, children: displayName })), jsxRuntime.jsxs("div", { className: "flex items-center gap-1", children: [jsxRuntime.jsx("button", { className: "w-4 h-4 border border-gray-400 rounded text-[10px] leading-3 flex items-center justify-center", title: "Invalidate and re-run", onClick: (e) => {
|
|
4456
|
+
}, children: [isEditing ? (jsxRuntime.jsx("input", { ref: inputRef, type: "text", value: editValue, onChange: (e) => setEditValue(e.target.value), onBlur: handleSave, onKeyDown: handleKeyDown, onClick: (e) => e.stopPropagation(), onMouseDown: (e) => e.stopPropagation(), className: "flex-1 h-full text-sm bg-transparent border border-blue-500 rounded px-1 outline-none wb-nodrag", style: { lineHeight: `${NODE_HEADER_HEIGHT_PX}px` } })) : (jsxRuntime.jsx("strong", { className: `react-flow__node-title flex-1 h-full text-sm select-none truncate ${typeId ? "cursor-text" : ""}`, style: { lineHeight: `${NODE_HEADER_HEIGHT_PX}px` }, onDoubleClick: handleDoubleClick, title: typeId ? "Double-click to rename" : undefined, children: displayName })), jsxRuntime.jsxs("div", { className: "flex items-center gap-1", children: [jsxRuntime.jsx("button", { className: "w-4 h-4 border border-gray-400 rounded text-[10px] leading-3 flex items-center justify-center", title: "Invalidate and re-run", onClick: (e) => {
|
|
4428
4457
|
e.stopPropagation();
|
|
4429
4458
|
handleInvalidate();
|
|
4430
4459
|
}, children: jsxRuntime.jsx(react$1.ArrowClockwiseIcon, { size: 10 }) }), right, validation.issues && validation.issues.length > 0 && (jsxRuntime.jsx(IssueBadge, { level: validation.issues.some((i) => i.level === "error")
|
|
@@ -4435,7 +4464,7 @@ function DefaultNodeHeader({ id, typeId, title, validation, right, showId, onInv
|
|
|
4435
4464
|
}
|
|
4436
4465
|
|
|
4437
4466
|
function NodeHandleItem({ kind, id, type, position, y, isConnectable, className, labelClassName, renderLabel, }) {
|
|
4438
|
-
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(react.Handle, { id: id, type: type, position: position, isConnectable: isConnectable, className: className, style: y !== undefined ? { top: y } : undefined }), renderLabel && (jsxRuntime.jsx("div", { className: labelClassName
|
|
4467
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(react.Handle, { id: id, type: type, position: position, isConnectable: isConnectable, className: className, style: y !== undefined ? { top: y } : undefined }), renderLabel && (jsxRuntime.jsx("div", { className: `${labelClassName} ${kind === "input" ? " left-2" : " right-2"}`, style: {
|
|
4439
4468
|
top: (y ?? 0) - 8,
|
|
4440
4469
|
...(kind === "input"
|
|
4441
4470
|
? { right: "50%" }
|
|
@@ -4547,7 +4576,7 @@ function DefaultNodeContent({ data, isConnectable, }) {
|
|
|
4547
4576
|
isDefault: false,
|
|
4548
4577
|
};
|
|
4549
4578
|
})();
|
|
4550
|
-
return (jsxRuntime.jsxs("span", { className: `flex items-center gap-1 w-full ${valueText?.isDefault ? "text-gray-400" : ""}`, children: [kind === "output" ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [valueText !== undefined ? (jsxRuntime.jsx("span", { className: "opacity-60 truncate pl-1", style: { flex: 1, minWidth: 0, maxWidth: "100%" }, children: valueText.text })) : (jsxRuntime.jsx("span", { style: { flex: 1, minWidth: 0, maxWidth: "100%" } })), jsxRuntime.jsx("span", { className: "truncate shrink-0", style: valueText !== undefined ? { maxWidth: "40%" } : {}, children: prettyHandle(handleId) })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("span", { className: "truncate shrink-0", style: valueText !== undefined ? { maxWidth: "40%" } : {}, children: prettyHandle(handleId) }), valueText !== undefined && (jsxRuntime.jsx("span", { className: `truncate pr-1 ${valueText.isDefault ? "text-gray-400" : "opacity-60"}`, style: { flex: 1, minWidth: 0, maxWidth: "100%" }, children: valueText.text }))] })), hasAny && (jsxRuntime.jsx(IssueBadge, { level: hasErr ? "error" : "warning", size: 12, className: "shrink-0", title: title }))] }));
|
|
4579
|
+
return (jsxRuntime.jsxs("span", { className: `flex items-center gap-1 w-full ${valueText?.isDefault ? "text-gray-400" : ""}`, children: [kind === "output" ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [valueText !== undefined ? (jsxRuntime.jsx("span", { className: "opacity-60 truncate pl-1", style: { flex: 1, minWidth: 0, maxWidth: "100%" }, children: valueText.text })) : (jsxRuntime.jsx("span", { style: { flex: 1, minWidth: 0, maxWidth: "100%" } })), jsxRuntime.jsx("span", { className: "truncate shrink-0", style: valueText !== undefined ? { maxWidth: "40%" } : {}, children: prettyHandle(handleId) })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("span", { className: "truncate shrink-0", "data-handle-id": handleId, style: valueText !== undefined ? { maxWidth: "40%" } : {}, children: prettyHandle(handleId) }), valueText !== undefined && (jsxRuntime.jsx("span", { className: `truncate pr-1 ${valueText.isDefault ? "text-gray-400" : "opacity-60"}`, style: { flex: 1, minWidth: 0, maxWidth: "100%" }, children: valueText.text }))] })), hasAny && (jsxRuntime.jsx(IssueBadge, { level: hasErr ? "error" : "warning", size: 12, className: "shrink-0", title: title }))] }));
|
|
4551
4580
|
} })] }));
|
|
4552
4581
|
}
|
|
4553
4582
|
|
|
@@ -5160,7 +5189,7 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
|
|
|
5160
5189
|
setNodeMenuOpen(false);
|
|
5161
5190
|
setSelectionMenuOpen(false);
|
|
5162
5191
|
};
|
|
5163
|
-
const addNodeAt = React.useCallback(async (typeId, opts) => wb.addNode({ typeId
|
|
5192
|
+
const addNodeAt = React.useCallback(async (typeId, opts) => wb.addNode({ typeId }, { inputs: opts.inputs, position: opts.position, commit: true }), [wb]);
|
|
5164
5193
|
const onCloseMenu = React.useCallback(() => {
|
|
5165
5194
|
setMenuOpen(false);
|
|
5166
5195
|
}, []);
|
|
@@ -5461,6 +5490,601 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
|
|
|
5461
5490
|
(SelectionContextMenuRenderer ? (jsxRuntime.jsx(SelectionContextMenuRenderer, { open: selectionMenuOpen, clientPos: selectionMenuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })) : (jsxRuntime.jsx(SelectionContextMenu, { open: selectionMenuOpen, clientPos: selectionMenuPos, handlers: selectionContextMenuHandlers, enableKeyboardShortcuts: enableKeyboardShortcuts, keyboardShortcuts: keyboardShortcuts })))] }) }), toast && (jsxRuntime.jsx(KeyboardShortcutToast, { message: toast.message, onClose: hideToast }, toast.id))] }));
|
|
5462
5491
|
});
|
|
5463
5492
|
|
|
5493
|
+
/**
|
|
5494
|
+
* Flow thumbnail capture utility
|
|
5495
|
+
* Captures React Flow canvas as SVG image
|
|
5496
|
+
*/
|
|
5497
|
+
// ============================================================================
|
|
5498
|
+
// Utility Functions
|
|
5499
|
+
// ============================================================================
|
|
5500
|
+
/**
|
|
5501
|
+
* Parses CSS transform string to extract translate and scale values
|
|
5502
|
+
*/
|
|
5503
|
+
function parseViewportTransform(transform) {
|
|
5504
|
+
let translateX = 0;
|
|
5505
|
+
let translateY = 0;
|
|
5506
|
+
let scale = 1;
|
|
5507
|
+
if (transform && transform !== "none") {
|
|
5508
|
+
// Try translate() scale() format first
|
|
5509
|
+
const translateMatch = transform.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
|
|
5510
|
+
const scaleMatch = transform.match(/scale\(([^)]+)\)/);
|
|
5511
|
+
if (translateMatch) {
|
|
5512
|
+
translateX = parseFloat(translateMatch[1]);
|
|
5513
|
+
translateY = parseFloat(translateMatch[2]);
|
|
5514
|
+
}
|
|
5515
|
+
if (scaleMatch) {
|
|
5516
|
+
scale = parseFloat(scaleMatch[1]);
|
|
5517
|
+
}
|
|
5518
|
+
// Fallback to matrix format
|
|
5519
|
+
if (!translateMatch) {
|
|
5520
|
+
const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
|
|
5521
|
+
if (matrixMatch) {
|
|
5522
|
+
const values = matrixMatch[1]
|
|
5523
|
+
.split(",")
|
|
5524
|
+
.map((v) => parseFloat(v.trim()));
|
|
5525
|
+
if (values.length >= 6) {
|
|
5526
|
+
scale = Math.sqrt(values[0] * values[0] + values[1] * values[1]);
|
|
5527
|
+
translateX = values[4];
|
|
5528
|
+
translateY = values[5];
|
|
5529
|
+
}
|
|
5530
|
+
}
|
|
5531
|
+
}
|
|
5532
|
+
}
|
|
5533
|
+
return { translateX, translateY, scale };
|
|
5534
|
+
}
|
|
5535
|
+
/**
|
|
5536
|
+
* Calculates visible viewport bounds in flow coordinates
|
|
5537
|
+
*/
|
|
5538
|
+
function calculateVisibleBounds(viewportRect, transform) {
|
|
5539
|
+
const { translateX, translateY, scale } = transform;
|
|
5540
|
+
// Guard against division by zero
|
|
5541
|
+
if (scale === 0) {
|
|
5542
|
+
console.warn("[flowThumbnail] Viewport scale is 0, using default bounds");
|
|
5543
|
+
return {
|
|
5544
|
+
minX: -translateX,
|
|
5545
|
+
minY: -translateY,
|
|
5546
|
+
maxX: viewportRect.width - translateX,
|
|
5547
|
+
maxY: viewportRect.height - translateY,
|
|
5548
|
+
};
|
|
5549
|
+
}
|
|
5550
|
+
// Screen to flow: (screenX - translateX) / scale
|
|
5551
|
+
return {
|
|
5552
|
+
minX: (0 - translateX) / scale,
|
|
5553
|
+
minY: (0 - translateY) / scale,
|
|
5554
|
+
maxX: (viewportRect.width - translateX) / scale,
|
|
5555
|
+
maxY: (viewportRect.height - translateY) / scale,
|
|
5556
|
+
};
|
|
5557
|
+
}
|
|
5558
|
+
/**
|
|
5559
|
+
* Parses border radius string (px or rem) to pixels
|
|
5560
|
+
*/
|
|
5561
|
+
function parseBorderRadius(borderRadiusStr) {
|
|
5562
|
+
if (borderRadiusStr === "0px") {
|
|
5563
|
+
return 8; // default
|
|
5564
|
+
}
|
|
5565
|
+
const match = borderRadiusStr.match(/([\d.]+)(px|rem)/);
|
|
5566
|
+
if (match) {
|
|
5567
|
+
const value = parseFloat(match[1]);
|
|
5568
|
+
const unit = match[2];
|
|
5569
|
+
// Convert rem to px (assuming 16px base) or use px directly
|
|
5570
|
+
return unit === "rem" ? value * 16 : value;
|
|
5571
|
+
}
|
|
5572
|
+
// Try direct parseFloat as fallback
|
|
5573
|
+
const parsed = parseFloat(borderRadiusStr);
|
|
5574
|
+
return isNaN(parsed) ? 8 : parsed;
|
|
5575
|
+
}
|
|
5576
|
+
/**
|
|
5577
|
+
* Extracts stroke color from element, with fallback
|
|
5578
|
+
*/
|
|
5579
|
+
function extractStrokeColor(element) {
|
|
5580
|
+
if (element instanceof SVGPathElement) {
|
|
5581
|
+
return (element.getAttribute("stroke") ||
|
|
5582
|
+
window.getComputedStyle(element).stroke ||
|
|
5583
|
+
"#b1b1b7");
|
|
5584
|
+
}
|
|
5585
|
+
const style = window.getComputedStyle(element);
|
|
5586
|
+
return (style.borderColor || style.borderTopColor || "#6b7280" // gray-500 default
|
|
5587
|
+
);
|
|
5588
|
+
}
|
|
5589
|
+
/**
|
|
5590
|
+
* Extracts stroke/border width from element, ensuring minimum value
|
|
5591
|
+
*/
|
|
5592
|
+
function extractStrokeWidth(element, minWidth = 1) {
|
|
5593
|
+
if (element instanceof SVGPathElement) {
|
|
5594
|
+
const width = parseFloat(element.getAttribute("stroke-width") || "0") ||
|
|
5595
|
+
parseFloat(window.getComputedStyle(element).strokeWidth || "2");
|
|
5596
|
+
return width > 0 ? width : minWidth;
|
|
5597
|
+
}
|
|
5598
|
+
const style = window.getComputedStyle(element);
|
|
5599
|
+
const width = parseFloat(style.borderWidth || style.borderTopWidth || "0");
|
|
5600
|
+
return width > 0 ? width : minWidth;
|
|
5601
|
+
}
|
|
5602
|
+
/**
|
|
5603
|
+
* Checks if a rectangle intersects with visible bounds
|
|
5604
|
+
*/
|
|
5605
|
+
function isRectVisible(x, y, width, height, bounds) {
|
|
5606
|
+
return (x + width >= bounds.minX &&
|
|
5607
|
+
x <= bounds.maxX &&
|
|
5608
|
+
y + height >= bounds.minY &&
|
|
5609
|
+
y <= bounds.maxY);
|
|
5610
|
+
}
|
|
5611
|
+
/**
|
|
5612
|
+
* Parses path data to get bounding box
|
|
5613
|
+
* Handles M (moveTo), L (lineTo), C (cubic Bezier), Q (quadratic Bezier), and H/V (horizontal/vertical) commands
|
|
5614
|
+
*/
|
|
5615
|
+
function getPathBounds(pathData) {
|
|
5616
|
+
let minX = Infinity;
|
|
5617
|
+
let minY = Infinity;
|
|
5618
|
+
let maxX = -Infinity;
|
|
5619
|
+
let maxY = -Infinity;
|
|
5620
|
+
// Match coordinates from various path commands: M, L, C, Q, T, S, H, V
|
|
5621
|
+
// Pattern matches: command letter followed by coordinate pairs
|
|
5622
|
+
const coordPattern = /[MLCQTSHV](-?\d+\.?\d*),(-?\d+\.?\d*)/g;
|
|
5623
|
+
const coords = pathData.match(coordPattern);
|
|
5624
|
+
if (coords) {
|
|
5625
|
+
coords.forEach((coord) => {
|
|
5626
|
+
const match = coord.match(/(-?\d+\.?\d*),(-?\d+\.?\d*)/);
|
|
5627
|
+
if (match) {
|
|
5628
|
+
const x = parseFloat(match[1]);
|
|
5629
|
+
const y = parseFloat(match[2]);
|
|
5630
|
+
if (!isNaN(x) && !isNaN(y)) {
|
|
5631
|
+
minX = Math.min(minX, x);
|
|
5632
|
+
minY = Math.min(minY, y);
|
|
5633
|
+
maxX = Math.max(maxX, x);
|
|
5634
|
+
maxY = Math.max(maxY, y);
|
|
5635
|
+
}
|
|
5636
|
+
}
|
|
5637
|
+
});
|
|
5638
|
+
}
|
|
5639
|
+
return { minX, minY, maxX, maxY };
|
|
5640
|
+
}
|
|
5641
|
+
// ============================================================================
|
|
5642
|
+
// Edge Extraction
|
|
5643
|
+
// ============================================================================
|
|
5644
|
+
/**
|
|
5645
|
+
* Extracts visible edge paths from React Flow viewport
|
|
5646
|
+
*/
|
|
5647
|
+
function extractEdgePaths(viewport, visibleBounds) {
|
|
5648
|
+
const edges = [];
|
|
5649
|
+
let minX = Infinity;
|
|
5650
|
+
let minY = Infinity;
|
|
5651
|
+
let maxX = -Infinity;
|
|
5652
|
+
let maxY = -Infinity;
|
|
5653
|
+
const edgePathElements = viewport.querySelectorAll(".react-flow__edge-path");
|
|
5654
|
+
edgePathElements.forEach((pathEl) => {
|
|
5655
|
+
const pathData = pathEl.getAttribute("d");
|
|
5656
|
+
if (!pathData)
|
|
5657
|
+
return;
|
|
5658
|
+
const pathBounds = getPathBounds(pathData);
|
|
5659
|
+
// Only include edge if it intersects with visible viewport
|
|
5660
|
+
if (pathBounds.maxX >= visibleBounds.minX &&
|
|
5661
|
+
pathBounds.minX <= visibleBounds.maxX &&
|
|
5662
|
+
pathBounds.maxY >= visibleBounds.minY &&
|
|
5663
|
+
pathBounds.minY <= visibleBounds.maxY) {
|
|
5664
|
+
edges.push({
|
|
5665
|
+
d: pathData,
|
|
5666
|
+
stroke: extractStrokeColor(pathEl),
|
|
5667
|
+
strokeWidth: extractStrokeWidth(pathEl, 2),
|
|
5668
|
+
});
|
|
5669
|
+
// Update bounding box
|
|
5670
|
+
minX = Math.min(minX, pathBounds.minX);
|
|
5671
|
+
minY = Math.min(minY, pathBounds.minY);
|
|
5672
|
+
maxX = Math.max(maxX, pathBounds.maxX);
|
|
5673
|
+
maxY = Math.max(maxY, pathBounds.maxY);
|
|
5674
|
+
}
|
|
5675
|
+
});
|
|
5676
|
+
return { edges, bounds: { minX, minY, maxX, maxY } };
|
|
5677
|
+
}
|
|
5678
|
+
// ============================================================================
|
|
5679
|
+
// Node Extraction
|
|
5680
|
+
// ============================================================================
|
|
5681
|
+
/**
|
|
5682
|
+
* Extracts node position from transform style
|
|
5683
|
+
*/
|
|
5684
|
+
function extractNodePosition(nodeEl) {
|
|
5685
|
+
const transformStyle = nodeEl.style.transform || "";
|
|
5686
|
+
const translateMatch = transformStyle.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
|
|
5687
|
+
if (translateMatch) {
|
|
5688
|
+
return {
|
|
5689
|
+
x: parseFloat(translateMatch[1]),
|
|
5690
|
+
y: parseFloat(translateMatch[2]),
|
|
5691
|
+
};
|
|
5692
|
+
}
|
|
5693
|
+
return { x: 0, y: 0 };
|
|
5694
|
+
}
|
|
5695
|
+
/**
|
|
5696
|
+
* Extracts node dimensions from inline styles
|
|
5697
|
+
*/
|
|
5698
|
+
function extractNodeDimensions(nodeEl) {
|
|
5699
|
+
const widthMatch = nodeEl.style.width?.match(/(\d+)px/);
|
|
5700
|
+
const heightMatch = nodeEl.style.height?.match(/(\d+)px/);
|
|
5701
|
+
return {
|
|
5702
|
+
width: widthMatch ? parseFloat(widthMatch[1]) : 150,
|
|
5703
|
+
height: heightMatch ? parseFloat(heightMatch[1]) : 40,
|
|
5704
|
+
};
|
|
5705
|
+
}
|
|
5706
|
+
/**
|
|
5707
|
+
* Extracts node styles (colors, border, radius) from computed styles
|
|
5708
|
+
*/
|
|
5709
|
+
function extractNodeStyles(nodeContent) {
|
|
5710
|
+
const computedStyle = window.getComputedStyle(nodeContent);
|
|
5711
|
+
// Use gray background for nodes in thumbnail
|
|
5712
|
+
const fill = "#f3f4f6"; // gray-100 equivalent
|
|
5713
|
+
const stroke = extractStrokeColor(nodeContent);
|
|
5714
|
+
const strokeWidth = extractStrokeWidth(nodeContent, 1);
|
|
5715
|
+
const borderRadiusStr = computedStyle.borderRadius || "8px";
|
|
5716
|
+
const rx = parseBorderRadius(borderRadiusStr);
|
|
5717
|
+
const ry = rx; // Use same radius for both x and y
|
|
5718
|
+
return { fill, stroke, strokeWidth, rx, ry };
|
|
5719
|
+
}
|
|
5720
|
+
/**
|
|
5721
|
+
* Determines if a handle is a source (output) or target (input)
|
|
5722
|
+
*/
|
|
5723
|
+
function isHandleSource(handleEl) {
|
|
5724
|
+
return (handleEl.classList.contains("react-flow__handle-right") ||
|
|
5725
|
+
handleEl.classList.contains("react-flow__handle-source"));
|
|
5726
|
+
}
|
|
5727
|
+
/**
|
|
5728
|
+
* Extracts handle position and calculates absolute coordinates
|
|
5729
|
+
*/
|
|
5730
|
+
function extractHandlePosition(handleEl, nodeX, nodeY, nodeWidth, isSource) {
|
|
5731
|
+
const handleStyle = window.getComputedStyle(handleEl);
|
|
5732
|
+
const handleTop = parseFloat(handleStyle.top || "0");
|
|
5733
|
+
const handleLeft = handleStyle.left;
|
|
5734
|
+
const handleRight = handleStyle.right;
|
|
5735
|
+
const handleY = nodeY + handleTop;
|
|
5736
|
+
let handleX;
|
|
5737
|
+
if (isSource) {
|
|
5738
|
+
// Source handles are on the right edge
|
|
5739
|
+
if (handleRight !== "auto" && handleRight !== "") {
|
|
5740
|
+
const rightValue = parseFloat(handleRight) || 0;
|
|
5741
|
+
handleX = nodeX + nodeWidth + rightValue;
|
|
5742
|
+
}
|
|
5743
|
+
else {
|
|
5744
|
+
handleX = nodeX + nodeWidth;
|
|
5745
|
+
}
|
|
5746
|
+
}
|
|
5747
|
+
else {
|
|
5748
|
+
// Target handles are on the left edge
|
|
5749
|
+
if (handleLeft !== "auto" && handleLeft !== "") {
|
|
5750
|
+
const leftValue = parseFloat(handleLeft) || 0;
|
|
5751
|
+
handleX = nodeX + leftValue;
|
|
5752
|
+
}
|
|
5753
|
+
else {
|
|
5754
|
+
handleX = nodeX;
|
|
5755
|
+
}
|
|
5756
|
+
}
|
|
5757
|
+
return { x: handleX, y: handleY };
|
|
5758
|
+
}
|
|
5759
|
+
/**
|
|
5760
|
+
* Extracts handles from a node element
|
|
5761
|
+
*/
|
|
5762
|
+
function extractNodeHandles(nodeEl, nodeX, nodeY, nodeWidth) {
|
|
5763
|
+
const handles = [];
|
|
5764
|
+
const handleElements = nodeEl.querySelectorAll(".react-flow__handle");
|
|
5765
|
+
handleElements.forEach((handleEl) => {
|
|
5766
|
+
const handleStyle = window.getComputedStyle(handleEl);
|
|
5767
|
+
const handleWidth = parseFloat(handleStyle.width || "12");
|
|
5768
|
+
const handleHeight = parseFloat(handleStyle.height || "12");
|
|
5769
|
+
const isSource = isHandleSource(handleEl);
|
|
5770
|
+
const position = extractHandlePosition(handleEl, nodeX, nodeY, nodeWidth, isSource);
|
|
5771
|
+
handles.push({
|
|
5772
|
+
x: position.x,
|
|
5773
|
+
y: position.y,
|
|
5774
|
+
width: handleWidth,
|
|
5775
|
+
height: handleHeight,
|
|
5776
|
+
fill: handleStyle.backgroundColor || "rgba(255, 255, 255, 0.5)",
|
|
5777
|
+
stroke: extractStrokeColor(handleEl),
|
|
5778
|
+
strokeWidth: extractStrokeWidth(handleEl, 1),
|
|
5779
|
+
type: isSource ? "source" : "target",
|
|
5780
|
+
});
|
|
5781
|
+
});
|
|
5782
|
+
return handles;
|
|
5783
|
+
}
|
|
5784
|
+
/**
|
|
5785
|
+
* Extracts node title text and position
|
|
5786
|
+
*/
|
|
5787
|
+
function extractNodeTitle(nodeEl, nodeX, nodeY) {
|
|
5788
|
+
const titleElement = nodeEl.querySelector(".react-flow__node-title");
|
|
5789
|
+
if (!titleElement)
|
|
5790
|
+
return undefined;
|
|
5791
|
+
const titleText = titleElement.textContent || titleElement.innerText || "";
|
|
5792
|
+
if (!titleText.trim())
|
|
5793
|
+
return undefined;
|
|
5794
|
+
const titleStyle = window.getComputedStyle(titleElement);
|
|
5795
|
+
const titleRect = titleElement.getBoundingClientRect();
|
|
5796
|
+
const nodeRect = nodeEl.getBoundingClientRect();
|
|
5797
|
+
// Calculate title position relative to node (in flow coordinates)
|
|
5798
|
+
const titleRelativeX = titleRect.left - nodeRect.left;
|
|
5799
|
+
const titleRelativeY = titleRect.top - nodeRect.top;
|
|
5800
|
+
// Get font properties
|
|
5801
|
+
const fontSize = parseFloat(titleStyle.fontSize || "14");
|
|
5802
|
+
const fill = titleStyle.color || "#374151"; // gray-700 default
|
|
5803
|
+
const fontWeight = titleStyle.fontWeight || "600"; // bold default
|
|
5804
|
+
// Calculate text position (SVG text uses baseline)
|
|
5805
|
+
const lineHeight = parseFloat(titleStyle.lineHeight || String(fontSize * 1.2));
|
|
5806
|
+
const textBaselineOffset = lineHeight * 0.8; // Approximate baseline offset
|
|
5807
|
+
return {
|
|
5808
|
+
text: titleText.trim(),
|
|
5809
|
+
x: nodeX + titleRelativeX,
|
|
5810
|
+
y: nodeY + titleRelativeY + textBaselineOffset,
|
|
5811
|
+
fontSize,
|
|
5812
|
+
fill,
|
|
5813
|
+
fontWeight,
|
|
5814
|
+
};
|
|
5815
|
+
}
|
|
5816
|
+
/**
|
|
5817
|
+
* Extracts visible nodes from React Flow viewport
|
|
5818
|
+
*/
|
|
5819
|
+
function extractNodes(viewport, visibleBounds) {
|
|
5820
|
+
const nodes = [];
|
|
5821
|
+
let minX = Infinity;
|
|
5822
|
+
let minY = Infinity;
|
|
5823
|
+
let maxX = -Infinity;
|
|
5824
|
+
let maxY = -Infinity;
|
|
5825
|
+
const nodeElements = viewport.querySelectorAll(".react-flow__node");
|
|
5826
|
+
nodeElements.forEach((nodeEl) => {
|
|
5827
|
+
const position = extractNodePosition(nodeEl);
|
|
5828
|
+
const dimensions = extractNodeDimensions(nodeEl);
|
|
5829
|
+
// Get the actual node content div (first child)
|
|
5830
|
+
const nodeContent = nodeEl.firstElementChild;
|
|
5831
|
+
if (!nodeContent)
|
|
5832
|
+
return;
|
|
5833
|
+
const styles = extractNodeStyles(nodeContent);
|
|
5834
|
+
const handles = extractNodeHandles(nodeEl, position.x, position.y, dimensions.width);
|
|
5835
|
+
const title = extractNodeTitle(nodeEl, position.x, position.y);
|
|
5836
|
+
// Only include node if it's within visible viewport
|
|
5837
|
+
if (isRectVisible(position.x, position.y, dimensions.width, dimensions.height, visibleBounds)) {
|
|
5838
|
+
nodes.push({
|
|
5839
|
+
x: position.x,
|
|
5840
|
+
y: position.y,
|
|
5841
|
+
width: dimensions.width,
|
|
5842
|
+
height: dimensions.height,
|
|
5843
|
+
...styles,
|
|
5844
|
+
handles,
|
|
5845
|
+
title,
|
|
5846
|
+
});
|
|
5847
|
+
// Update bounding box
|
|
5848
|
+
minX = Math.min(minX, position.x);
|
|
5849
|
+
minY = Math.min(minY, position.y);
|
|
5850
|
+
maxX = Math.max(maxX, position.x + dimensions.width);
|
|
5851
|
+
maxY = Math.max(maxY, position.y + dimensions.height);
|
|
5852
|
+
// Update bounding box to include handles
|
|
5853
|
+
handles.forEach((handle) => {
|
|
5854
|
+
minX = Math.min(minX, handle.x);
|
|
5855
|
+
minY = Math.min(minY, handle.y);
|
|
5856
|
+
maxX = Math.max(maxX, handle.x + handle.width);
|
|
5857
|
+
maxY = Math.max(maxY, handle.y + handle.height);
|
|
5858
|
+
});
|
|
5859
|
+
}
|
|
5860
|
+
});
|
|
5861
|
+
return { nodes, bounds: { minX, minY, maxX, maxY } };
|
|
5862
|
+
}
|
|
5863
|
+
// ============================================================================
|
|
5864
|
+
// SVG Rendering
|
|
5865
|
+
// ============================================================================
|
|
5866
|
+
/**
|
|
5867
|
+
* Creates SVG element with dot pattern background (matching React Flow)
|
|
5868
|
+
*/
|
|
5869
|
+
function createSVGElement(width, height) {
|
|
5870
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
5871
|
+
svg.setAttribute("width", String(width));
|
|
5872
|
+
svg.setAttribute("height", String(height));
|
|
5873
|
+
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
5874
|
+
// Create defs section for patterns
|
|
5875
|
+
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
|
|
5876
|
+
// Create dot pattern (matching React Flow's BackgroundVariant.Dots)
|
|
5877
|
+
// React Flow uses gap={12} and size={1} by default
|
|
5878
|
+
const pattern = document.createElementNS("http://www.w3.org/2000/svg", "pattern");
|
|
5879
|
+
pattern.setAttribute("id", "dot-pattern");
|
|
5880
|
+
pattern.setAttribute("x", "0");
|
|
5881
|
+
pattern.setAttribute("y", "0");
|
|
5882
|
+
pattern.setAttribute("width", "24"); // gap between dots (matching React Flow default)
|
|
5883
|
+
pattern.setAttribute("height", "24");
|
|
5884
|
+
pattern.setAttribute("patternUnits", "userSpaceOnUse");
|
|
5885
|
+
// Create a circle for the dot (centered in the pattern cell)
|
|
5886
|
+
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
|
5887
|
+
circle.setAttribute("cx", "12"); // Center of 24x24 pattern cell
|
|
5888
|
+
circle.setAttribute("cy", "12");
|
|
5889
|
+
circle.setAttribute("r", "1"); // dot radius = 1 (matching React Flow size={1})
|
|
5890
|
+
circle.setAttribute("fill", "#f1f1f1"); // gray color matching React Flow default
|
|
5891
|
+
pattern.appendChild(circle);
|
|
5892
|
+
defs.appendChild(pattern);
|
|
5893
|
+
svg.appendChild(defs);
|
|
5894
|
+
// Create background rectangle with white base
|
|
5895
|
+
const bgRect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
5896
|
+
bgRect.setAttribute("width", String(width));
|
|
5897
|
+
bgRect.setAttribute("height", String(height));
|
|
5898
|
+
bgRect.setAttribute("fill", "#ffffff"); // Base background color
|
|
5899
|
+
svg.appendChild(bgRect);
|
|
5900
|
+
// Create pattern overlay rectangle
|
|
5901
|
+
const patternRect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
5902
|
+
patternRect.setAttribute("width", String(width));
|
|
5903
|
+
patternRect.setAttribute("height", String(height));
|
|
5904
|
+
patternRect.setAttribute("fill", "url(#dot-pattern)");
|
|
5905
|
+
svg.appendChild(patternRect);
|
|
5906
|
+
// Create group with transform
|
|
5907
|
+
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
5908
|
+
svg.appendChild(group);
|
|
5909
|
+
return { svg, group };
|
|
5910
|
+
}
|
|
5911
|
+
/**
|
|
5912
|
+
* Renders a node rectangle to SVG group
|
|
5913
|
+
*/
|
|
5914
|
+
function renderNodeRect(group, node) {
|
|
5915
|
+
const rectEl = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
5916
|
+
rectEl.setAttribute("x", String(node.x));
|
|
5917
|
+
rectEl.setAttribute("y", String(node.y));
|
|
5918
|
+
rectEl.setAttribute("width", String(node.width));
|
|
5919
|
+
rectEl.setAttribute("height", String(node.height));
|
|
5920
|
+
rectEl.setAttribute("rx", String(node.rx));
|
|
5921
|
+
rectEl.setAttribute("ry", String(node.ry));
|
|
5922
|
+
rectEl.setAttribute("fill", node.fill);
|
|
5923
|
+
rectEl.setAttribute("stroke", node.stroke);
|
|
5924
|
+
rectEl.setAttribute("stroke-width", String(node.strokeWidth));
|
|
5925
|
+
group.appendChild(rectEl);
|
|
5926
|
+
}
|
|
5927
|
+
/**
|
|
5928
|
+
* Renders a handle to SVG group
|
|
5929
|
+
*/
|
|
5930
|
+
function renderHandle(group, handle) {
|
|
5931
|
+
const handleEl = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
5932
|
+
// Handles are centered on edges
|
|
5933
|
+
handleEl.setAttribute("x", String(handle.x - handle.width / 2));
|
|
5934
|
+
handleEl.setAttribute("y", String(handle.y - handle.height / 2));
|
|
5935
|
+
handleEl.setAttribute("width", String(handle.width));
|
|
5936
|
+
handleEl.setAttribute("height", String(handle.height));
|
|
5937
|
+
handleEl.setAttribute("rx", String(handle.width / 2)); // Make handles circular/rounded
|
|
5938
|
+
handleEl.setAttribute("ry", String(handle.height / 2));
|
|
5939
|
+
handleEl.setAttribute("fill", handle.fill);
|
|
5940
|
+
handleEl.setAttribute("stroke", handle.stroke);
|
|
5941
|
+
handleEl.setAttribute("stroke-width", String(handle.strokeWidth));
|
|
5942
|
+
group.appendChild(handleEl);
|
|
5943
|
+
}
|
|
5944
|
+
/**
|
|
5945
|
+
* Renders node title text to SVG group
|
|
5946
|
+
*/
|
|
5947
|
+
function renderNodeTitle(group, title) {
|
|
5948
|
+
const textEl = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
5949
|
+
textEl.setAttribute("x", String(title.x));
|
|
5950
|
+
textEl.setAttribute("y", String(title.y));
|
|
5951
|
+
textEl.setAttribute("font-size", String(title.fontSize));
|
|
5952
|
+
textEl.setAttribute("fill", title.fill);
|
|
5953
|
+
textEl.setAttribute("font-weight", title.fontWeight);
|
|
5954
|
+
textEl.setAttribute("font-family", "system-ui, -apple-system, sans-serif");
|
|
5955
|
+
textEl.textContent = title.text;
|
|
5956
|
+
group.appendChild(textEl);
|
|
5957
|
+
}
|
|
5958
|
+
/**
|
|
5959
|
+
* Renders an edge path to SVG group
|
|
5960
|
+
*/
|
|
5961
|
+
function renderEdgePath(group, edge) {
|
|
5962
|
+
const pathEl = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
5963
|
+
pathEl.setAttribute("d", edge.d);
|
|
5964
|
+
pathEl.setAttribute("fill", "none");
|
|
5965
|
+
pathEl.setAttribute("stroke", edge.stroke);
|
|
5966
|
+
pathEl.setAttribute("stroke-width", String(edge.strokeWidth));
|
|
5967
|
+
group.appendChild(pathEl);
|
|
5968
|
+
}
|
|
5969
|
+
/**
|
|
5970
|
+
* Renders all nodes, handles, titles, and edges to SVG group
|
|
5971
|
+
*/
|
|
5972
|
+
function renderContentToSVG(group, nodes, edges, transformX, transformY) {
|
|
5973
|
+
group.setAttribute("transform", `translate(${transformX}, ${transformY})`);
|
|
5974
|
+
// Render nodes
|
|
5975
|
+
nodes.forEach((node) => {
|
|
5976
|
+
renderNodeRect(group, node);
|
|
5977
|
+
node.handles.forEach((handle) => renderHandle(group, handle));
|
|
5978
|
+
if (node.title) {
|
|
5979
|
+
renderNodeTitle(group, node.title);
|
|
5980
|
+
}
|
|
5981
|
+
});
|
|
5982
|
+
// Render edges
|
|
5983
|
+
edges.forEach((edge) => renderEdgePath(group, edge));
|
|
5984
|
+
}
|
|
5985
|
+
// ============================================================================
|
|
5986
|
+
// Main Capture Function
|
|
5987
|
+
// ============================================================================
|
|
5988
|
+
/**
|
|
5989
|
+
* Captures a React Flow container element as an SVG image and returns data URL
|
|
5990
|
+
* @param containerElement - The React Flow container DOM element
|
|
5991
|
+
* @returns Promise resolving to SVG data URL string, or null if capture fails
|
|
5992
|
+
*/
|
|
5993
|
+
async function captureCanvasThumbnail(containerElement) {
|
|
5994
|
+
if (!containerElement) {
|
|
5995
|
+
console.warn("[flowThumbnail] Container element is null");
|
|
5996
|
+
return null;
|
|
5997
|
+
}
|
|
5998
|
+
try {
|
|
5999
|
+
// Find the React Flow viewport element
|
|
6000
|
+
const reactFlowViewport = containerElement.querySelector(".react-flow__viewport");
|
|
6001
|
+
if (!reactFlowViewport) {
|
|
6002
|
+
console.warn("[flowThumbnail] React Flow viewport not found");
|
|
6003
|
+
return null;
|
|
6004
|
+
}
|
|
6005
|
+
// Parse viewport transform
|
|
6006
|
+
const viewportStyle = window.getComputedStyle(reactFlowViewport);
|
|
6007
|
+
const viewportTransform = viewportStyle.transform || viewportStyle.getPropertyValue("transform");
|
|
6008
|
+
const transform = parseViewportTransform(viewportTransform);
|
|
6009
|
+
// Calculate visible bounds
|
|
6010
|
+
const viewportRect = reactFlowViewport.getBoundingClientRect();
|
|
6011
|
+
const visibleBounds = calculateVisibleBounds(viewportRect, transform);
|
|
6012
|
+
// Extract edges and nodes
|
|
6013
|
+
const { edges, bounds: edgeBounds } = extractEdgePaths(reactFlowViewport, visibleBounds);
|
|
6014
|
+
const { nodes, bounds: nodeBounds } = extractNodes(reactFlowViewport, visibleBounds);
|
|
6015
|
+
// Calculate overall bounding box
|
|
6016
|
+
// Handle case where one or both bounds might be Infinity (no content)
|
|
6017
|
+
let minX = Infinity;
|
|
6018
|
+
let minY = Infinity;
|
|
6019
|
+
let maxX = -Infinity;
|
|
6020
|
+
let maxY = -Infinity;
|
|
6021
|
+
if (edgeBounds.minX !== Infinity) {
|
|
6022
|
+
minX = Math.min(minX, edgeBounds.minX);
|
|
6023
|
+
minY = Math.min(minY, edgeBounds.minY);
|
|
6024
|
+
maxX = Math.max(maxX, edgeBounds.maxX);
|
|
6025
|
+
maxY = Math.max(maxY, edgeBounds.maxY);
|
|
6026
|
+
}
|
|
6027
|
+
if (nodeBounds.minX !== Infinity) {
|
|
6028
|
+
minX = Math.min(minX, nodeBounds.minX);
|
|
6029
|
+
minY = Math.min(minY, nodeBounds.minY);
|
|
6030
|
+
maxX = Math.max(maxX, nodeBounds.maxX);
|
|
6031
|
+
maxY = Math.max(maxY, nodeBounds.maxY);
|
|
6032
|
+
}
|
|
6033
|
+
// If no visible content, use the visible viewport bounds
|
|
6034
|
+
if (minX === Infinity || (nodes.length === 0 && edges.length === 0)) {
|
|
6035
|
+
minX = visibleBounds.minX;
|
|
6036
|
+
minY = visibleBounds.minY;
|
|
6037
|
+
maxX = visibleBounds.maxX;
|
|
6038
|
+
maxY = visibleBounds.maxY;
|
|
6039
|
+
}
|
|
6040
|
+
// Use the visible viewport bounds exactly (what the user sees)
|
|
6041
|
+
const contentMinX = visibleBounds.minX;
|
|
6042
|
+
const contentMinY = visibleBounds.minY;
|
|
6043
|
+
const contentMaxX = visibleBounds.maxX;
|
|
6044
|
+
const contentMaxY = visibleBounds.maxY;
|
|
6045
|
+
const contentWidth = contentMaxX - contentMinX;
|
|
6046
|
+
const contentHeight = contentMaxY - contentMinY;
|
|
6047
|
+
// Create SVG
|
|
6048
|
+
const { svg, group } = createSVGElement(contentWidth, contentHeight);
|
|
6049
|
+
// Render content
|
|
6050
|
+
renderContentToSVG(group, nodes, edges, -contentMinX, -contentMinY);
|
|
6051
|
+
// Serialize SVG to string
|
|
6052
|
+
const serializer = new XMLSerializer();
|
|
6053
|
+
const svgString = serializer.serializeToString(svg);
|
|
6054
|
+
// Return SVG data URL
|
|
6055
|
+
const svgDataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgString)))}`;
|
|
6056
|
+
return svgDataUrl;
|
|
6057
|
+
}
|
|
6058
|
+
catch (error) {
|
|
6059
|
+
console.error("[flowThumbnail] Failed to capture thumbnail:", error);
|
|
6060
|
+
return null;
|
|
6061
|
+
}
|
|
6062
|
+
}
|
|
6063
|
+
/**
|
|
6064
|
+
* Captures a React Flow container element as an SVG image and downloads it
|
|
6065
|
+
* @param containerElement - The React Flow container DOM element
|
|
6066
|
+
* @returns Promise resolving to true if successful, false otherwise
|
|
6067
|
+
*/
|
|
6068
|
+
async function downloadCanvasThumbnail(containerElement) {
|
|
6069
|
+
const svgDataUrl = await captureCanvasThumbnail(containerElement);
|
|
6070
|
+
if (!svgDataUrl) {
|
|
6071
|
+
return false;
|
|
6072
|
+
}
|
|
6073
|
+
// Create blob and download
|
|
6074
|
+
const base64Data = svgDataUrl.split(",")[1];
|
|
6075
|
+
const svgString = atob(base64Data);
|
|
6076
|
+
const blob = new Blob([svgString], { type: "image/svg+xml" });
|
|
6077
|
+
const url = URL.createObjectURL(blob);
|
|
6078
|
+
const link = document.createElement("a");
|
|
6079
|
+
link.href = url;
|
|
6080
|
+
link.download = `flow-thumbnail-${Date.now()}.svg`;
|
|
6081
|
+
document.body.appendChild(link);
|
|
6082
|
+
link.click();
|
|
6083
|
+
document.body.removeChild(link);
|
|
6084
|
+
URL.revokeObjectURL(url);
|
|
6085
|
+
return true;
|
|
6086
|
+
}
|
|
6087
|
+
|
|
5464
6088
|
function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, example, onExampleChange, engine, onEngineChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, overrides, onInit, onChange, }) {
|
|
5465
6089
|
const { wb, runner, registry, selectedNodeId, runAutoLayout } = useWorkbenchContext();
|
|
5466
6090
|
const [transportStatus, setTransportStatus] = React.useState({
|
|
@@ -5549,6 +6173,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
|
|
|
5549
6173
|
return defaultExamples;
|
|
5550
6174
|
}, [overrides, defaultExamples]);
|
|
5551
6175
|
const canvasRef = React.useRef(null);
|
|
6176
|
+
const canvasContainerRef = React.useRef(null);
|
|
5552
6177
|
const uploadInputRef = React.useRef(null);
|
|
5553
6178
|
const [registryReady, setRegistryReady] = React.useState(() => {
|
|
5554
6179
|
// For local backends, registry is always ready
|
|
@@ -5910,7 +6535,9 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
|
|
|
5910
6535
|
// Normal change when not running
|
|
5911
6536
|
onEngineChange?.(kind);
|
|
5912
6537
|
}
|
|
5913
|
-
}, children: [jsxRuntime.jsx("option", { value: "", children: "Select Engine\u2026" }), jsxRuntime.jsx("option", { value: "push", children: "Push" }), jsxRuntime.jsx("option", { value: "batched", children: "Batched" }), jsxRuntime.jsx("option", { value: "pull", children: "Pull" }), jsxRuntime.jsx("option", { value: "hybrid", children: "Hybrid" }), jsxRuntime.jsx("option", { value: "step", children: "Step" })] }), engineKind === "step" && (jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.step(), disabled: !isGraphRunning, title: "Step", children: jsxRuntime.jsx(react$1.PlayPauseIcon, { size: 24 }) })), engineKind === "batched" && (jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.flush(), disabled: !isGraphRunning, title: "Flush", children: jsxRuntime.jsx(react$1.LightningIcon, { size: 24 }) })), renderStartStopButton(), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: runAutoLayout, children: jsxRuntime.jsx(react$1.TreeStructureIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: jsxRuntime.jsx(react$1.CornersOutIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: download$1, children: jsxRuntime.jsx(react$1.DownloadIcon, { size: 24 }) }), jsxRuntime.jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: triggerUpload, children: jsxRuntime.jsx(react$1.UploadIcon, { size: 24 }) }), jsxRuntime.
|
|
6538
|
+
}, children: [jsxRuntime.jsx("option", { value: "", children: "Select Engine\u2026" }), jsxRuntime.jsx("option", { value: "push", children: "Push" }), jsxRuntime.jsx("option", { value: "batched", children: "Batched" }), jsxRuntime.jsx("option", { value: "pull", children: "Pull" }), jsxRuntime.jsx("option", { value: "hybrid", children: "Hybrid" }), jsxRuntime.jsx("option", { value: "step", children: "Step" })] }), engineKind === "step" && (jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.step(), disabled: !isGraphRunning, title: "Step", children: jsxRuntime.jsx(react$1.PlayPauseIcon, { size: 24 }) })), engineKind === "batched" && (jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => runner.flush(), disabled: !isGraphRunning, title: "Flush", children: jsxRuntime.jsx(react$1.LightningIcon, { size: 24 }) })), renderStartStopButton(), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: runAutoLayout, children: jsxRuntime.jsx(react$1.TreeStructureIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: () => canvasRef.current?.fitView?.(), title: "Fit View", children: jsxRuntime.jsx(react$1.CornersOutIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: download$1, children: jsxRuntime.jsx(react$1.DownloadIcon, { size: 24 }) }), jsxRuntime.jsx("input", { ref: uploadInputRef, type: "file", accept: "application/json,.json", className: "hidden", onChange: onUploadPicked }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: triggerUpload, children: jsxRuntime.jsx(react$1.UploadIcon, { size: 24 }) }), jsxRuntime.jsx("button", { className: "border border-gray-300 rounded p-1", onClick: async () => {
|
|
6539
|
+
await downloadCanvasThumbnail(canvasContainerRef.current);
|
|
6540
|
+
}, title: "Download Flow Thumbnail (SVG)", children: jsxRuntime.jsx(react$1.ImageIcon, { size: 24 }) }), jsxRuntime.jsxs("label", { className: "flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: debug, onChange: (e) => onDebugChange(e.target.checked) }), jsxRuntime.jsx(react$1.BugBeetleIcon, { size: 24, weight: debug ? "fill" : undefined })] }), jsxRuntime.jsxs("label", { className: "flex items-center gap-1", children: [jsxRuntime.jsx("input", { type: "checkbox", checked: showValues, onChange: (e) => onShowValuesChange(e.target.checked) }), jsxRuntime.jsx(react$1.ListBulletsIcon, { size: 24, weight: showValues ? "fill" : undefined })] })] }), jsxRuntime.jsxs("div", { className: "flex flex-1 min-h-0", children: [jsxRuntime.jsx("div", { className: "flex-1 min-w-0", ref: canvasContainerRef, children: jsxRuntime.jsx(WorkbenchCanvas, { ref: canvasRef, showValues: showValues, toString: toString, toElement: toElement, getDefaultNodeSize: overrides?.getDefaultNodeSize }) }), jsxRuntime.jsx(Inspector, { setInput: setInput, debug: debug, autoScroll: autoScroll, hideWorkbench: hideWorkbench, onAutoScrollChange: onAutoScrollChange, onHideWorkbenchChange: onHideWorkbenchChange, toString: toString, contextPanel: overrides?.contextPanel })] })] }));
|
|
5914
6541
|
}
|
|
5915
6542
|
function WorkbenchStudio({ engine, onEngineChange, example, onExampleChange, backendKind, onBackendKindChange, httpBaseUrl, onHttpBaseUrlChange, wsUrl, onWsUrlChange, debug, onDebugChange, showValues, onShowValuesChange, hideWorkbench, onHideWorkbenchChange, autoScroll, onAutoScrollChange, backendOptions, overrides, onInit, onChange, }) {
|
|
5916
6543
|
const [registry, setRegistry] = React.useState(sparkGraph.createSimpleGraphRegistry());
|
|
@@ -6000,6 +6627,7 @@ exports.WorkbenchCanvas = WorkbenchCanvas;
|
|
|
6000
6627
|
exports.WorkbenchContext = WorkbenchContext;
|
|
6001
6628
|
exports.WorkbenchProvider = WorkbenchProvider;
|
|
6002
6629
|
exports.WorkbenchStudio = WorkbenchStudio;
|
|
6630
|
+
exports.captureCanvasThumbnail = captureCanvasThumbnail;
|
|
6003
6631
|
exports.computeEffectiveHandles = computeEffectiveHandles;
|
|
6004
6632
|
exports.countVisibleHandles = countVisibleHandles;
|
|
6005
6633
|
exports.createCopyHandler = createCopyHandler;
|
|
@@ -6010,6 +6638,7 @@ exports.createNodeContextMenuHandlers = createNodeContextMenuHandlers;
|
|
|
6010
6638
|
exports.createNodeCopyHandler = createNodeCopyHandler;
|
|
6011
6639
|
exports.createSelectionContextMenuHandlers = createSelectionContextMenuHandlers;
|
|
6012
6640
|
exports.download = download;
|
|
6641
|
+
exports.downloadCanvasThumbnail = downloadCanvasThumbnail;
|
|
6013
6642
|
exports.estimateNodeSize = estimateNodeSize;
|
|
6014
6643
|
exports.excludeViewportFromUIState = excludeViewportFromUIState;
|
|
6015
6644
|
exports.formatDataUrlAsLabel = formatDataUrlAsLabel;
|