@bian-womp/spark-workbench 0.2.85 → 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
@@ -4453,7 +4453,7 @@ function DefaultNodeHeader({ id, typeId, title, validation, right, showId, onInv
4453
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: {
4454
4454
  maxHeight: NODE_HEADER_HEIGHT_PX,
4455
4455
  minHeight: NODE_HEADER_HEIGHT_PX,
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: `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) => {
4457
4457
  e.stopPropagation();
4458
4458
  handleInvalidate();
4459
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")
@@ -4464,7 +4464,7 @@ function DefaultNodeHeader({ id, typeId, title, validation, right, showId, onInv
4464
4464
  }
4465
4465
 
4466
4466
  function NodeHandleItem({ kind, id, type, position, y, isConnectable, className, labelClassName, renderLabel, }) {
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: {
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: {
4468
4468
  top: (y ?? 0) - 8,
4469
4469
  ...(kind === "input"
4470
4470
  ? { right: "50%" }
@@ -4576,7 +4576,7 @@ function DefaultNodeContent({ data, isConnectable, }) {
4576
4576
  isDefault: false,
4577
4577
  };
4578
4578
  })();
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", 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 }))] }));
4580
4580
  } })] }));
4581
4581
  }
4582
4582
 
@@ -5490,6 +5490,601 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5490
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))] }));
5491
5491
  });
5492
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
+
5493
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, }) {
5494
6089
  const { wb, runner, registry, selectedNodeId, runAutoLayout } = useWorkbenchContext();
5495
6090
  const [transportStatus, setTransportStatus] = React.useState({
@@ -5578,6 +6173,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
5578
6173
  return defaultExamples;
5579
6174
  }, [overrides, defaultExamples]);
5580
6175
  const canvasRef = React.useRef(null);
6176
+ const canvasContainerRef = React.useRef(null);
5581
6177
  const uploadInputRef = React.useRef(null);
5582
6178
  const [registryReady, setRegistryReady] = React.useState(() => {
5583
6179
  // For local backends, registry is always ready
@@ -5939,7 +6535,9 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
5939
6535
  // Normal change when not running
5940
6536
  onEngineChange?.(kind);
5941
6537
  }
5942
- }, 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 })] })] }));
5943
6541
  }
5944
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, }) {
5945
6543
  const [registry, setRegistry] = React.useState(sparkGraph.createSimpleGraphRegistry());
@@ -6029,6 +6627,7 @@ exports.WorkbenchCanvas = WorkbenchCanvas;
6029
6627
  exports.WorkbenchContext = WorkbenchContext;
6030
6628
  exports.WorkbenchProvider = WorkbenchProvider;
6031
6629
  exports.WorkbenchStudio = WorkbenchStudio;
6630
+ exports.captureCanvasThumbnail = captureCanvasThumbnail;
6032
6631
  exports.computeEffectiveHandles = computeEffectiveHandles;
6033
6632
  exports.countVisibleHandles = countVisibleHandles;
6034
6633
  exports.createCopyHandler = createCopyHandler;
@@ -6039,6 +6638,7 @@ exports.createNodeContextMenuHandlers = createNodeContextMenuHandlers;
6039
6638
  exports.createNodeCopyHandler = createNodeCopyHandler;
6040
6639
  exports.createSelectionContextMenuHandlers = createSelectionContextMenuHandlers;
6041
6640
  exports.download = download;
6641
+ exports.downloadCanvasThumbnail = downloadCanvasThumbnail;
6042
6642
  exports.estimateNodeSize = estimateNodeSize;
6043
6643
  exports.excludeViewportFromUIState = excludeViewportFromUIState;
6044
6644
  exports.formatDataUrlAsLabel = formatDataUrlAsLabel;