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