@bian-womp/spark-workbench 0.2.85 → 0.2.87

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
@@ -2190,19 +2190,26 @@ function createHandleLayout(args) {
2190
2190
  };
2191
2191
  }
2192
2192
  function layoutNode(args, overrides) {
2193
- const { node, registry, showValues, overrides: sizeOverrides } = args;
2193
+ const { node, registry, showValues, overrides: sizeOverrides, missingInputs = [], missingOutputs = [], } = args;
2194
2194
  const { inputs, outputs } = computeEffectiveHandles(node, registry);
2195
2195
  const inputOrder = Object.keys(inputs).filter((k) => !sparkGraph.isInputPrivate(inputs, k));
2196
2196
  const outputOrder = Object.keys(outputs);
2197
2197
  const estimateNodeSizeFn = overrides?.estimateNodeSize ?? estimateNodeSize;
2198
2198
  const createHandleBoundsFn = overrides?.createHandleBounds ?? createHandleBounds;
2199
2199
  const createHandleLayoutFn = overrides?.createHandleLayout ?? createHandleLayout;
2200
- const { width, height } = estimateNodeSizeFn({
2200
+ // Calculate base dimensions
2201
+ const baseRows = Math.max(inputOrder.length, outputOrder.length);
2202
+ const { width: baseWidth, height: baseHeight } = estimateNodeSizeFn({
2201
2203
  node,
2202
2204
  registry,
2203
2205
  showValues,
2204
2206
  overrides: sizeOverrides,
2205
2207
  });
2208
+ // Calculate final dimensions accounting for missing handles
2209
+ const finalRows = Math.max(inputOrder.length + missingInputs.length, outputOrder.length + missingOutputs.length);
2210
+ const width = baseWidth;
2211
+ const height = baseHeight + Math.max(0, finalRows - baseRows) * NODE_ROW_HEIGHT_PX;
2212
+ // Create bounds and layouts for regular handles
2206
2213
  const handles = [
2207
2214
  ...inputOrder.map((id, i) => createHandleBoundsFn({
2208
2215
  id,
@@ -2233,7 +2240,51 @@ function layoutNode(args, overrides) {
2233
2240
  rowIndex: i,
2234
2241
  })),
2235
2242
  ];
2236
- return { width, height, inputOrder, outputOrder, handles, handleLayout };
2243
+ // Create bounds and layouts for missing handles
2244
+ const missingHandleBounds = [
2245
+ ...missingInputs.map((id, i) => createHandleBoundsFn({
2246
+ id,
2247
+ type: "target",
2248
+ position: react.Position.Left,
2249
+ rowIndex: inputOrder.length + i,
2250
+ nodeWidth: width,
2251
+ })),
2252
+ ...missingOutputs.map((id, i) => createHandleBoundsFn({
2253
+ id,
2254
+ type: "source",
2255
+ position: react.Position.Right,
2256
+ rowIndex: outputOrder.length + i,
2257
+ nodeWidth: width,
2258
+ })),
2259
+ ];
2260
+ const missingHandleLayout = [
2261
+ ...missingInputs.map((id, i) => ({
2262
+ ...createHandleLayoutFn({
2263
+ id,
2264
+ type: "target",
2265
+ position: react.Position.Left,
2266
+ rowIndex: inputOrder.length + i,
2267
+ }),
2268
+ missing: true,
2269
+ })),
2270
+ ...missingOutputs.map((id, i) => ({
2271
+ ...createHandleLayoutFn({
2272
+ id,
2273
+ type: "source",
2274
+ position: react.Position.Right,
2275
+ rowIndex: outputOrder.length + i,
2276
+ }),
2277
+ missing: true,
2278
+ })),
2279
+ ];
2280
+ return {
2281
+ width,
2282
+ height,
2283
+ inputOrder,
2284
+ outputOrder,
2285
+ handles: [...handles, ...missingHandleBounds],
2286
+ handleLayout: [...handleLayout, ...missingHandleLayout],
2287
+ };
2237
2288
  }
2238
2289
 
2239
2290
  function useWorkbenchBridge(wb) {
@@ -2505,10 +2556,13 @@ function toReactFlow(def, positions, sizes, registry, opts) {
2505
2556
  const EDGE_STYLE_MISSING = { stroke: "#f59e0b", strokeWidth: 2 }; // amber-500
2506
2557
  const EDGE_STYLE_ERROR = { stroke: "#ef4444", strokeWidth: 2 };
2507
2558
  const EDGE_STYLE_RUNNING = { stroke: "#3b82f6" };
2508
- // Build a map of valid handles per node up-front
2559
+ // Build a map of valid handles per node up-front and cache handle data
2509
2560
  const validHandleMap = {};
2561
+ const nodeHandlesCache = {};
2510
2562
  for (const n of def.nodes) {
2511
- const { inputs, outputs } = computeEffectiveHandles(n, registry);
2563
+ const handles = computeEffectiveHandles(n, registry);
2564
+ nodeHandlesCache[n.nodeId] = handles;
2565
+ const { inputs, outputs } = handles;
2512
2566
  const inputOrder = Object.keys(inputs).filter((k) => !sparkGraph.isInputPrivate(inputs, k));
2513
2567
  const outputOrder = Object.keys(outputs);
2514
2568
  validHandleMap[n.nodeId] = {
@@ -2537,87 +2591,36 @@ function toReactFlow(def, positions, sizes, registry, opts) {
2537
2591
  (missingOutputsByNode[srcId] || (missingOutputsByNode[srcId] = new Set())).add(srcHandle);
2538
2592
  }
2539
2593
  }
2540
- // This map is still used later for certain checks; align with valid handles
2541
- const nodeHandleMap = {};
2542
- Object.assign(nodeHandleMap, validHandleMap);
2543
2594
  // Get layout function overrides from UI registry
2544
2595
  const layoutNodeOverride = opts.ui?.getLayoutNode() ?? layoutNode;
2545
2596
  const createHandleBoundsFn = opts.ui?.getCreateHandleBounds() ?? createHandleBounds;
2546
2597
  const createHandleLayoutFn = opts.ui?.getCreateHandleLayout() ?? createHandleLayout;
2547
2598
  const estimateNodeSizeFn = opts.ui?.getEstimateNodeSize() ?? estimateNodeSize;
2548
- const computeLayout = (node, overrides) => {
2599
+ const computeLayout = (node, overrides, missingInputs, missingOutputs) => {
2549
2600
  return layoutNodeOverride
2550
2601
  ? layoutNodeOverride({
2551
2602
  node,
2552
2603
  registry,
2553
2604
  showValues: opts.showValues,
2554
2605
  overrides,
2606
+ missingInputs,
2607
+ missingOutputs,
2555
2608
  })
2556
2609
  : layoutNode({
2557
2610
  node,
2558
2611
  registry,
2559
2612
  showValues: opts.showValues,
2560
2613
  overrides,
2614
+ missingInputs,
2615
+ missingOutputs,
2561
2616
  }, {
2562
2617
  estimateNodeSize: estimateNodeSizeFn,
2563
2618
  createHandleBounds: createHandleBoundsFn,
2564
2619
  createHandleLayout: createHandleLayoutFn,
2565
2620
  });
2566
2621
  };
2567
- const calculateDimensionsWithMissingHandles = (geom, extraInputs, extraOutputs) => {
2568
- const baseLeftCount = geom.inputOrder.length;
2569
- const baseRightCount = geom.outputOrder.length;
2570
- const baseRows = Math.max(baseLeftCount, baseRightCount);
2571
- const newRows = Math.max(baseLeftCount + extraInputs.length, baseRightCount + extraOutputs.length);
2572
- return {
2573
- baseLeftCount,
2574
- baseRightCount,
2575
- baseRows,
2576
- newRows,
2577
- width: geom.width,
2578
- height: geom.height + Math.max(0, newRows - baseRows) * NODE_ROW_HEIGHT_PX,
2579
- };
2580
- };
2581
- const createMissingHandleLayouts = (extraInputs, extraOutputs, baseLeftCount, baseRightCount) => {
2582
- const left = extraInputs.map((id, i) => ({
2583
- ...createHandleLayoutFn({
2584
- id,
2585
- type: "target",
2586
- position: react.Position.Left,
2587
- rowIndex: baseLeftCount + i,
2588
- }),
2589
- missing: true,
2590
- }));
2591
- const right = extraOutputs.map((id, i) => ({
2592
- ...createHandleLayoutFn({
2593
- id,
2594
- type: "source",
2595
- position: react.Position.Right,
2596
- rowIndex: baseRightCount + i,
2597
- }),
2598
- missing: true,
2599
- }));
2600
- return [...left, ...right];
2601
- };
2602
- const createMissingHandleBounds = (extraInputs, extraOutputs, baseLeftCount, baseRightCount, nodeWidth) => {
2603
- const left = extraInputs.map((id, i) => createHandleBoundsFn({
2604
- id,
2605
- type: "target",
2606
- position: react.Position.Left,
2607
- rowIndex: baseLeftCount + i,
2608
- nodeWidth,
2609
- }));
2610
- const right = extraOutputs.map((id, i) => createHandleBoundsFn({
2611
- id,
2612
- type: "source",
2613
- position: react.Position.Right,
2614
- rowIndex: baseRightCount + i,
2615
- nodeWidth,
2616
- }));
2617
- return [...left, ...right];
2618
- };
2619
2622
  const nodes = def.nodes.map((n) => {
2620
- const { inputs: inputSource, outputs: outputSource } = computeEffectiveHandles(n, registry);
2623
+ const { inputs: inputSource, outputs: outputSource } = nodeHandlesCache[n.nodeId];
2621
2624
  const overrideSize = opts.getDefaultNodeSize?.(n.typeId);
2622
2625
  const customSize = sizes?.[n.nodeId];
2623
2626
  const sizeOverrides = customSize
@@ -2625,14 +2628,12 @@ function toReactFlow(def, positions, sizes, registry, opts) {
2625
2628
  : overrideSize;
2626
2629
  const extraInputs = Array.from(missingInputsByNode[n.nodeId] || []);
2627
2630
  const extraOutputs = Array.from(missingOutputsByNode[n.nodeId] || []);
2628
- const geom = computeLayout(n, sizeOverrides);
2629
- const finalDims = calculateDimensionsWithMissingHandles(geom, extraInputs, extraOutputs);
2630
- const renderWidth = customSize?.width ?? finalDims.width;
2631
- const renderHeight = customSize?.height ?? finalDims.height;
2632
- const initialGeom = customSize ? computeLayout(n, overrideSize) : geom;
2633
- const initialDims = customSize
2634
- ? calculateDimensionsWithMissingHandles(initialGeom, extraInputs, extraOutputs)
2635
- : finalDims;
2631
+ const geom = computeLayout(n, sizeOverrides, extraInputs, extraOutputs);
2632
+ const renderWidth = customSize?.width ?? geom.width;
2633
+ const renderHeight = customSize?.height ?? geom.height;
2634
+ const initialGeom = customSize
2635
+ ? computeLayout(n, overrideSize, extraInputs, extraOutputs)
2636
+ : geom;
2636
2637
  const inputHandles = geom.inputOrder.map((id) => ({
2637
2638
  id,
2638
2639
  typeId: sparkGraph.getInputTypeId(inputSource, id),
@@ -2641,14 +2642,8 @@ function toReactFlow(def, positions, sizes, registry, opts) {
2641
2642
  id,
2642
2643
  typeId: formatDeclaredTypeSignature(outputSource[id]),
2643
2644
  }));
2644
- nodeHandleMap[n.nodeId] = {
2645
- inputs: new Set(inputHandles.map((h) => h.id)),
2646
- outputs: new Set(outputHandles.map((h) => h.id)),
2647
- };
2648
- const missingHandleLayouts = createMissingHandleLayouts(extraInputs, extraOutputs, finalDims.baseLeftCount, finalDims.baseRightCount);
2649
- const handleLayout = [...geom.handleLayout, ...missingHandleLayouts];
2650
- const missingHandleBounds = createMissingHandleBounds(extraInputs, extraOutputs, finalDims.baseLeftCount, finalDims.baseRightCount, renderWidth);
2651
- const handles = [...geom.handles, ...missingHandleBounds];
2645
+ const handleLayout = geom.handleLayout;
2646
+ const handles = geom.handles;
2652
2647
  return {
2653
2648
  id: n.nodeId,
2654
2649
  data: {
@@ -2664,8 +2659,8 @@ function toReactFlow(def, positions, sizes, registry, opts) {
2664
2659
  showValues: opts.showValues,
2665
2660
  renderWidth,
2666
2661
  renderHeight,
2667
- initialWidth: initialDims.width,
2668
- initialHeight: initialDims.height,
2662
+ initialWidth: initialGeom.width,
2663
+ initialHeight: initialGeom.height,
2669
2664
  inputValues: opts.inputs?.[n.nodeId],
2670
2665
  inputDefaults: opts.inputDefaults?.[n.nodeId],
2671
2666
  outputValues: opts.outputs?.[n.nodeId],
@@ -4453,7 +4448,7 @@ function DefaultNodeHeader({ id, typeId, title, validation, right, showId, onInv
4453
4448
  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
4449
  maxHeight: NODE_HEADER_HEIGHT_PX,
4455
4450
  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) => {
4451
+ }, 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
4452
  e.stopPropagation();
4458
4453
  handleInvalidate();
4459
4454
  }, 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 +4459,7 @@ function DefaultNodeHeader({ id, typeId, title, validation, right, showId, onInv
4464
4459
  }
4465
4460
 
4466
4461
  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: {
4462
+ 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
4463
  top: (y ?? 0) - 8,
4469
4464
  ...(kind === "input"
4470
4465
  ? { right: "50%" }
@@ -4576,7 +4571,7 @@ function DefaultNodeContent({ data, isConnectable, }) {
4576
4571
  isDefault: false,
4577
4572
  };
4578
4573
  })();
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 }))] }));
4574
+ 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
4575
  } })] }));
4581
4576
  }
