@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 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 (node.position)
222
- this.positions[id] = node.position;
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
- this.sizes = { ...this.sizes, [nodeId]: size };
314
+ updatedSizes[nodeId] = size;
309
315
  }
310
316
  else {
311
- this.sizes = lod.omit(this.sizes, nodeId);
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] || { x: 0, y: 0 };
483
- const size = getNodeSize?.(node.nodeId, node.typeId) || {
484
- width: 200,
485
- height: 100,
486
- };
487
- minX = Math.min(minX, pos.x);
488
- minY = Math.min(minY, pos.y);
489
- maxX = Math.max(maxX, pos.x + size.width);
490
- maxY = Math.max(maxY, pos.y + size.height);
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] || { x: 0, y: 0 };
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
- x: pos.x - centerX,
519
- y: pos.y - centerY,
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] || { x: 0, y: 0 };
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
- }, { inputs: { [singleTarget.inputHandle]: coerced } });
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
- }, { inputs: { [arrTarget.inputHandle]: coerced } });
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
- }, { inputs: { [elemTarget.inputHandle]: coercedItems[idx] } });
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] || { x: 0, y: 0 };
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] || { x: 0, y: 0 };
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 + (kind === "input" ? " left-2" : " right-2"), style: {
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, position: opts.position }, { inputs: opts.inputs, commit: true }), [wb]);
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.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", 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 })] })] }));
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;