fix preview and save on native

This commit is contained in:
2026-03-24 22:52:24 -07:00
parent a60b0c15ca
commit 6959c62c8f
16 changed files with 875 additions and 202 deletions

View File

@@ -13,6 +13,7 @@ import FileBrowser from './FileBrowser';
import * as api from './api';
import { toBlob } from 'html-to-image';
import { embedWorkflow, extractWorkflow } from './pngMetadata';
import { hydrateWorkflowState } from './workflowHydration';
import { serializeWorkflowState } from './workflowSerialization';
// ── Constants ─────────────────────────────────────────────────────────
@@ -43,15 +44,6 @@ function getOutputSlot(handleId) {
return parseInt(handleId.split('::')[1], 10);
}
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = () => reject(reader.error || new Error('Failed to read file'));
reader.readAsDataURL(blob);
});
}
async function waitForImageElement(img) {
if (img.complete && img.naturalWidth > 0) return;
if (typeof img.decode === 'function') {
@@ -73,6 +65,31 @@ async function waitForImageElement(img) {
});
}
async function getCaptureImageDataUrl(img) {
const src = img.currentSrc || img.src;
if (!src) return null;
if (!src.startsWith('data:')) return src;
const rect = img.getBoundingClientRect();
const width = Math.max(1, Math.round(img.clientWidth || rect.width));
const height = Math.max(1, Math.round(img.clientHeight || rect.height));
const scale = Math.min(2, window.devicePixelRatio || 1);
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width * scale));
canvas.height = Math.max(1, Math.round(height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return src;
try {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png');
} catch {
return src;
}
}
function createCapturePlaceholder(el, dataUrl) {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
@@ -101,8 +118,9 @@ async function captureViewportBlob(viewportEl, options) {
await Promise.all(images.map(waitForImageElement));
for (const img of images) {
const dataUrl = img.currentSrc || img.src;
if (!dataUrl || !img.parentNode) continue;
if (!img.parentNode) continue;
const dataUrl = await getCaptureImageDataUrl(img);
if (!dataUrl) continue;
const placeholder = createCapturePlaceholder(img, dataUrl);
img.parentNode.replaceChild(placeholder, img);
restorers.push(() => {
@@ -144,12 +162,13 @@ async function captureViewportBlob(viewportEl, options) {
// ── Graph serialisation → backend prompt format ───────────────────────
function serializeGraph(nodes, edges) {
function serializeGraph(nodes, edges, { excludeManualTrigger = false } = {}) {
const prompt = {};
for (const node of nodes) {
const { className, definition, widgetValues } = node.data;
if (!definition) continue;
if (excludeManualTrigger && definition.manual_trigger) continue;
const inputs = {};
@@ -551,10 +570,23 @@ function Flow() {
// ── Node context value (stable) ─────────────────────────────────────
const onManualTrigger = useCallback((nodeId) => {
const currentNodes = reactFlow.getNodes();
const currentEdges = reactFlow.getEdges();
// Include ALL nodes (no excludeManualTrigger) so the save node is in the prompt
const prompt = serializeGraph(currentNodes, currentEdges);
if (!prompt || Object.keys(prompt).length === 0) return;
setStatus({ text: 'Saving…', level: 'info' });
api.runPrompt(prompt).catch((err) => {
setStatus({ text: 'Save failed: ' + err.message, level: 'error' });
});
}, [reactFlow]);
const contextValue = useMemo(() => ({
onWidgetChange,
openFileBrowser,
}), [onWidgetChange, openFileBrowser]);
onManualTrigger,
}), [onWidgetChange, openFileBrowser, onManualTrigger]);
// ── Add node from context menu ──────────────────────────────────────
@@ -687,13 +719,18 @@ function Flow() {
const currentNodes = reactFlow.getNodes();
const currentEdges = reactFlow.getEdges();
// Don't run if any node has unconnected required data inputs
// Don't run if any non-manual node has unconnected required data inputs
// or any FILE_PICKER widget is empty
for (const node of currentNodes) {
const def = node.data?.definition;
if (!def) continue;
if (!def || def.manual_trigger) continue; // skip manual-trigger nodes
const required = def.input.required || {};
for (const [name, spec] of Object.entries(required)) {
const [type] = Array.isArray(spec) ? spec : [spec];
if (type === 'FILE_PICKER') {
if (!node.data.widgetValues?.[name]) return; // no file selected, skip
continue;
}
if (!DATA_TYPES.has(type)) continue;
const hasEdge = currentEdges.some(
(e) => e.target === node.id && getInputName(e.targetHandle) === name
@@ -702,7 +739,7 @@ function Flow() {
}
}
const prompt = serializeGraph(currentNodes, currentEdges);
const prompt = serializeGraph(currentNodes, currentEdges, { excludeManualTrigger: true });
if (!prompt || Object.keys(prompt).length === 0) return;
setStatus({ text: 'Running…', level: 'info' });
api.runPrompt(prompt).catch((err) => {
@@ -723,25 +760,10 @@ function Flow() {
}, [setNodes, setEdges]);
const applyWorkflowData = useCallback((data) => {
const loadedNodes = data.nodes || [];
const loadedEdges = data.edges || [];
const defs = nodeDefsRef.current;
const hydrated = loadedNodes.map((n) => ({
...n,
type: n.type || 'custom',
dragHandle: n.dragHandle || '.drag-handle',
data: {
...n.data,
label: n.data?.label || n.data?.className || 'Node',
widgetValues: n.data?.widgetValues || {},
definition: defs[n.data.className] || n.data.definition,
previewImage: null, tableRows: null, meshData: null, overlay: null,
},
}));
setNodes(hydrated);
setEdges(loadedEdges);
const maxId = Math.max(0, ...loadedNodes.map((n) => parseInt(n.id, 10) || 0));
nextIdRef.current = maxId + 1;
const hydrated = hydrateWorkflowState(data, nodeDefsRef.current);
setNodes(hydrated.nodes);
setEdges(hydrated.edges);
nextIdRef.current = hydrated.nextNodeId;
}, [setNodes, setEdges]);
const getWorkflowBlob = useCallback(async () => {
@@ -778,9 +800,23 @@ function Flow() {
try {
const finalBlob = await getWorkflowBlob();
if (window.pywebview?.api?.save_workflow_png) {
const dataUrl = await blobToDataUrl(finalBlob);
const savedPath = await window.pywebview.api.save_workflow_png(dataUrl, 'workflow.png');
if (window.pywebview?.api?.choose_save_workflow_png_path) {
const requestedPath = await window.pywebview.api.choose_save_workflow_png_path('workflow.png');
if (!requestedPath) {
setStatus({ text: 'Save cancelled.', level: 'info' });
return;
}
const resp = await fetch(`/save-workflow-png?path=${encodeURIComponent(requestedPath)}`, {
method: 'POST',
headers: {
'Content-Type': 'image/png',
},
body: finalBlob,
});
if (!resp.ok) {
throw new Error(await resp.text() || `Save failed (${resp.status})`);
}
const { path: savedPath } = await resp.json();
if (!savedPath) {
setStatus({ text: 'Save cancelled.', level: 'info' });
return;

View File

@@ -1,5 +1,6 @@
import React, { useContext, useRef, useCallback, useState, memo, lazy, Suspense } from 'react';
import { Handle, Position } from '@xyflow/react';
import { Handle, Position, useStore } from '@xyflow/react';
import LinePlotOverlay from './LinePlotOverlay';
const SurfaceView = lazy(() => import('./SurfaceView'));
const CrossSectionOverlay = lazy(() => import('./CrossSectionOverlay'));
@@ -29,6 +30,47 @@ const CAT_COLORS = {
export const NodeContext = React.createContext(null);
class PreviewBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error) {
console.error('[argonode] preview render failed', error);
}
componentDidUpdate(prevProps) {
if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) {
this.setState({ hasError: false });
}
}
render() {
if (!this.state.hasError) {
return this.props.children;
}
if (this.props.fallbackImage) {
return (
<div className="node-preview">
<img src={this.props.fallbackImage} alt="preview fallback" draggable={false} />
</div>
);
}
return (
<div className="node-preview" style={{ color: '#94a3b8', padding: 8 }}>
Preview unavailable.
</div>
);
}
}
// ── Draggable number input ────────────────────────────────────────────
function DraggableNumber({ value, step, min, max, precision, onChange }) {
@@ -151,8 +193,39 @@ function CustomNode({ id, data }) {
}
}
// For manual-trigger nodes (Save), show progressive optional inputs:
// show field_N only if field_(N-1) is connected (or N==0).
const isProgressive = def.manual_trigger;
const connectedInputs = useStore(
useCallback(
(s) => {
if (!isProgressive) return null;
const set = new Set();
for (const e of s.edges) {
if (e.target === id) {
const parts = e.targetHandle?.split('::');
if (parts) set.add(parts[1]);
}
}
return set;
},
[id, isProgressive],
),
);
for (const [name, spec] of Object.entries(optional)) {
const [type] = Array.isArray(spec) ? spec : [spec];
if (isProgressive && DATA_TYPES.has(type)) {
// Progressive: show this slot only if it's the first or the previous is connected
const match = name.match(/^field_(\d+)$/);
if (match) {
const idx = parseInt(match[1], 10);
if (idx === 0 || (connectedInputs && connectedInputs.has(`field_${idx - 1}`))) {
dataInputs.push({ name, type });
}
continue;
}
}
dataInputs.push({ name, type });
}
@@ -229,6 +302,19 @@ function CustomNode({ id, data }) {
</div>
))}
{/* Manual trigger button (Save) */}
{def.manual_trigger && (
<div className="widget-row">
<button
className="nodrag btn btn-primary"
style={{ flex: 1 }}
onClick={() => ctx.onManualTrigger?.(id)}
>
Save to Disk
</button>
</div>
)}
{/* Interactive 3D surface view */}
{data.meshData && (
<CollapsibleSection title="3D View" defaultOpen={true}>
@@ -241,9 +327,21 @@ function CustomNode({ id, data }) {
{/* Collapsible preview image */}
{data.previewImage && (
<CollapsibleSection title="Preview" defaultOpen={true}>
<div className="node-preview">
<img src={data.previewImage} alt="preview" draggable={false} />
</div>
<PreviewBoundary
resetKey={typeof data.previewImage === 'string' ? data.previewImage : JSON.stringify({
kind: data.previewImage.kind,
len: data.previewImage.line?.length,
})}
fallbackImage={typeof data.previewImage === 'object' ? data.previewImage.fallback_image : null}
>
{typeof data.previewImage === 'string' ? (
<div className="node-preview">
<img src={data.previewImage} alt="preview" draggable={false} />
</div>
) : data.previewImage.kind === 'line_plot' ? (
<LinePlotOverlay overlay={data.previewImage} interactive={false} />
) : null}
</PreviewBoundary>
</CollapsibleSection>
)}
@@ -251,17 +349,29 @@ function CustomNode({ id, data }) {
{data.overlay && hiddenWidgets.has('x1') && (
<CollapsibleSection title="Cross Section" defaultOpen={true}>
<Suspense fallback={<div className="node-preview" style={{color:'#64748b',padding:4}}>Loading...</div>}>
<CrossSectionOverlay
image={data.overlay.image}
x1={data.overlay.a_locked ? data.overlay.x1 : (data.widgetValues.x1 ?? data.overlay.x1)}
y1={data.overlay.a_locked ? data.overlay.y1 : (data.widgetValues.y1 ?? data.overlay.y1)}
x2={data.overlay.b_locked ? data.overlay.x2 : (data.widgetValues.x2 ?? data.overlay.x2)}
y2={data.overlay.b_locked ? data.overlay.y2 : (data.widgetValues.y2 ?? data.overlay.y2)}
aLocked={data.overlay.a_locked}
bLocked={data.overlay.b_locked}
nodeId={id}
onWidgetChange={ctx.onWidgetChange}
/>
{data.overlay.kind === 'line_plot' ? (
<LinePlotOverlay
overlay={data.overlay}
x1={data.overlay.a_locked ? data.overlay.x1 : (data.widgetValues.x1 ?? data.overlay.x1)}
x2={data.overlay.b_locked ? data.overlay.x2 : (data.widgetValues.x2 ?? data.overlay.x2)}
aLocked={data.overlay.a_locked}
bLocked={data.overlay.b_locked}
nodeId={id}
onWidgetChange={ctx.onWidgetChange}
/>
) : (
<CrossSectionOverlay
image={data.overlay.image}
x1={data.overlay.a_locked ? data.overlay.x1 : (data.widgetValues.x1 ?? data.overlay.x1)}
y1={data.overlay.a_locked ? data.overlay.y1 : (data.widgetValues.y1 ?? data.overlay.y1)}
x2={data.overlay.b_locked ? data.overlay.x2 : (data.widgetValues.x2 ?? data.overlay.x2)}
y2={data.overlay.b_locked ? data.overlay.y2 : (data.widgetValues.y2 ?? data.overlay.y2)}
aLocked={data.overlay.a_locked}
bLocked={data.overlay.b_locked}
nodeId={id}
onWidgetChange={ctx.onWidgetChange}
/>
)}
</Suspense>
</CollapsibleSection>
)}

View File

@@ -0,0 +1,271 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
const ASPECT_RATIO = 3.2 / 2.2;
const MARGINS = { top: 18, right: 16, bottom: 34, left: 56 };
function clamp(v, min, max) {
return Math.max(min, Math.min(max, v));
}
function round3(v) {
return parseFloat(v.toFixed(3));
}
function trimZeros(text) {
return text.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1');
}
function formatTick(value) {
const abs = Math.abs(value);
if (abs === 0) return '0';
if (abs >= 1e4 || abs < 1e-3) {
return value.toExponential(1).replace('e+', 'e');
}
if (abs >= 100) return trimZeros(value.toFixed(0));
if (abs >= 10) return trimZeros(value.toFixed(1));
if (abs >= 1) return trimZeros(value.toFixed(2));
return trimZeros(value.toFixed(3));
}
function makeTicks(min, max, count = 5) {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [];
if (min === max) return [min];
const ticks = [];
for (let i = 0; i < count; i += 1) {
ticks.push(min + ((max - min) * i) / (count - 1));
}
return ticks;
}
function getExtent(values, fallbackMin = 0, fallbackMax = 1) {
if (!Array.isArray(values) || values.length === 0) {
return [fallbackMin, fallbackMax];
}
let min = Infinity;
let max = -Infinity;
for (const value of values) {
if (!Number.isFinite(value)) continue;
if (value < min) min = value;
if (value > max) max = value;
}
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return [fallbackMin, fallbackMax];
}
return [min, max];
}
export default function LinePlotOverlay({
overlay,
x1,
x2,
aLocked,
bLocked,
nodeId,
onWidgetChange,
interactive = true,
}) {
const containerRef = useRef(null);
const [dragging, setDragging] = useState(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!containerRef.current) return undefined;
const updateSize = () => {
if (!containerRef.current) return;
setSize({
width: Math.max(1, Math.round(containerRef.current.clientWidth || 320)),
height: Math.max(1, Math.round(containerRef.current.clientHeight || (containerRef.current.clientWidth / ASPECT_RATIO) || 220)),
});
};
updateSize();
if (typeof ResizeObserver === 'function') {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
setSize({
width: Math.max(1, Math.round(width)),
height: Math.max(1, Math.round(height)),
});
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, []);
const xValues = Array.isArray(overlay?.x_axis) && overlay.x_axis.length === overlay.line?.length
? overlay.x_axis
: overlay?.line?.map((_, i) => i) || [];
const yValues = Array.isArray(overlay?.line) ? overlay.line : [];
const width = size.width || 320;
const height = size.height || Math.round(width / ASPECT_RATIO);
const plotLeft = MARGINS.left;
const plotTop = MARGINS.top;
const plotWidth = Math.max(1, width - MARGINS.left - MARGINS.right);
const plotHeight = Math.max(1, height - MARGINS.top - MARGINS.bottom);
const [xMin, xMax] = getExtent(xValues, 0, 1);
const [yMinRaw, yMaxRaw] = getExtent(yValues, 0, 1);
const yPad = yMinRaw === yMaxRaw ? 1 : (yMaxRaw - yMinRaw) * 0.08;
const yMin = yMinRaw - yPad;
const yMax = yMaxRaw + yPad;
const scaleX = useCallback((value) => {
if (xMax === xMin) return plotLeft + plotWidth / 2;
return plotLeft + ((value - xMin) / (xMax - xMin)) * plotWidth;
}, [plotLeft, plotWidth, xMin, xMax]);
const scaleY = useCallback((value) => {
if (yMax === yMin) return plotTop + plotHeight / 2;
return plotTop + (1 - ((value - yMin) / (yMax - yMin))) * plotHeight;
}, [plotTop, plotHeight, yMin, yMax]);
const pickCursorPoint = useCallback((fraction) => {
if (!xValues.length || !yValues.length) {
return {
x: plotLeft,
y: plotTop + plotHeight / 2,
yFraction: 0.5,
};
}
const frac = clamp(fraction ?? 0.5, 0, 1);
const targetX = xMin + frac * (xMax - xMin || 1);
let idx = 0;
let best = Infinity;
for (let i = 0; i < xValues.length; i += 1) {
const dist = Math.abs(xValues[i] - targetX);
if (dist < best) {
best = dist;
idx = i;
}
}
const x = xValues[idx];
const y = yValues[idx];
const yFraction = yMax === yMin ? 0.5 : clamp((y - yMin) / (yMax - yMin), 0, 1);
return {
x: scaleX(x),
y: scaleY(y),
yFraction,
};
}, [plotLeft, plotTop, plotHeight, scaleX, scaleY, xValues, yValues, xMin, xMax, yMin, yMax]);
const cursorA = pickCursorPoint(x1 ?? overlay?.x1 ?? 0.25);
const cursorB = pickCursorPoint(x2 ?? overlay?.x2 ?? 0.75);
const path = yValues.map((y, i) => `${i === 0 ? 'M' : 'L'} ${scaleX(xValues[i])} ${scaleY(y)}`).join(' ');
const xTicks = makeTicks(xMin, xMax);
const yTicks = makeTicks(yMin, yMax);
const plotStroke = clamp(plotWidth / 240, 1.4, 2.6);
const gridStroke = clamp(plotWidth / 900, 0.6, 1.1);
const cursorStroke = clamp(plotWidth / 220, 1.4, 2.2);
const measureStroke = clamp(plotWidth / 180, 1.6, 2.8);
const markerRadius = clamp(plotWidth / 42, 5.5, 9);
const updateCursor = useCallback((point, event) => {
if (!interactive || !onWidgetChange || !nodeId) return;
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const xFrac = clamp((event.clientX - rect.left - plotLeft) / plotWidth, 0, 1);
const sample = pickCursorPoint(xFrac);
if (point === 'p1') {
onWidgetChange(nodeId, 'x1', round3(xFrac));
onWidgetChange(nodeId, 'y1', round3(sample.yFraction));
} else {
onWidgetChange(nodeId, 'x2', round3(xFrac));
onWidgetChange(nodeId, 'y2', round3(sample.yFraction));
}
}, [interactive, nodeId, onWidgetChange, pickCursorPoint, plotLeft, plotWidth]);
const onPointerDown = useCallback((point) => (event) => {
if (!interactive) return;
if ((point === 'p1' && aLocked) || (point === 'p2' && bLocked)) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(point);
}, [interactive, aLocked, bLocked]);
const onPointerMove = useCallback((event) => {
if (!dragging) return;
updateCursor(dragging, event);
}, [dragging, updateCursor]);
const onPointerUp = useCallback(() => {
setDragging(null);
}, []);
return (
<div
ref={containerRef}
className="nodrag nowheel lineplot-overlay"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
>
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="lineplot-svg">
<rect x="0" y="0" width={width} height={height} fill="#0f172a" />
{xTicks.map((tick) => {
const x = scaleX(tick);
return (
<g key={`x-${tick}`}>
<line x1={x} y1={plotTop} x2={x} y2={plotTop + plotHeight} stroke="#334155" strokeWidth={gridStroke} opacity="0.45" />
<text x={x} y={height - 10} textAnchor="middle" fontSize="11" fill="#94a3b8">
{formatTick(tick)}
</text>
</g>
);
})}
{yTicks.map((tick) => {
const y = scaleY(tick);
return (
<g key={`y-${tick}`}>
<line x1={plotLeft} y1={y} x2={plotLeft + plotWidth} y2={y} stroke="#334155" strokeWidth={gridStroke} opacity="0.45" />
<text x={plotLeft - 10} y={y + 4} textAnchor="end" fontSize="11" fill="#94a3b8">
{formatTick(tick)}
</text>
</g>
);
})}
<rect x={plotLeft} y={plotTop} width={plotWidth} height={plotHeight} fill="none" stroke="#334155" strokeWidth={gridStroke + 0.3} />
<path d={path} fill="none" stroke="#ff9800" strokeWidth={plotStroke} strokeLinecap="round" strokeLinejoin="round" />
{interactive && (
<>
<line x1={cursorA.x} y1={plotTop} x2={cursorA.x} y2={plotTop + plotHeight} stroke="#ffd700" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95" />
<line x1={cursorB.x} y1={plotTop} x2={cursorB.x} y2={plotTop + plotHeight} stroke="#ffd700" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95" />
<line x1={cursorA.x} y1={cursorA.y} x2={cursorB.x} y2={cursorB.y} stroke="#90caf9" strokeWidth={measureStroke} opacity="0.95" />
<circle
cx={cursorA.x}
cy={cursorA.y}
r={markerRadius}
className={`lineplot-marker ${aLocked ? 'lineplot-marker-locked' : ''}`}
onPointerDown={onPointerDown('p1')}
/>
<circle
cx={cursorB.x}
cy={cursorB.y}
r={markerRadius}
className={`lineplot-marker ${bLocked ? 'lineplot-marker-locked' : ''}`}
onPointerDown={onPointerDown('p2')}
/>
</>
)}
</svg>
</div>
);
}

View File

@@ -367,6 +367,41 @@ html, body, #root {
opacity: 0.9;
}
.lineplot-overlay {
width: 100%;
aspect-ratio: 32 / 22;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
overflow: hidden;
user-select: none;
touch-action: none;
}
.lineplot-svg {
display: block;
width: 100%;
height: 100%;
}
.lineplot-marker {
fill: #ffd700;
stroke: #fff;
stroke-width: 2px;
cursor: grab;
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.45));
}
.lineplot-marker:active {
cursor: grabbing;
}
.lineplot-marker-locked {
fill: #e91e63;
stroke: #e91e63;
cursor: default;
}
/* ── 3D surface view ──────────────────────────────────────────────── */
.surface-view-container {
width: 100%;

View File

@@ -0,0 +1,52 @@
function mergeDefinition(nodeData, defs) {
const savedData = nodeData || {};
const savedDefinition = savedData.definition && typeof savedData.definition === 'object'
? savedData.definition
: null;
const registryDefinition = savedData.className ? defs[savedData.className] : null;
const definition = registryDefinition || savedDefinition;
if (!definition) return null;
const output = Array.isArray(savedData.output)
? savedData.output
: (Array.isArray(savedDefinition?.output) ? savedDefinition.output : null);
const outputName = Array.isArray(savedData.output_name)
? savedData.output_name
: (Array.isArray(savedDefinition?.output_name) ? savedDefinition.output_name : null);
return {
...definition,
...(output ? { output } : {}),
...(outputName ? { output_name: outputName } : {}),
};
}
export function hydrateWorkflowState(data, defs = {}) {
const loadedNodes = Array.isArray(data?.nodes) ? data.nodes : [];
const loadedEdges = Array.isArray(data?.edges) ? data.edges : [];
const nodes = loadedNodes.map((node) => ({
...node,
type: node.type || 'custom',
dragHandle: node.dragHandle || '.drag-handle',
data: {
...node.data,
label: node.data?.label || node.data?.className || 'Node',
widgetValues: node.data?.widgetValues || {},
definition: mergeDefinition(node.data, defs),
previewImage: null,
tableRows: null,
meshData: null,
overlay: null,
},
}));
const nextNodeId = Math.max(0, ...loadedNodes.map((node) => parseInt(node.id, 10) || 0)) + 1;
return {
nodes,
edges: loadedEdges,
nextNodeId,
};
}

View File

@@ -10,6 +10,8 @@ export function serializeWorkflowState(nodes, edges) {
label: node.data?.label || node.data?.className || 'Node',
className: node.data?.className || '',
widgetValues: node.data?.widgetValues || {},
output: node.data?.definition?.output || [],
output_name: node.data?.definition?.output_name || [],
},
})),
edges: edges.map((edge) => ({
@@ -18,7 +20,7 @@ export function serializeWorkflowState(nodes, edges) {
sourceHandle: edge.sourceHandle,
target: edge.target,
targetHandle: edge.targetHandle,
style: edge.style,
...(edge.style ? { style: edge.style } : {}),
})),
};
}

View File

@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { hydrateWorkflowState } from '../src/workflowHydration.js';
import { serializeWorkflowState } from '../src/workflowSerialization.js';
test('serializeWorkflowState keeps only stable workflow fields needed for reload', () => {
@@ -59,6 +60,8 @@ test('serializeWorkflowState keeps only stable workflow fields needed for reload
label: 'Demo Label',
className: 'DemoNode',
widgetValues: { threshold: 0.42, mode: 'fast' },
output: [],
output_name: [],
},
},
{
@@ -70,6 +73,8 @@ test('serializeWorkflowState keeps only stable workflow fields needed for reload
label: 'NoLabelNode',
className: 'NoLabelNode',
widgetValues: {},
output: [],
output_name: [],
},
},
],
@@ -89,3 +94,99 @@ test('serializeWorkflowState keeps only stable workflow fields needed for reload
assert.equal('previewImage' in serialized.nodes[0].data, false);
assert.equal('selected' in serialized.edges[0], false);
});
test('hydrateWorkflowState restores saved dynamic outputs on top of current node definitions', () => {
const saved = {
version: 1,
nodes: [
{
id: '12',
position: { x: 40, y: 80 },
data: {
className: 'LoadFile',
widgetValues: { filename: 'scan.ibw', colormap: 'viridis' },
output: ['DATA_FIELD', 'DATA_FIELD'],
output_name: ['Height', 'Phase'],
previewImage: 'stale',
},
},
],
edges: [
{
id: 'e12-3',
source: '12',
sourceHandle: 'output::1::DATA_FIELD',
target: '3',
targetHandle: 'input::field::DATA_FIELD',
},
],
};
const defs = {
LoadFile: {
category: 'io',
input: { required: { filename: ['FILE_PICKER', {}], colormap: [['viridis', 'gray'], {}] } },
output: ['DATA_FIELD'],
output_name: ['field'],
manual_trigger: false,
},
};
const hydrated = hydrateWorkflowState(saved, defs);
assert.equal(hydrated.nextNodeId, 13);
assert.deepEqual(hydrated.edges, saved.edges);
assert.equal(hydrated.nodes[0].type, 'custom');
assert.equal(hydrated.nodes[0].dragHandle, '.drag-handle');
assert.equal(hydrated.nodes[0].data.label, 'LoadFile');
assert.equal(hydrated.nodes[0].data.previewImage, null);
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD', 'DATA_FIELD']);
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['Height', 'Phase']);
assert.deepEqual(hydrated.nodes[0].data.definition.input, defs.LoadFile.input);
});
test('serializeWorkflowState and hydrateWorkflowState preserve reload-critical metadata for dynamic nodes', () => {
const nodes = [
{
id: '7',
position: { x: 10, y: 20 },
data: {
label: 'Load File',
className: 'LoadFile',
widgetValues: { filename: 'scan.gwy', colormap: 'gray' },
definition: {
category: 'io',
input: { required: { filename: ['FILE_PICKER', {}], colormap: [['gray', 'viridis'], {}] } },
output: ['DATA_FIELD', 'DATA_FIELD', 'DATA_FIELD'],
output_name: ['Topography', 'Error', 'Mask'],
},
previewImage: 'data:image/png;base64,stale',
},
},
];
const edges = [
{
id: 'e7-9',
source: '7',
sourceHandle: 'output::2::DATA_FIELD',
target: '9',
targetHandle: 'input::field::DATA_FIELD',
},
];
const defs = {
LoadFile: {
category: 'io',
input: { required: { filename: ['FILE_PICKER', {}], colormap: [['gray', 'viridis'], {}] } },
output: ['DATA_FIELD'],
output_name: ['field'],
},
};
const serialized = serializeWorkflowState(nodes, edges);
const hydrated = hydrateWorkflowState(serialized, defs);
assert.deepEqual(hydrated.nodes[0].data.widgetValues, nodes[0].data.widgetValues);
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD', 'DATA_FIELD', 'DATA_FIELD']);
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['Topography', 'Error', 'Mask']);
assert.deepEqual(hydrated.edges, edges);
});