4582
4577
 
@@ -5490,6 +5485,601 @@ const WorkbenchCanvas = React.forwardRef(({ showValues, toString, toElement, get
5490
5485
  (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
5486
  });
5492
5487
 
5488
+ /**
5489
+ * Flow thumbnail capture utility
5490
+ * Captures React Flow canvas as SVG image
5491
+ */
5492
+ // ============================================================================
5493
+ // Utility Functions
5494
+ // ============================================================================
5495
+ /**
5496
+ * Parses CSS transform string to extract translate and scale values
5497
+ */
5498
+ function parseViewportTransform(transform) {
5499
+ let translateX = 0;
5500
+ let translateY = 0;
5501
+ let scale = 1;
5502
+ if (transform && transform !== "none") {
5503
+ // Try translate() scale() format first
5504
+ const translateMatch = transform.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
5505
+ const scaleMatch = transform.match(/scale\(([^)]+)\)/);
5506
+ if (translateMatch) {
5507
+ translateX = parseFloat(translateMatch[1]);
5508
+ translateY = parseFloat(translateMatch[2]);
5509
+ }
5510
+ if (scaleMatch) {
5511
+ scale = parseFloat(scaleMatch[1]);
5512
+ }
5513
+ // Fallback to matrix format
5514
+ if (!translateMatch) {
5515
+ const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
5516
+ if (matrixMatch) {
5517
+ const values = matrixMatch[1]
5518
+ .split(",")
5519
+ .map((v) => parseFloat(v.trim()));
5520
+ if (values.length >= 6) {
5521
+ scale = Math.sqrt(values[0] * values[0] + values[1] * values[1]);
5522
+ translateX = values[4];
5523
+ translateY = values[5];
5524
+ }
5525
+ }
5526
+ }
5527
+ }
5528
+ return { translateX, translateY, scale };
5529
+ }
5530
+ /**
5531
+ * Calculates visible viewport bounds in flow coordinates
5532
+ */
5533
+ function calculateVisibleBounds(viewportRect, transform) {
5534
+ const { translateX, translateY, scale } = transform;
5535
+ // Guard against division by zero
5536
+ if (scale === 0) {
5537
+ console.warn("[flowThumbnail] Viewport scale is 0, using default bounds");
5538
+ return {
5539
+ minX: -translateX,
5540
+ minY: -translateY,
5541
+ maxX: viewportRect.width - translateX,
5542
+ maxY: viewportRect.height - translateY,
5543
+ };
5544
+ }
5545
+ // Screen to flow: (screenX - translateX) / scale
5546
+ return {
5547
+ minX: (0 - translateX) / scale,
5548
+ minY: (0 - translateY) / scale,
5549
+ maxX: (viewportRect.width - translateX) / scale,
5550
+ maxY: (viewportRect.height - translateY) / scale,
5551
+ };
5552
+ }
5553
+ /**
5554
+ * Parses border radius string (px or rem) to pixels
5555
+ */
5556
+ function parseBorderRadius(borderRadiusStr) {
5557
+ if (borderRadiusStr === "0px") {
5558
+ return 8; // default
5559
+ }
5560
+ const match = borderRadiusStr.match(/([\d.]+)(px|rem)/);
5561
+ if (match) {
5562
+ const value = parseFloat(match[1]);
5563
+ const unit = match[2];
5564
+ // Convert rem to px (assuming 16px base) or use px directly
5565
+ return unit === "rem" ? value * 16 : value;
5566
+ }
5567
+ // Try direct parseFloat as fallback
5568
+ const parsed = parseFloat(borderRadiusStr);
5569
+ return isNaN(parsed) ? 8 : parsed;
5570
+ }
5571
+ /**
5572
+ * Extracts stroke color from element, with fallback
5573
+ */
5574
+ function extractStrokeColor(element) {
5575
+ if (element instanceof SVGPathElement) {
5576
+ return (element.getAttribute("stroke") ||
5577
+ window.getComputedStyle(element).stroke ||
5578
+ "#b1b1b7");
5579
+ }
5580
+ const style = window.getComputedStyle(element);
5581
+ return (style.borderColor || style.borderTopColor || "#6b7280" // gray-500 default
5582
+ );
5583
+ }
5584
+ /**
5585
+ * Extracts stroke/border width from element, ensuring minimum value
5586
+ */
5587
+ function extractStrokeWidth(element, minWidth = 1) {
5588
+ if (element instanceof SVGPathElement) {
5589
+ const width = parseFloat(element.getAttribute("stroke-width") || "0") ||
5590
+ parseFloat(window.getComputedStyle(element).strokeWidth || "2");
5591
+ return width > 0 ? width : minWidth;
5592
+ }
5593
+ const style = window.getComputedStyle(element);
5594
+ const width = parseFloat(style.borderWidth || style.borderTopWidth || "0");
5595
+ return width > 0 ? width : minWidth;
5596
+ }
5597
+ /**
5598
+ * Checks if a rectangle intersects with visible bounds
5599
+ */
5600
+ function isRectVisible(x, y, width, height, bounds) {
5601
+ return (x + width >= bounds.minX &&
5602
+ x <= bounds.maxX &&
5603
+ y + height >= bounds.minY &&
5604
+ y <= bounds.maxY);
5605
+ }
5606
+ /**
5607
+ * Parses path data to get bounding box
5608
+ * Handles M (moveTo), L (lineTo), C (cubic Bezier), Q (quadratic Bezier), and H/V (horizontal/vertical) commands
5609
+ */
5610
+ function getPathBounds(pathData) {
5611
+ let minX = Infinity;
5612
+ let minY = Infinity;
5613
+ let maxX = -Infinity;
5614
+ let maxY = -Infinity;
5615
+ // Match coordinates from various path commands: M, L, C, Q, T, S, H, V
5616
+ // Pattern matches: command letter followed by coordinate pairs
5617
+ const coordPattern = /[MLCQTSHV](-?\d+\.?\d*),(-?\d+\.?\d*)/g;
5618
+ const coords = pathData.match(coordPattern);
5619
+ if (coords) {
5620
+ coords.forEach((coord) => {
5621
+ const match = coord.match(/(-?\d+\.?\d*),(-?\d+\.?\d*)/);
5622
+ if (match) {
5623
+ const x = parseFloat(match[1]);
5624
+ const y = parseFloat(match[2]);
5625
+ if (!isNaN(x) && !isNaN(y)) {
5626
+ minX = Math.min(minX, x);
5627
+ minY = Math.min(minY, y);
5628
+ maxX = Math.max(maxX, x);
5629
+ maxY = Math.max(maxY, y);
5630
+ }
5631
+ }
5632
+ });
5633
+ }
5634
+ return { minX, minY, maxX, maxY };
5635
+ }
5636
+ // ============================================================================
5637
+ // Edge Extraction
5638
+ // ============================================================================
5639
+ /**
5640
+ * Extracts visible edge paths from React Flow viewport
5641
+ */
5642
+ function extractEdgePaths(viewport, visibleBounds) {
5643
+ const edges = [];
5644
+ let minX = Infinity;
5645
+ let minY = Infinity;
5646
+ let maxX = -Infinity;
5647
+ let maxY = -Infinity;
5648
+ const edgePathElements = viewport.querySelectorAll(".react-flow__edge-path");
5649
+ edgePathElements.forEach((pathEl) => {
5650
+ const pathData = pathEl.getAttribute("d");
5651
+ if (!pathData)
5652
+ return;
5653
+ const pathBounds = getPathBounds(pathData);
5654
+ // Only include edge if it intersects with visible viewport
5655
+ if (pathBounds.maxX >= visibleBounds.minX &&
5656
+ pathBounds.minX <= visibleBounds.maxX &&
5657
+ pathBounds.maxY >= visibleBounds.minY &&
5658
+ pathBounds.minY <= visibleBounds.maxY) {
5659
+ edges.push({
5660
+ d: pathData,
5661
+ stroke: extractStrokeColor(pathEl),
5662
+ strokeWidth: extractStrokeWidth(pathEl, 2),
5663
+ });
5664
+ // Update bounding box
5665
+ minX = Math.min(minX, pathBounds.minX);
5666
+ minY = Math.min(minY, pathBounds.minY);
5667
+ maxX = Math.max(maxX, pathBounds.maxX);
5668
+ maxY = Math.max(maxY, pathBounds.maxY);
5669
+ }
5670
+ });
5671
+ return { edges, bounds: { minX, minY, maxX, maxY } };
5672
+ }
5673
+ // ============================================================================
5674
+ // Node Extraction
5675
+ // ============================================================================
5676
+ /**
5677
+ * Extracts node position from transform style
5678
+ */
5679
+ function extractNodePosition(nodeEl) {
5680
+ const transformStyle = nodeEl.style.transform || "";
5681
+ const translateMatch = transformStyle.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
5682
+ if (translateMatch) {
5683
+ return {
5684
+ x: parseFloat(translateMatch[1]),
5685
+ y: parseFloat(translateMatch[2]),
5686
+ };
5687
+ }
5688
+ return { x: 0, y: 0 };
5689
+ }
5690
+ /**
5691
+ * Extracts node dimensions from inline styles
5692
+ */
5693
+ function extractNodeDimensions(nodeEl) {
5694
+ const widthMatch = nodeEl.style.width?.match(/(\d+)px/);
5695
+ const heightMatch = nodeEl.style.height?.match(/(\d+)px/);
5696
+ return {
5697
+ width: widthMatch ? parseFloat(widthMatch[1]) : 150,
5698
+ height: heightMatch ? parseFloat(heightMatch[1]) : 40,
5699
+ };
5700
+ }
5701
+ /**
5702
+ * Extracts node styles (colors, border, radius) from computed styles
5703
+ */
5704
+ function extractNodeStyles(nodeContent) {
5705
+ const computedStyle = window.getComputedStyle(nodeContent);
5706
+ // Use gray background for nodes in thumbnail
5707
+ const fill = "#f3f4f6"; // gray-100 equivalent
5708
+ const stroke = extractStrokeColor(nodeContent);
5709
+ const strokeWidth = extractStrokeWidth(nodeContent, 1);
5710
+ const borderRadiusStr = computedStyle.borderRadius || "8px";
5711
+ const rx = parseBorderRadius(borderRadiusStr);
5712
+ const ry = rx; // Use same radius for both x and y
5713
+ return { fill, stroke, strokeWidth, rx, ry };
5714
+ }
5715
+ /**
5716
+ * Determines if a handle is a source (output) or target (input)
5717
+ */
5718
+ function isHandleSource(handleEl) {
5719
+ return (handleEl.classList.contains("react-flow__handle-right") ||
5720
+ handleEl.classList.contains("react-flow__handle-source"));
5721
+ }
5722
+ /**
5723
+ * Extracts handle position and calculates absolute coordinates
5724
+ */
5725
+ function extractHandlePosition(handleEl, nodeX, nodeY, nodeWidth, isSource) {
5726
+ const handleStyle = window.getComputedStyle(handleEl);
5727
+ const handleTop = parseFloat(handleStyle.top || "0");
5728
+ const handleLeft = handleStyle.left;
5729
+ const handleRight = handleStyle.right;
5730
+ const handleY = nodeY + handleTop;
5731
+ let handleX;
5732
+ if (isSource) {
5733
+ // Source handles are on the right edge
5734
+ if (handleRight !== "auto" && handleRight !== "") {
5735
+ const rightValue = parseFloat(handleRight) || 0;
5736
+ handleX = nodeX + nodeWidth + rightValue;
5737
+ }
5738
+ else {
5739
+ handleX = nodeX + nodeWidth;
5740
+ }
5741
+ }
5742
+ else {
5743
+ // Target handles are on the left edge
5744
+ if (handleLeft !== "auto" && handleLeft !== "") {
5745
+ const leftValue = parseFloat(handleLeft) || 0;
5746
+ handleX = nodeX + leftValue;
5747
+ }
5748
+ else {
5749
+ handleX = nodeX;
5750
+ }
5751
+ }
5752
+ return { x: handleX, y: handleY };
5753
+ }
5754
+ /**
5755
+ * Extracts handles from a node element
5756
+ */
5757
+ function extractNodeHandles(nodeEl, nodeX, nodeY, nodeWidth) {
5758
+ const handles = [];
5759
+ const handleElements = nodeEl.querySelectorAll(".react-flow__handle");
5760
+ handleElements.forEach((handleEl) => {
5761
+ const handleStyle = window.getComputedStyle(handleEl);
5762
+ const handleWidth = parseFloat(handleStyle.width || "12");
5763
+ const handleHeight = parseFloat(handleStyle.height || "12");
5764
+ const isSource = isHandleSource(handleEl);
5765
+ const position = extractHandlePosition(handleEl, nodeX, nodeY, nodeWidth, isSource);
5766
+ handles.push({
5767
+ x: position.x,
5768
+ y: position.y,
5769
+ width: handleWidth,
5770
+ height: handleHeight,
5771
+ fill: handleStyle.backgroundColor || "rgba(255, 255, 255, 0.5)",
5772
+ stroke: extractStrokeColor(handleEl),
5773
+ strokeWidth: extractStrokeWidth(handleEl, 1),
5774
+ type: isSource ? "source" : "target",
5775
+ });
5776
+ });
5777
+ return handles;
5778
+ }
5779
+ /**
5780
+ * Extracts node title text and position
5781
+ */
5782
+ function extractNodeTitle(nodeEl, nodeX, nodeY) {
5783
+ const titleElement = nodeEl.querySelector(".react-flow__node-title");
5784
+ if (!titleElement)
5785
+ return undefined;
5786
+ const titleText = titleElement.textContent || titleElement.innerText || "";
5787
+ if (!titleText.trim())
5788
+ return undefined;
5789
+ const titleStyle = window.getComputedStyle(titleElement);
5790
+ const titleRect = titleElement.getBoundingClientRect();
5791
+ const nodeRect = nodeEl.getBoundingClientRect();
5792
+ // Calculate title position relative to node (in flow coordinates)
5793
+ const titleRelativeX = titleRect.left - nodeRect.left;
5794
+ const titleRelativeY = titleRect.top - nodeRect.top;
5795
+ // Get font properties
5796
+ const fontSize = parseFloat(titleStyle.fontSize || "14");
5797
+ const fill = titleStyle.color || "#374151"; // gray-700 default
5798
+ const fontWeight = titleStyle.fontWeight || "600"; // bold default
5799
+ // Calculate text position (SVG text uses baseline)
5800
+ const lineHeight = parseFloat(titleStyle.lineHeight || String(fontSize * 1.2));
5801
+ const textBaselineOffset = lineHeight * 0.8; // Approximate baseline offset
5802
+ return {
5803
+ text: titleText.trim(),
5804
+ x: nodeX + titleRelativeX,
5805
+ y: nodeY + titleRelativeY + textBaselineOffset,
5806
+ fontSize,
5807
+ fill,
5808
+ fontWeight,
5809
+ };
5810
+ }
5811
+ /**
5812
+ * Extracts visible nodes from React Flow viewport
5813
+ */
5814
+ function extractNodes(viewport, visibleBounds) {
5815
+ const nodes = [];
5816
+ let minX = Infinity;
5817
+ let minY = Infinity;
5818
+ let maxX = -Infinity;
5819
+ let maxY = -Infinity;
5820
+ const nodeElements = viewport.querySelectorAll(".react-flow__node");
5821
+ nodeElements.forEach((nodeEl) => {
5822
+ const position = extractNodePosition(nodeEl);
5823
+ const dimensions = extractNodeDimensions(nodeEl);
5824
+ // Get the actual node content div (first child)
5825
+ const nodeContent = nodeEl.firstElementChild;
5826
+ if (!nodeContent)
5827
+ return;
5828
+ const styles = extractNodeStyles(nodeContent);
5829
+ const handles = extractNodeHandles(nodeEl, position.x, position.y, dimensions.width);
5830
+ const title = extractNodeTitle(nodeEl, position.x, position.y);
5831
+ // Only include node if it's within visible viewport
5832
+ if (isRectVisible(position.x, position.y, dimensions.width, dimensions.height, visibleBounds)) {
5833
+ nodes.push({
5834
+ x: position.x,
5835
+ y: position.y,
5836
+ width: dimensions.width,
5837
+ height: dimensions.height,
5838
+ ...styles,
5839
+ handles,
5840
+ title,
5841
+ });
5842
+ // Update bounding box
5843
+ minX = Math.min(minX, position.x);
5844
+ minY = Math.min(minY, position.y);
5845
+ maxX = Math.max(maxX, position.x + dimensions.width);
5846
+ maxY = Math.max(maxY, position.y + dimensions.height);
5847
+ // Update bounding box to include handles
5848
+ handles.forEach((handle) => {
5849
+ minX = Math.min(minX, handle.x);
5850
+ minY = Math.min(minY, handle.y);
5851
+ maxX = Math.max(maxX, handle.x + handle.width);
5852
+ maxY = Math.max(maxY, handle.y + handle.height);
5853
+ });
5854
+ }
5855
+ });
5856
+ return { nodes, bounds: { minX, minY, maxX, maxY } };
5857
+ }
5858
+ // ============================================================================
5859
+ // SVG Rendering
5860
+ // ============================================================================
5861
+ /**
5862
+ * Creates SVG element with dot pattern background (matching React Flow)
5863
+ */
5864
+ function createSVGElement(width, height) {
5865
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
5866
+ svg.setAttribute("width", String(width));
5867
+ svg.setAttribute("height", String(height));
5868
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
5869
+ // Create defs section for patterns
5870
+ const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
5871
+ // Create dot pattern (matching React Flow's BackgroundVariant.Dots)
5872
+ // React Flow uses gap={12} and size={1} by default
5873
+ const pattern = document.createElementNS("http://www.w3.org/2000/svg", "pattern");
5874
+ pattern.setAttribute("id", "dot-pattern");
5875
+ pattern.setAttribute("x", "0");
5876
+ pattern.setAttribute("y", "0");
5877
+ pattern.setAttribute("width", "24"); // gap between dots (matching React Flow default)
5878
+ pattern.setAttribute("height", "24");
5879
+ pattern.setAttribute("patternUnits", "userSpaceOnUse");
5880
+ // Create a circle for the dot (centered in the pattern cell)
5881
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
5882
+ circle.setAttribute("cx", "12"); // Center of 24x24 pattern cell
5883
+ circle.setAttribute("cy", "12");
5884
+ circle.setAttribute("r", "1"); // dot radius = 1 (matching React Flow size={1})
5885
+ circle.setAttribute("fill", "#f1f1f1"); // gray color matching React Flow default
5886
+ pattern.appendChild(circle);
5887
+ defs.appendChild(pattern);
5888
+ svg.appendChild(defs);
5889
+ // Create background rectangle with white base
5890
+ const bgRect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
5891
+ bgRect.setAttribute("width", String(width));
5892
+ bgRect.setAttribute("height", String(height));
5893
+ bgRect.setAttribute("fill", "#ffffff"); // Base background color
5894
+ svg.appendChild(bgRect);
5895
+ // Create pattern overlay rectangle
5896
+ const patternRect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
5897
+ patternRect.setAttribute("width", String(width));
5898
+ patternRect.setAttribute("height", String(height));
5899
+ patternRect.setAttribute("fill", "url(#dot-pattern)");
5900
+ svg.appendChild(patternRect);
5901
+ // Create group with transform
5902
+ const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
5903
+ svg.appendChild(group);
5904
+ return { svg, group };
5905
+ }
5906
+ /**
5907
+ * Renders a node rectangle to SVG group
5908
+ */
5909
+ function renderNodeRect(group, node) {
5910
+ const rectEl = document.createElementNS("http://www.w3.org/2000/svg", "rect");
5911
+ rectEl.setAttribute("x", String(node.x));
5912
+ rectEl.setAttribute("y", String(node.y));
5913
+ rectEl.setAttribute("width", String(node.width));
5914
+ rectEl.setAttribute("height", String(node.height));
5915
+ rectEl.setAttribute("rx", String(node.rx));
5916
+ rectEl.setAttribute("ry", String(node.ry));
5917
+ rectEl.setAttribute("fill", node.fill);
5918
+ rectEl.setAttribute("stroke", node.stroke);
5919
+ rectEl.setAttribute("stroke-width", String(node.strokeWidth));
5920
+ group.appendChild(rectEl);
5921
+ }
5922
+ /**
5923
+ * Renders a handle to SVG group
5924
+ */
5925
+ function renderHandle(group, handle) {
5926
+ const handleEl = document.createElementNS("http://www.w3.org/2000/svg", "rect");
5927
+ // Handles are centered on edges
5928
+ handleEl.setAttribute("x", String(handle.x - handle.width / 2));
5929
+ handleEl.setAttribute("y", String(handle.y - handle.height / 2));
5930
+ handleEl.setAttribute("width", String(handle.width));
5931
+ handleEl.setAttribute("height", String(handle.height));
5932
+ handleEl.setAttribute("rx", String(handle.width / 2)); // Make handles circular/rounded
5933
+ handleEl.setAttribute("ry", String(handle.height / 2));
5934
+ handleEl.setAttribute("fill", handle.fill);
5935
+ handleEl.setAttribute("stroke", handle.stroke);
5936
+ handleEl.setAttribute("stroke-width", String(handle.strokeWidth));
5937
+ group.appendChild(handleEl);
5938
+ }
5939
+ /**
5940
+ * Renders node title text to SVG group
5941
+ */
5942
+ function renderNodeTitle(group, title) {
5943
+ const textEl = document.createElementNS("http://www.w3.org/2000/svg", "text");
5944
+ textEl.setAttribute("x", String(title.x));
5945
+ textEl.setAttribute("y", String(title.y));
5946
+ textEl.setAttribute("font-size", String(title.fontSize));
5947
+ textEl.setAttribute("fill", title.fill);
5948
+ textEl.setAttribute("font-weight", title.fontWeight);
5949
+ textEl.setAttribute("font-family", "system-ui, -apple-system, sans-serif");
5950
+ textEl.textContent = title.text;
5951
+ group.appendChild(textEl);
5952
+ }
5953
+ /**
5954
+ * Renders an edge path to SVG group
5955
+ */
5956
+ function renderEdgePath(group, edge) {
5957
+ const pathEl = document.createElementNS("http://www.w3.org/2000/svg", "path");
5958
+ pathEl.setAttribute("d", edge.d);
5959
+ pathEl.setAttribute("fill", "none");
5960
+ pathEl.setAttribute("stroke", edge.stroke);
5961
+ pathEl.setAttribute("stroke-width", String(edge.strokeWidth));
5962
+ group.appendChild(pathEl);
5963
+ }
5964
+ /**
5965
+ * Renders all nodes, handles, titles, and edges to SVG group
5966
+ */
5967
+ function renderContentToSVG(group, nodes, edges, transformX, transformY) {
5968
+ group.setAttribute("transform", `translate(${transformX}, ${transformY})`);
5969
+ // Render nodes
5970
+ nodes.forEach((node) => {
5971
+ renderNodeRect(group, node);
5972
+ node.handles.forEach((handle) => renderHandle(group, handle));
5973
+ if (node.title) {
5974
+ renderNodeTitle(group, node.title);
5975
+ }
5976
+ });
5977
+ // Render edges
5978
+ edges.forEach((edge) => renderEdgePath(group, edge));
5979
+ }
5980
+ // ============================================================================
5981
+ // Main Capture Function
5982
+ // ============================================================================
5983
+ /**
5984
+ * Captures a React Flow container element as an SVG image and returns data URL
5985
+ * @param containerElement - The React Flow container DOM element
5986
+ * @returns Promise resolving to SVG data URL string, or null if capture fails
5987
+ */
5988
+ async function captureCanvasThumbnail(containerElement) {
5989
+ if (!containerElement) {
5990
+ console.warn("[flowThumbnail] Container element is null");
5991
+ return null;
5992
+ }
5993
+ try {
5994
+ // Find the React Flow viewport element
5995
+ const reactFlowViewport = containerElement.querySelector(".react-flow__viewport");
5996
+ if (!reactFlowViewport) {
5997
+ console.warn("[flowThumbnail] React Flow viewport not found");
5998
+ return null;
5999
+ }
6000
+ // Parse viewport transform
6001
+ const viewportStyle = window.getComputedStyle(reactFlowViewport);
6002
+ const viewportTransform = viewportStyle.transform || viewportStyle.getPropertyValue("transform");
6003
+ const transform = parseViewportTransform(viewportTransform);
6004
+ // Calculate visible bounds
6005
+ const viewportRect = reactFlowViewport.getBoundingClientRect();
6006
+ const visibleBounds = calculateVisibleBounds(viewportRect, transform);
6007
+ // Extract edges and nodes
6008
+ const { edges, bounds: edgeBounds } = extractEdgePaths(reactFlowViewport, visibleBounds);
6009
+ const { nodes, bounds: nodeBounds } = extractNodes(reactFlowViewport, visibleBounds);
6010
+ // Calculate overall bounding box
6011
+ // Handle case where one or both bounds might be Infinity (no content)
6012
+ let minX = Infinity;
6013
+ let minY = Infinity;
6014
+ let maxX = -Infinity;
6015
+ let maxY = -Infinity;
6016
+ if (edgeBounds.minX !== Infinity) {
6017
+ minX = Math.min(minX, edgeBounds.minX);
6018
+ minY = Math.min(minY, edgeBounds.minY);
6019
+ maxX = Math.max(maxX, edgeBounds.maxX);
6020
+ maxY = Math.max(maxY, edgeBounds.maxY);
6021
+ }
6022
+ if (nodeBounds.minX !== Infinity) {
6023
+ minX = Math.min(minX, nodeBounds.minX);
6024
+ minY = Math.min(minY, nodeBounds.minY);
6025
+ maxX = Math.max(maxX, nodeBounds.maxX);
6026
+ maxY = Math.max(maxY, nodeBounds.maxY);
6027
+ }
6028
+ // If no visible content, use the visible viewport bounds
6029
+ if (minX === Infinity || (nodes.length === 0 && edges.length === 0)) {
6030
+ minX = visibleBounds.minX;
6031
+ minY = visibleBounds.minY;
6032
+ maxX = visibleBounds.maxX;
6033
+ maxY = visibleBounds.maxY;
6034
+ }
6035
+ // Use the visible viewport bounds exactly (what the user sees)
6036
+ const contentMinX = visibleBounds.minX;
6037
+ const contentMinY = visibleBounds.minY;
6038
+ const contentMaxX = visibleBounds.maxX;
6039
+ const contentMaxY = visibleBounds.maxY;
6040
+ const contentWidth = contentMaxX - contentMinX;
6041
+ const contentHeight = contentMaxY - contentMinY;
6042
+ // Create SVG
6043
+ const { svg, group } = createSVGElement(contentWidth, contentHeight);
6044
+ // Render content
6045
+ renderContentToSVG(group, nodes, edges, -contentMinX, -contentMinY);
6046
+ // Serialize SVG to string
6047
+ const serializer = new XMLSerializer();
6048
+ const svgString = serializer.serializeToString(svg);
6049
+ // Return SVG data URL
6050
+ const svgDataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgString)))}`;
6051
+ return svgDataUrl;
6052
+ }
6053
+ catch (error) {
6054
+ console.error("[flowThumbnail] Failed to capture thumbnail:", error);
6055
+ return null;
6056
+ }
6057
+ }
6058
+ /**
6059
+ * Captures a React Flow container element as an SVG image and downloads it
6060
+ * @param containerElement - The React Flow container DOM element
6061
+ * @returns Promise resolving to true if successful, false otherwise
6062
+ */
6063
+ async function downloadCanvasThumbnail(containerElement) {
6064
+ const svgDataUrl = await captureCanvasThumbnail(containerElement);
6065
+ if (!svgDataUrl) {
6066
+ return false;
6067
+ }
6068
+ // Create blob and download
6069
+ const base64Data = svgDataUrl.split(",")[1];
6070
+ const svgString = atob(base64Data);
6071
+ const blob = new Blob([svgString], { type: "image/svg+xml" });
6072
+ const url = URL.createObjectURL(blob);
6073
+ const link = document.createElement("a");
6074
+ link.href = url;
6075
+ link.download = `flow-thumbnail-${Date.now()}.svg`;
6076
+ document.body.appendChild(link);
6077
+ link.click();
6078
+ document.body.removeChild(link);
6079
+ URL.revokeObjectURL(url);
6080
+ return true;
6081
+ }
6082
+
5493
6083
  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
6084
  const { wb, runner, registry, selectedNodeId, runAutoLayout } = useWorkbenchContext();
5495
6085
  const [transportStatus, setTransportStatus] = React.useState({
@@ -5578,6 +6168,7 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
5578
6168
  return defaultExamples;
5579
6169
  }, [overrides, defaultExamples]);
5580
6170
  const canvasRef = React.useRef(null);
6171
+ const canvasContainerRef = React.useRef(null);
5581
6172
  const uploadInputRef = React.useRef(null);
5582
6173
  const [registryReady, setRegistryReady] = React.useState(() => {
5583
6174
  // For local backends, registry is always ready
@@ -5939,7 +6530,9 @@ function WorkbenchStudioCanvas({ setRegistry, autoScroll, onAutoScrollChange, ex
5939
6530
  // Normal change when not running
5940
6531
  onEngineChange?.(kind);
5941
6532
  }
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 })] })] }));
6533
+ }, 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 () => {
6534
+ await downloadCanvasThumbnail(canvasContainerRef.current);
6535
+ }, 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
6536
  }
5944
6537
  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
6538
  const [registry, setRegistry] = React.useState(sparkGraph.createSimpleGraphRegistry());
@@ -6029,6 +6622,7 @@ exports.WorkbenchCanvas = WorkbenchCanvas;
6029
6622
  exports.WorkbenchContext = WorkbenchContext;
6030
6623
  exports.WorkbenchProvider = WorkbenchProvider;
6031
6624
  exports.WorkbenchStudio = WorkbenchStudio;
6625
+ exports.captureCanvasThumbnail = captureCanvasThumbnail;
6032
6626
  exports.computeEffectiveHandles = computeEffectiveHandles;
6033
6627
  exports.countVisibleHandles = countVisibleHandles;
6034
6628
  exports.createCopyHandler = createCopyHandler;
@@ -6039,6 +6633,7 @@ exports.createNodeContextMenuHandlers = createNodeContextMenuHandlers;
6039
6633
  exports.createNodeCopyHandler = createNodeCopyHandler;
6040
6634
  exports.createSelectionContextMenuHandlers = createSelectionContextMenuHandlers;
6041
6635
  exports.download = download;
6636
+ exports.downloadCanvasThumbnail = downloadCanvasThumbnail;
6042
6637
  exports.estimateNodeSize = estimateNodeSize;
6043
6638
  exports.excludeViewportFromUIState = excludeViewportFromUIState;
6044
6639
  exports.formatDataUrlAsLabel = formatDataUrlAsLabel;