add folder, file nodes and major usability improvements

This commit is contained in:
2026-03-25 22:18:25 -07:00
parent 61b68c142b
commit 7f3dfa8fdf
22 changed files with 3881 additions and 299 deletions

View File

@@ -16,18 +16,27 @@ import { embedWorkflow, extractWorkflow } from './pngMetadata';
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
import { hydrateWorkflowState } from './workflowHydration';
import { serializeWorkflowState } from './workflowSerialization';
import { loadDefaultWorkflowAsset } from './defaultWorkflow';
import {
serializeExecutionGraph,
getAutoRunnableNodes,
hasBlockingAutoRunInput,
} from './executionGraph';
// ── Constants ─────────────────────────────────────────────────────────
const DATA_TYPES = new Set([
'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE',
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE',
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
]);
const SOCKET_COMPATIBILITY = {
STATS_SOURCE: new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'RECORD_TABLE']),
ANY_TABLE: new Set(['MEASURE_TABLE', 'RECORD_TABLE']),
VALUE_SOURCE: new Set(['FLOAT', 'MEASURE_TABLE']),
SAVE_LAYER: new Set(['DATA_FIELD', 'IMAGE']),
FLOAT: new Set(['INT']),
INT: new Set(['FLOAT']),
};
const TYPE_COLORS = {
@@ -39,8 +48,14 @@ const TYPE_COLORS = {
ANY_TABLE: '#67e8f9',
COORD: '#e91ed1',
FLOAT: '#7dd3fc',
INT: '#38bdf8',
STATS_SOURCE:'#c084fc',
VALUE_SOURCE:'#60a5fa',
COLORMAP: '#f472b6',
SAVE_LAYER: '#22c55e',
FONT: '#fb7185',
FILE_PATH: '#f59e0b',
DIRECTORY: '#f97316',
};
const NODE_TYPES = { custom: CustomNode };
@@ -59,6 +74,12 @@ function getOutputSlot(handleId) {
return parseInt(handleId.split('::')[1], 10);
}
function sameStringArray(a = [], b = []) {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
return a.every((item, index) => item === b[index]);
}
function socketTypesCompatible(sourceType, targetType) {
if (sourceType === targetType) return true;
const accepted = SOCKET_COMPATIBILITY[targetType];
@@ -221,43 +242,6 @@ async function captureViewportBlob(viewportEl, options) {
}
}
// ── Graph serialisation → backend prompt format ───────────────────────
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 = {};
// Widget (scalar) values
const required = definition.input.required || {};
for (const [name, spec] of Object.entries(required)) {
const [type] = Array.isArray(spec) ? spec : [spec];
if (DATA_TYPES.has(type)) continue; // socket, handled via edges
if (type === 'BUTTON') continue; // UI-only widget, not a backend input
if (widgetValues[name] !== undefined) {
inputs[name] = widgetValues[name];
}
}
// Connected (socket) inputs from edges
const incoming = edges.filter((e) => e.target === node.id);
for (const edge of incoming) {
const inputName = getInputName(edge.targetHandle);
const outputSlot = getOutputSlot(edge.sourceHandle);
inputs[inputName] = [edge.source, outputSlot];
}
prompt[node.id] = { class_type: className, inputs };
}
return prompt;
}
// ── Context menu component ────────────────────────────────────────────
function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirection }) {
@@ -461,25 +445,15 @@ function Flow() {
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' });
const [contextMenu, setContextMenu] = useState(null);
const [fileBrowserCb, setFileBrowserCb] = useState(null);
const [fileBrowserState, setFileBrowserState] = useState(null);
const nodeDefsRef = useRef({});
const nextIdRef = useRef(1);
const autoRunTimer = useRef(null);
const autoRunRef = useRef(null);
const defaultWorkflowLoadAttemptedRef = useRef(false);
const reactFlow = useReactFlow();
// ── Load node definitions ───────────────────────────────────────────
useEffect(() => {
api.getNodes().then((defs) => {
nodeDefsRef.current = defs;
setStatus({ text: `Loaded ${Object.keys(defs).length} nodes.`, level: 'info' });
}).catch((err) => {
setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' });
});
}, []);
// ── WebSocket ───────────────────────────────────────────────────────
const updateNodeData = useCallback((nodeId, patch) => {
@@ -488,6 +462,96 @@ function Flow() {
));
}, [setNodes]);
const setNodeOutputs = useCallback((nodeId, output, outputName, extraDefinitionPatch = {}) => {
setNodes((prev) => prev.map((node) => {
if (node.id !== nodeId) return node;
const currentDefinition = node.data.definition || {};
const nextDefinition = {
...currentDefinition,
...extraDefinitionPatch,
output,
output_name: outputName,
};
const sameOutputs = sameStringArray(currentDefinition.output, output);
const sameNames = sameStringArray(currentDefinition.output_name, outputName);
const sameOutputPaths = sameStringArray(currentDefinition.output_paths, nextDefinition.output_paths);
if (sameOutputs && sameNames && sameOutputPaths) {
return node;
}
return {
...node,
data: {
...node.data,
definition: nextDefinition,
},
};
}));
reactFlow.updateNodeInternals(nodeId);
}, [reactFlow, setNodes]);
const getResolvedPathInput = useCallback((nodeId) => {
const edge = reactFlow.getEdges().find(
(e) => e.target === nodeId && getInputName(e.targetHandle) === 'path'
);
if (!edge) return null;
const sourceNode = reactFlow.getNode(edge.source);
const outputPaths = sourceNode?.data?.definition?.output_paths;
const outputSlot = getOutputSlot(edge.sourceHandle);
if (Array.isArray(outputPaths) && typeof outputPaths[outputSlot] === 'string') {
return outputPaths[outputSlot];
}
return null;
}, [reactFlow]);
const refreshLoadNodeOutputs = useCallback(async (nodeId, explicitPath = null) => {
const node = reactFlow.getNode(nodeId);
if (!node) return;
let resolvedPath = typeof explicitPath === 'string' && explicitPath ? explicitPath : null;
if (!resolvedPath) {
resolvedPath = getResolvedPathInput(nodeId);
}
if (!resolvedPath) {
if (node.data.className === 'LoadFile') {
resolvedPath = node.data.widgetValues?.filename || '';
} else if (node.data.className === 'LoadDemo') {
resolvedPath = node.data.widgetValues?.name || '';
}
}
if (!resolvedPath) {
setNodeOutputs(nodeId, ['DATA_FIELD'], ['field'], { output_paths: [] });
return;
}
const channels = await api.getChannels(resolvedPath);
setNodeOutputs(
nodeId,
channels.map((channel) => channel.type),
channels.map((channel) => channel.name),
{ output_paths: [] },
);
}, [getResolvedPathInput, reactFlow, setNodeOutputs]);
const refreshFolderNodeOutputs = useCallback(async (nodeId, folderPath) => {
const entries = folderPath ? await api.getFolderFiles(folderPath) : [];
setNodeOutputs(
nodeId,
entries.map((entry) => entry.type),
entries.map((entry) => entry.name),
{ output_paths: entries.map((entry) => entry.path) },
);
const downstreamPathEdges = reactFlow.getEdges().filter(
(edge) => edge.source === nodeId && getInputName(edge.targetHandle) === 'path'
);
for (const edge of downstreamPathEdges) {
const outputSlot = getOutputSlot(edge.sourceHandle);
const resolvedPath = entries[outputSlot]?.path || null;
await refreshLoadNodeOutputs(edge.target, resolvedPath);
}
}, [reactFlow, refreshLoadNodeOutputs, setNodeOutputs]);
useEffect(() => {
api.setMessageHandler((msg) => {
console.log('[argonode] WS:', msg.type, msg.data?.node_id || msg.data?.node || '');
@@ -532,7 +596,7 @@ function Flow() {
case 'overlay':
updateNodeData(
msg.data.node_id,
msg.data.overlay?.kind === 'mask_paint'
msg.data.overlay?.kind === 'mask_paint' || msg.data.overlay?.kind === 'markup'
? { overlay: msg.data.overlay, previewImage: null }
: { overlay: msg.data.overlay },
);
@@ -568,8 +632,36 @@ function Flow() {
filtered
);
});
if (getInputName(params.targetHandle) === 'path') {
setTimeout(() => {
refreshLoadNodeOutputs(params.target);
}, 0);
}
scheduleAutoRun();
}, [setEdges]);
}, [refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
const handleEdgesChange = useCallback((changes) => {
const currentEdges = reactFlow.getEdges();
onEdgesChange(changes);
const affectedPathTargets = new Set();
for (const change of changes) {
if (change.type !== 'remove') continue;
const removedEdge = currentEdges.find((edge) => edge.id === change.id);
if (!removedEdge) continue;
if (getInputName(removedEdge.targetHandle) === 'path') {
affectedPathTargets.add(removedEdge.target);
}
}
if (affectedPathTargets.size > 0) {
setTimeout(() => {
affectedPathTargets.forEach((nodeId) => {
refreshLoadNodeOutputs(nodeId);
});
}, 0);
}
}, [onEdgesChange, reactFlow, refreshLoadNodeOutputs]);
// ── Drop-on-blank: open filtered context menu ──────────────────────
@@ -610,44 +702,35 @@ function Flow() {
};
}));
// If this is a filename/name change on a LoadFile/LoadDemo node, fetch channels
if ((name === 'filename' || name === 'name') && value) {
const node = reactFlow.getNode(nodeId);
if (node && (node.data.className === 'LoadFile' || node.data.className === 'LoadDemo')) {
api.getChannels(value).then((channels) => {
setNodes((prev) => prev.map((n) => {
if (n.id !== nodeId) return n;
return {
...n,
data: {
...n.data,
definition: {
...n.data.definition,
output: channels.map((c) => c.type),
output_name: channels.map((c) => c.name),
},
},
};
}));
reactFlow.updateNodeInternals(nodeId);
});
}
const node = reactFlow.getNode(nodeId);
if (node && node.data.className === 'Folder' && name === 'folder') {
refreshFolderNodeOutputs(nodeId, value);
}
if (node && (node.data.className === 'LoadFile' || node.data.className === 'LoadDemo') && (name === 'filename' || name === 'name')) {
refreshLoadNodeOutputs(nodeId, value);
}
scheduleAutoRun();
}, [setNodes]); // scheduleAutoRun is stable (no deps)
}, [reactFlow, refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes]); // scheduleAutoRun is stable (no deps)
// ── File browser ────────────────────────────────────────────────────
const openFileBrowser = useCallback((callback) => {
const openFileBrowser = useCallback((callback, { selectionMode = 'file' } = {}) => {
if (selectionMode === 'folder' && window.pywebview?.api?.open_folder_dialog) {
window.pywebview.api.open_folder_dialog().then((path) => {
if (path) callback(path);
});
return;
}
// Use native file picker when running inside pywebview (desktop app)
if (window.pywebview?.api?.open_file_dialog) {
if (selectionMode === 'file' && window.pywebview?.api?.open_file_dialog) {
window.pywebview.api.open_file_dialog().then((path) => {
if (path) callback(path);
});
return;
}
setFileBrowserCb(() => callback);
setFileBrowserState({ callback, selectionMode });
}, []);
// ── Node context value (stable) ─────────────────────────────────────
@@ -656,7 +739,7 @@ function Flow() {
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);
const prompt = serializeExecutionGraph(currentNodes, currentEdges);
if (!prompt || Object.keys(prompt).length === 0) return;
setStatus({ text: 'Saving…', level: 'info' });
api.runPrompt(prompt).catch((err) => {
@@ -715,25 +798,17 @@ function Flow() {
setNodes((ns) => [...ns, newNode]);
// Initialize dynamic outputs for nodes that depend on the selected path/folder.
if (className === 'Folder' && widgetValues.folder) {
refreshFolderNodeOutputs(newNodeId, widgetValues.folder);
}
// For LoadFile/LoadDemo, auto-fetch channels for the default value
if (className === 'LoadDemo' && widgetValues.name) {
api.getChannels(widgetValues.name).then((channels) => {
setNodes((prev) => prev.map((n) => {
if (n.id !== newNodeId) return n;
return {
...n,
data: {
...n.data,
definition: {
...n.data.definition,
output: channels.map((c) => c.type),
output_name: channels.map((c) => c.name),
},
},
};
}));
reactFlow.updateNodeInternals(newNodeId);
});
refreshLoadNodeOutputs(newNodeId, widgetValues.name);
}
if (className === 'LoadFile' && widgetValues.filename) {
refreshLoadNodeOutputs(newNodeId, widgetValues.filename);
}
// Auto-connect if this was triggered by dropping a connection on blank space
@@ -783,7 +858,7 @@ function Flow() {
setContextMenu(null);
scheduleAutoRun();
}, [contextMenu, reactFlow, setNodes, setEdges]);
}, [contextMenu, reactFlow, refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes, setEdges]); // scheduleAutoRun is stable (no deps)
// ── Toolbar actions ─────────────────────────────────────────────────
@@ -791,7 +866,7 @@ function Flow() {
// Read current state via functional ref to avoid stale closure
const currentNodes = reactFlow.getNodes();
const currentEdges = reactFlow.getEdges();
const prompt = serializeGraph(currentNodes, currentEdges);
const prompt = serializeExecutionGraph(currentNodes, currentEdges);
if (!prompt || Object.keys(prompt).length === 0) {
setStatus({ text: 'Graph is empty — add some nodes first.', level: 'error' });
@@ -809,28 +884,15 @@ function Flow() {
autoRunRef.current = () => {
const currentNodes = reactFlow.getNodes();
const currentEdges = reactFlow.getEdges();
const runnableNodes = getAutoRunnableNodes(currentNodes, currentEdges);
// 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 || 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
);
if (!hasEdge) return; // incomplete graph, skip auto-run
}
for (const node of runnableNodes) {
if (hasBlockingAutoRunInput(node, currentEdges)) return;
}
const prompt = serializeGraph(currentNodes, currentEdges, { excludeManualTrigger: true });
const prompt = serializeExecutionGraph(currentNodes, currentEdges, { excludeManualTrigger: true });
if (!prompt || Object.keys(prompt).length === 0) return;
setStatus({ text: 'Running…', level: 'info' });
api.runPrompt(prompt).catch((err) => {
@@ -855,7 +917,57 @@ function Flow() {
setNodes(hydrated.nodes);
setEdges(hydrated.edges);
nextIdRef.current = hydrated.nextNodeId;
}, [setNodes, setEdges]);
setTimeout(() => {
hydrated.nodes.forEach((node) => {
if (node.data.className === 'Folder' && node.data.widgetValues?.folder) {
refreshFolderNodeOutputs(node.id, node.data.widgetValues.folder);
}
});
hydrated.nodes.forEach((node) => {
if (node.data.className === 'LoadFile' || node.data.className === 'LoadDemo') {
refreshLoadNodeOutputs(node.id);
}
});
}, 0);
}, [refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes, setEdges]);
const loadDefaultWorkflow = useCallback(async () => {
if (defaultWorkflowLoadAttemptedRef.current) return;
defaultWorkflowLoadAttemptedRef.current = true;
const graphHasContent = () => {
const currentNodes = reactFlow.getNodes();
const currentEdges = reactFlow.getEdges();
return currentNodes.length > 0 || currentEdges.length > 0;
};
if (graphHasContent()) return;
try {
const loaded = await loadDefaultWorkflowAsset();
if (!loaded || graphHasContent()) return;
applyWorkflowData(loaded.workflow);
setStatus({ text: `Loaded default workflow from ${loaded.source}.`, level: 'info' });
requestAnimationFrame(() => {
requestAnimationFrame(() => scheduleAutoRun());
});
} catch (err) {
setStatus({ text: 'Default workflow failed to load: ' + err.message, level: 'error' });
}
}, [applyWorkflowData, reactFlow, scheduleAutoRun]);
// ── Load node definitions ───────────────────────────────────────────
useEffect(() => {
api.getNodes().then((defs) => {
nodeDefsRef.current = defs;
setStatus({ text: `Loaded ${Object.keys(defs).length} nodes.`, level: 'info' });
loadDefaultWorkflow();
}).catch((err) => {
setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' });
});
}, [loadDefaultWorkflow]);
const getWorkflowBlob = useCallback(async () => {
const viewportEl = document.querySelector('.react-flow__viewport');
@@ -1112,7 +1224,7 @@ function Flow() {
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onConnectEnd={onConnectEnd}
isValidConnection={isValidConnection}
@@ -1150,10 +1262,11 @@ function Flow() {
</div>
{/* File browser modal */}
{fileBrowserCb && (
{fileBrowserState && (
<FileBrowser
onSelect={(path) => { fileBrowserCb(path); setFileBrowserCb(null); }}
onClose={() => setFileBrowserCb(null)}
selectionMode={fileBrowserState.selectionMode}
onSelect={(path) => { fileBrowserState.callback(path); setFileBrowserState(null); }}
onClose={() => setFileBrowserState(null)}
/>
)}
</div>

View File

@@ -6,14 +6,15 @@ const SurfaceView = lazy(() => import('./SurfaceView'));
const CrossSectionOverlay = lazy(() => import('./CrossSectionOverlay'));
const CropBoxOverlay = lazy(() => import('./CropBoxOverlay'));
const MaskPaintOverlay = lazy(() => import('./MaskPaintOverlay'));
const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
// ── Constants ─────────────────────────────────────────────────────────
const DATA_TYPES = new Set([
'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE',
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE',
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
]);
const SOCKET_WIDGET_TYPES = new Set(['FLOAT']);
const SOCKET_WIDGET_TYPES = new Set(['FLOAT', 'INT']);
const TYPE_COLORS = {
DATA_FIELD: '#3a7abf',
@@ -24,8 +25,14 @@ const TYPE_COLORS = {
ANY_TABLE: '#67e8f9',
COORD: '#e91e63',
FLOAT: '#7dd3fc',
INT: '#38bdf8',
STATS_SOURCE:'#c084fc',
VALUE_SOURCE:'#60a5fa',
COLORMAP: '#f472b6',
SAVE_LAYER: '#22c55e',
FONT: '#fb7185',
FILE_PATH: '#f59e0b',
DIRECTORY: '#f97316',
};
const CAT_COLORS = {
@@ -128,6 +135,21 @@ function DraggableNumber({ value, step, min, max, precision, onChange }) {
}
}, [display]);
const onWheel = useCallback((e) => {
if (editing) return;
e.preventDefault();
const baseStep = Number(step) || 1;
const multiplier = e.shiftKey ? 10 : 1;
const delta = (e.deltaY < 0 ? 1 : -1) * baseStep * multiplier;
const startVal = Number(value);
const raw = (Number.isFinite(startVal) ? startVal : 0) + delta;
const rounded = precision != null
? parseFloat(raw.toFixed(precision))
: Math.round(raw);
onChange(clamp(rounded));
}, [editing, step, value, precision, onChange, clamp]);
const commitEdit = useCallback(() => {
setEditing(false);
const parsed = parseFloat(editText);
@@ -155,6 +177,7 @@ function DraggableNumber({ value, step, min, max, precision, onChange }) {
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onWheel={onWheel}
>
<span className="drag-number-val">{display}</span>
</div>
@@ -179,6 +202,57 @@ function CollapsibleSection({ title, defaultOpen, children }) {
);
}
function LayerGalleryPreview({ overlay }) {
const layers = Array.isArray(overlay?.layers) ? overlay.layers : [];
const [index, setIndex] = useState(0);
useEffect(() => {
setIndex(0);
}, [overlay]);
useEffect(() => {
if (layers.length === 0) {
setIndex(0);
return;
}
if (index >= layers.length) {
setIndex(layers.length - 1);
}
}, [index, layers.length]);
if (layers.length === 0) return null;
const active = layers[index] || layers[0];
return (
<div className="layer-gallery">
<div className="layer-gallery-toolbar">
<button
className="layer-gallery-btn nodrag"
onClick={() => setIndex((current) => (current - 1 + layers.length) % layers.length)}
>
{'<'}
</button>
<div className="layer-gallery-name" title={active.name || `Layer ${index + 1}`}>
{active.name || `Layer ${index + 1}`}
</div>
<button
className="layer-gallery-btn nodrag"
onClick={() => setIndex((current) => (current + 1) % layers.length)}
>
{'>'}
</button>
</div>
<div className="layer-gallery-count">
{index + 1} / {layers.length}
</div>
<div className="node-preview">
<img src={active.image} alt={active.name || `layer ${index + 1}`} draggable={false} />
</div>
</div>
);
}
function getTableColumns(rows) {
const columns = [];
for (const row of rows) {
@@ -352,6 +426,28 @@ function getSourceNodeForInput(store, nodeId, inputName) {
return store.nodeLookup?.get(edge.source) || store.nodes?.find((n) => n.id === edge.source) || null;
}
function getConnectedOutputInfo(store, nodeId, inputName) {
const targetHandle = `input::${inputName}::`;
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
if (!edge?.sourceHandle) return null;
const sourceNode = store.nodeLookup?.get(edge.source) || store.nodes?.find((n) => n.id === edge.source) || null;
const slot = Number.parseInt(edge.sourceHandle.split('::')[1], 10);
if (!sourceNode || !Number.isInteger(slot)) return null;
return {
path: sourceNode.data?.definition?.output_paths?.[slot] || null,
name: sourceNode.data?.definition?.output_name?.[slot] || null,
};
}
function getBasename(value) {
if (typeof value !== 'string') return '';
const trimmed = value.trim();
if (!trimmed) return '';
const normalized = trimmed.replace(/\\/g, '/').replace(/\/+$/, '');
const parts = normalized.split('/');
return parts[parts.length - 1] || '';
}
function getWidgetSourceInputName(opts) {
return opts?.source_type_input
|| opts?.choices_from_table_input
@@ -368,6 +464,197 @@ function widgetVisibleForSourceType(widget, sourceType) {
return allowed.includes(sourceType);
}
function widgetVisibleForWidgetValues(widget, widgetValues) {
const rules = widget?.opts?.show_when_widget_value;
if (!rules || typeof rules !== 'object') return true;
for (const [widgetName, allowedValues] of Object.entries(rules)) {
const allowed = Array.isArray(allowedValues) ? allowedValues.map(String) : [];
if (allowed.length === 0) continue;
if (!allowed.includes(String(widgetValues?.[widgetName] ?? ''))) {
return false;
}
}
return true;
}
function widgetHiddenByConnectedInput(widget, connectedInputs) {
const raw = widget?.opts?.hide_when_input_connected;
if (!raw || !connectedInputs) return false;
const inputs = Array.isArray(raw) ? raw : [raw];
return inputs.some((inputName) => connectedInputs.has(String(inputName)));
}
function widgetVisibleForInputVisibility(widget, visibleInputs) {
const raw = widget?.opts?.show_when_input_visible;
if (!raw) return true;
const inputs = Array.isArray(raw) ? raw : [raw];
return inputs.some((inputName) => visibleInputs?.has(String(inputName)));
}
function getWidgetInlineInputName(widget) {
const raw = widget?.opts?.inline_with_input;
if (!raw) return null;
return String(Array.isArray(raw) ? raw[0] : raw);
}
const DEFAULT_COLORMAP_STOPS = [
{ position: 0, color: '#440154' },
{ position: 1, color: '#fde725' },
];
function normalizeHexColor(color, fallback = '#000000') {
if (typeof color !== 'string') return fallback;
let text = color.trim();
if (text.startsWith('#') && text.length === 4) {
text = `#${text.slice(1).split('').map((ch) => `${ch}${ch}`).join('')}`;
}
if (/^#[0-9a-fA-F]{6}$/.test(text)) {
return text.toLowerCase();
}
return fallback;
}
function parseColorMapStops(raw) {
let parsed = raw;
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw);
} catch {
parsed = DEFAULT_COLORMAP_STOPS;
}
}
if (!Array.isArray(parsed)) {
parsed = DEFAULT_COLORMAP_STOPS;
}
const stops = parsed
.map((stop) => {
const position = Number(stop?.position);
return {
position: Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0,
color: normalizeHexColor(stop?.color, '#000000'),
};
})
.sort((a, b) => a.position - b.position);
if (stops.length < 2) {
return DEFAULT_COLORMAP_STOPS.map((stop) => ({ ...stop }));
}
stops[0].position = 0;
stops[stops.length - 1].position = 1;
return stops;
}
function serializeColorMapStops(stops) {
return JSON.stringify(stops.map((stop, index) => ({
position: index === 0 ? 0 : index === stops.length - 1 ? 1 : Number(stop.position.toFixed(4)),
color: normalizeHexColor(stop.color, '#000000'),
})));
}
function colorMapGradient(stops) {
return `linear-gradient(90deg, ${stops.map((stop) => `${stop.color} ${Math.round(stop.position * 1000) / 10}%`).join(', ')})`;
}
function ColorMapStopsEditor({ nodeId, name, value, onChange }) {
const stops = parseColorMapStops(value);
const commitStops = useCallback((nextStops) => {
const ordered = [...nextStops].sort((a, b) => a.position - b.position);
if (ordered.length < 2) return;
ordered[0] = { ...ordered[0], position: 0 };
ordered[ordered.length - 1] = { ...ordered[ordered.length - 1], position: 1 };
onChange(nodeId, name, serializeColorMapStops(ordered));
}, [name, nodeId, onChange]);
const updateStop = useCallback((index, patch) => {
const next = stops.map((stop, stopIndex) => (stopIndex === index ? { ...stop, ...patch } : { ...stop }));
if (index > 0 && index < next.length - 1) {
const prev = next[index - 1].position + 0.001;
const after = next[index + 1].position - 0.001;
next[index].position = Math.max(prev, Math.min(after, next[index].position));
}
commitStops(next);
}, [commitStops, stops]);
const removeStop = useCallback((index) => {
if (stops.length <= 2) return;
commitStops(stops.filter((_, stopIndex) => stopIndex !== index));
}, [commitStops, stops]);
const addStop = useCallback(() => {
let gapIndex = 0;
let gapSize = -1;
for (let i = 0; i < stops.length - 1; i += 1) {
const gap = stops[i + 1].position - stops[i].position;
if (gap > gapSize) {
gapIndex = i;
gapSize = gap;
}
}
const left = stops[gapIndex];
const right = stops[gapIndex + 1];
const newStop = {
position: Number((((left.position + right.position) / 2)).toFixed(4)),
color: left.color,
};
const next = [...stops];
next.splice(gapIndex + 1, 0, newStop);
commitStops(next);
}, [commitStops, stops]);
return (
<div className="colormap-editor">
<div className="colormap-preview" style={{ backgroundImage: colorMapGradient(stops) }} />
<div className="colormap-stop-list">
{stops.map((stop, index) => {
const isEndpoint = index === 0 || index === stops.length - 1;
return (
<div className="colormap-stop-row" key={`${index}-${stop.position}-${stop.color}`}>
<span className="colormap-stop-label">{isEndpoint ? (index === 0 ? 'min' : 'max') : `stop ${index}`}</span>
<input
className="nodrag colormap-stop-color"
type="color"
value={normalizeHexColor(stop.color, '#000000')}
onChange={(e) => updateStop(index, { color: e.target.value })}
/>
{isEndpoint ? (
<span className="colormap-stop-boundary">{index === 0 ? '0%' : '100%'}</span>
) : (
<input
className="nodrag colormap-stop-position"
type="number"
min="0.001"
max="0.999"
step="0.01"
value={Number(stop.position.toFixed(4))}
onChange={(e) => updateStop(index, { position: Number(e.target.value) })}
/>
)}
<button
className="nodrag colormap-stop-action"
type="button"
disabled={isEndpoint}
onClick={() => removeStop(index)}
>
Remove
</button>
</div>
);
})}
</div>
<button className="nodrag widget-button colormap-add-stop" type="button" onClick={addStop}>
Add Stop
</button>
</div>
);
}
function NodeTable({ rows }) {
const columns = getTableColumns(rows);
if (columns.length === 0) return null;
@@ -440,6 +727,9 @@ function CustomNode({ id, data }) {
const def = data.definition;
const scalarDisplay = formatScalarDisplay(data.scalarValue);
const processingTimeText = formatProcessingTime(data.processingTimeMs);
const connectedPathInfo = useStore(
useCallback((s) => getConnectedOutputInfo(s, id, 'path'), [id]),
);
// Parse inputs into data handles and widgets
const required = def.input.required || {};
@@ -447,13 +737,15 @@ function CustomNode({ id, data }) {
const dataInputs = [];
const widgets = [];
const visibleInputNames = new Set();
const hiddenWidgets = new Set();
for (const [name, spec] of Object.entries(required)) {
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
if (DATA_TYPES.has(type)) {
dataInputs.push({ name, type });
dataInputs.push({ name, type, label: opts?.label || name });
visibleInputNames.add(name);
} else if (opts?.hidden) {
hiddenWidgets.add(name);
} else {
@@ -467,7 +759,6 @@ function CustomNode({ id, data }) {
const connectedInputs = useStore(
useCallback(
(s) => {
if (!isProgressive) return null;
const set = new Set();
for (const e of s.edges) {
if (e.target === id) {
@@ -477,7 +768,7 @@ function CustomNode({ id, data }) {
}
return set;
},
[id, isProgressive],
[id],
),
);
@@ -503,7 +794,8 @@ function CustomNode({ id, data }) {
if (match) {
const idx = parseInt(match[1], 10);
if (idx === 0 || (connectedInputs && connectedInputs.has(`field_${idx - 1}`))) {
dataInputs.push({ name, type });
dataInputs.push({ name, type, label: opts?.label || name });
visibleInputNames.add(name);
}
continue;
}
@@ -511,12 +803,42 @@ function CustomNode({ id, data }) {
if (opts?.hidden) {
hiddenWidgets.add(name);
} else if (DATA_TYPES.has(type)) {
dataInputs.push({ name, type });
dataInputs.push({ name, type, label: opts?.label || name });
visibleInputNames.add(name);
} else {
widgets.push({ name, type, opts: opts || {}, socketType: SOCKET_WIDGET_TYPES.has(type) ? type : null });
}
}
const visibleWidgets = widgets.filter((w) => (
widgetVisibleForSourceType(w, connectedSourceTypes?.[getWidgetSourceInputName(w.opts)])
&& widgetVisibleForWidgetValues(w, data.widgetValues)
&& widgetVisibleForInputVisibility(w, visibleInputNames)
&& !widgetHiddenByConnectedInput(w, connectedInputs)
));
const combinedTopInputNames = new Set(
visibleWidgets
.map((widget) => widget?.opts?.top_socket_input)
.filter((name) => typeof name === 'string' && name.length > 0),
);
const renderedDataInputs = dataInputs.filter((input) => !combinedTopInputNames.has(input.name));
const dataInputByName = new Map(dataInputs.map((input) => [input.name, input]));
const inlineWidgetsByInput = new Map();
const topWidgets = [];
const standaloneWidgets = [];
for (const widget of visibleWidgets) {
const inlineInputName = getWidgetInlineInputName(widget);
if (inlineInputName) {
inlineWidgetsByInput.set(inlineInputName, widget);
} else if (widget.opts?.placement === 'top') {
topWidgets.push(widget);
} else {
standaloneWidgets.push(widget);
}
}
const outputs = def.output.map((type, i) => ({
name: def.output_name[i] || type,
type,
@@ -524,30 +846,85 @@ function CustomNode({ id, data }) {
}));
const catColor = CAT_COLORS[def.category] || '#333';
const maxIORows = Math.max(dataInputs.length, outputs.length);
const maxIORows = Math.max(renderedDataInputs.length, outputs.length);
const hasInteractiveLineOverlay = data.overlay?.kind === 'line_plot' && hiddenWidgets.has('x1');
const hasInteractiveOverlay = !!data.overlay && (hiddenWidgets.has('x1') || data.overlay.kind === 'mask_paint');
const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint';
const hasInteractiveOverlay = !!data.overlay && (
hiddenWidgets.has('x1')
|| data.overlay.kind === 'mask_paint'
|| data.overlay.kind === 'markup'
);
const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint' || data.overlay?.kind === 'markup';
const overlayTitle = data.overlay?.section_title
|| (data.overlay?.kind === 'mask_paint'
? 'Mask'
: data.overlay?.kind === 'markup'
? 'Markup'
: data.overlay?.kind === 'crop_box'
? 'Crop'
: data.overlay?.kind === 'line_plot'
? 'Line Plot'
: 'Cross Section');
: 'Cross Section');
const headerMeta = (() => {
if (data.className === 'Folder') {
return getBasename(data.widgetValues?.folder);
}
if (data.className === 'LoadFile') {
return getBasename(connectedPathInfo?.path || data.widgetValues?.filename);
}
if (data.className === 'LoadDemo') {
return getBasename(data.widgetValues?.name);
}
return '';
})();
return (
<div className="custom-node">
{/* Title */}
<div className="node-title drag-handle" style={{ background: catColor }}>
{data.label}
<span className="node-title-main">{data.label}</span>
{headerMeta && <span className="node-title-meta" title={headerMeta}>{headerMeta}</span>}
</div>
<div className="node-body">
{topWidgets.length > 0 && (
<div className="top-widget-section">
{topWidgets.map((w) => (
<div className={`widget-row${w.socketType ? ' widget-row-socket' : ''}`} key={w.name}>
{(w.socketType || w.opts?.top_socket_input) && (() => {
const socketInput = w.opts?.top_socket_input ? dataInputByName.get(w.opts.top_socket_input) : null;
const socketType = w.socketType || socketInput?.type;
const socketName = w.socketType ? w.name : socketInput?.name;
if (!socketType || !socketName) return null;
return (
<Handle
type="target"
position={Position.Left}
id={`input::${socketName}::${socketType}`}
className="typed-handle"
style={{ background: TYPE_COLORS[socketType] || '#999' }}
/>
);
})()}
<WidgetControl
widget={w}
nodeId={id}
value={data.widgetValues[w.name]}
widgetValues={data.widgetValues}
onChange={ctx.onWidgetChange}
openFileBrowser={ctx.openFileBrowser}
connected={!!(
(w.socketType && connectedInputs?.has(w.name))
|| (w.opts?.top_socket_input && connectedInputs?.has(w.opts.top_socket_input))
)}
/>
</div>
))}
</div>
)}
{/* I/O rows — pair inputs[i] with outputs[i] */}
{Array.from({ length: maxIORows }, (_, i) => {
const inp = dataInputs[i];
const inp = renderedDataInputs[i];
const out = outputs[i];
return (
<div className="io-row" key={`io-${i}`}>
@@ -561,7 +938,20 @@ function CustomNode({ id, data }) {
className="typed-handle"
style={{ background: TYPE_COLORS[inp.type] || '#999' }}
/>
<span className="io-label">{inp.name}</span>
<span className="io-label">{inp.label || inp.name}</span>
{inlineWidgetsByInput.has(inp.name) && (
<div className="io-inline-widget">
<WidgetControl
widget={inlineWidgetsByInput.get(inp.name)}
nodeId={id}
value={data.widgetValues[inlineWidgetsByInput.get(inp.name).name]}
widgetValues={data.widgetValues}
onChange={ctx.onWidgetChange}
openFileBrowser={ctx.openFileBrowser}
hideLabel={true}
/>
</div>
)}
</>
)}
</div>
@@ -601,7 +991,7 @@ function CustomNode({ id, data }) {
)}
{/* Widget rows */}
{widgets.filter((w) => widgetVisibleForSourceType(w, connectedSourceTypes?.[getWidgetSourceInputName(w.opts)])).map((w) => (
{standaloneWidgets.map((w) => (
<div className={`widget-row${w.socketType ? ' widget-row-socket' : ''}`} key={w.name}>
{w.socketType && (
<Handle
@@ -619,6 +1009,7 @@ function CustomNode({ id, data }) {
widgetValues={data.widgetValues}
onChange={ctx.onWidgetChange}
openFileBrowser={ctx.openFileBrowser}
connected={!!(w.socketType && connectedInputs?.has(w.name))}
/>
</div>
))}
@@ -654,6 +1045,7 @@ function CustomNode({ id, data }) {
resetKey={typeof data.previewImage === 'string' ? data.previewImage : JSON.stringify({
kind: data.previewImage.kind,
len: data.previewImage.line?.length,
layers: data.previewImage.layers?.length,
})}
fallbackImage={typeof data.previewImage === 'object' ? data.previewImage.fallback_image : null}
>
@@ -661,6 +1053,8 @@ function CustomNode({ id, data }) {
<div className="node-preview">
<img src={data.previewImage} alt="preview" draggable={false} />
</div>
) : data.previewImage.kind === 'layer_gallery' ? (
<LayerGalleryPreview overlay={data.previewImage} />
) : data.previewImage.kind === 'line_plot' ? (
<LinePlotOverlay overlay={data.previewImage} interactive={false} />
) : null}
@@ -704,6 +1098,16 @@ function CustomNode({ id, data }) {
nodeId={id}
onWidgetChange={ctx.onWidgetChange}
/>
) : data.overlay.kind === 'markup' ? (
<MarkupOverlay
image={data.overlay.image}
shape={data.widgetValues.shape ?? data.overlay.shape}
strokeColor={data.widgetValues.stroke_color ?? data.overlay.stroke_color}
strokeWidth={data.widgetValues.stroke_width ?? data.overlay.stroke_width}
markupShapes={data.widgetValues.markup_shapes}
nodeId={id}
onWidgetChange={ctx.onWidgetChange}
/>
) : (
<CrossSectionOverlay
image={data.overlay.image}
@@ -739,9 +1143,11 @@ function CustomNode({ id, data }) {
// ── Widget renderer ───────────────────────────────────────────────────
function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser }) {
function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser, connected = false, hideLabel = false }) {
const { name, type, opts } = widget;
const label = opts?.label || name;
const val = value ?? opts?.default ?? '';
const placeholder = opts?.placeholder || '';
const dynamicSourceType = useStore(
useCallback(
(s) => {
@@ -818,11 +1224,34 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
onChange(nodeId, name, dynamicTypeChoices[0]);
}, [dynamicTypeChoices, name, nodeId, onChange, val]);
if (connected) {
return (
<>
{!hideLabel && <label>{label}</label>}
<div className="widget-linked-state">Connected</div>
</>
);
}
if (opts?.colormap_stops) {
return (
<>
{!hideLabel && <label>{label}</label>}
<ColorMapStopsEditor
nodeId={nodeId}
name={name}
value={val}
onChange={onChange}
/>
</>
);
}
// Combo / enum — type itself is the array of options
if (Array.isArray(type)) {
return (
<>
<label>{name}</label>
{!hideLabel && <label>{label}</label>}
<select
className="nodrag"
value={val || type[0]}
@@ -840,7 +1269,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
const selected = dynamicTypeChoices.includes(String(val)) ? String(val) : dynamicTypeChoices[0];
return (
<>
<label>{name}</label>
{!hideLabel && <label>{label}</label>}
<select
className="nodrag"
value={selected}
@@ -858,7 +1287,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
const selected = dynamicTableColumns.includes(String(val)) ? String(val) : dynamicTableColumns[0];
return (
<>
<label>{name}</label>
{!hideLabel && <label>{label}</label>}
<select
className="nodrag"
value={selected}
@@ -876,7 +1305,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
const selected = dynamicMeasurementChoices.includes(String(val)) ? String(val) : dynamicMeasurementChoices[0];
return (
<>
<label>{name}</label>
{!hideLabel && <label>{label}</label>}
<select
className="nodrag"
value={selected}
@@ -890,21 +1319,25 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
);
}
if (type === 'FILE_PICKER') {
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
const isFolderPicker = type === 'FOLDER_PICKER';
return (
<>
<label>{name}</label>
{!hideLabel && <label>{label}</label>}
<div className="file-picker-row">
<input
className="nodrag"
type="text"
value={val}
onChange={(e) => onChange(nodeId, name, e.target.value)}
placeholder="Select file…"
placeholder={placeholder || (isFolderPicker ? 'Select folder…' : 'Select file…')}
/>
<button
className="nodrag browse-btn"
onClick={() => openFileBrowser((path) => onChange(nodeId, name, path))}
onClick={() => openFileBrowser(
(path) => onChange(nodeId, name, path),
{ selectionMode: isFolderPicker ? 'folder' : 'file' },
)}
>
Browse
</button>
@@ -913,6 +1346,23 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
);
}
if (type === 'STRING' && opts?.color_picker) {
const normalized = typeof val === 'string' && /^#[0-9a-fA-F]{6}$/.test(val)
? val
: '#ffd54f';
return (
<>
{!hideLabel && <label>{label}</label>}
<input
className="nodrag widget-color-input"
type="color"
value={normalized}
onChange={(e) => onChange(nodeId, name, e.target.value)}
/>
</>
);
}
if (type === 'BUTTON') {
const updates = opts?.set_widgets && typeof opts.set_widgets === 'object'
? Object.entries(opts.set_widgets)
@@ -950,7 +1400,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
return (
<>
<label>{name}</label>
{!hideLabel && <label>{label}</label>}
<div className="slider-control">
<input
className="nodrag slider-input"
@@ -969,7 +1419,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
return (
<>
<label>{name}</label>
{!hideLabel && <label>{label}</label>}
<DraggableNumber
value={val || 0}
step={opts?.step ?? 0.01}
@@ -985,7 +1435,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
if (type === 'INT') {
return (
<>
<label>{name}</label>
{!hideLabel && <label>{label}</label>}
<DraggableNumber
value={val || 0}
step={opts?.step ?? 1}
@@ -1001,7 +1451,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
if (type === 'BOOLEAN') {
return (
<>
<label>{name}</label>
{!hideLabel && <label>{label}</label>}
<input
className="nodrag"
type="checkbox"
@@ -1015,11 +1465,12 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
// STRING and anything else
return (
<>
<label>{name}</label>
{!hideLabel && <label>{label}</label>}
<input
className="nodrag"
type="text"
value={val}
placeholder={placeholder}
onChange={(e) => onChange(nodeId, name, e.target.value)}
/>
</>

View File

@@ -5,10 +5,10 @@ import * as api from './api';
* Server-side file browser modal.
*
* Props:
* onSelect(absolutePath) — called when user picks a file
* onSelect(absolutePath) — called when user picks a file or folder
* onClose() — called when user dismisses the dialog
*/
export default function FileBrowser({ onSelect, onClose }) {
export default function FileBrowser({ onSelect, onClose, selectionMode = 'file' }) {
const [path, setPath] = useState('');
const [parent, setParent] = useState(null);
const [dirs, setDirs] = useState([]);
@@ -43,6 +43,11 @@ export default function FileBrowser({ onSelect, onClose }) {
{/* Header */}
<div className="fb-header">
<span className="fb-path">{path}</span>
{selectionMode === 'folder' && (
<button className="fb-select-btn" onClick={() => { onSelect(path); onClose(); }}>
Select Folder
</button>
)}
<button className="fb-close" onClick={onClose}></button>
</div>
@@ -75,8 +80,12 @@ export default function FileBrowser({ onSelect, onClose }) {
{files.map((f) => (
<div
key={f}
className="fb-entry fb-file"
onClick={() => { onSelect(path + '/' + f); onClose(); }}
className={`fb-entry fb-file${selectionMode === 'folder' ? ' fb-file-disabled' : ''}`}
onClick={() => {
if (selectionMode === 'folder') return;
onSelect(path + '/' + f);
onClose();
}}
>
{f}
</div>

View File

@@ -0,0 +1,285 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
function clampFraction(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(1, numeric));
}
function sanitizeColor(color, fallback = '#ffd54f') {
if (typeof color !== 'string') return fallback;
const value = color.trim();
return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
}
function sanitizeShape(shape, fallbackShape, fallbackColor, fallbackWidth) {
if (!shape || typeof shape !== 'object') return null;
const kind = ['line', 'rectangle', 'circle', 'arrow'].includes(shape.kind) ? shape.kind : fallbackShape;
const x1 = clampFraction(shape.x1);
const y1 = clampFraction(shape.y1);
const x2 = clampFraction(shape.x2);
const y2 = clampFraction(shape.y2);
const width = Math.max(1, Math.min(64, Math.round(Number(shape.width) || fallbackWidth || 1)));
return {
kind,
x1: Number(x1.toFixed(4)),
y1: Number(y1.toFixed(4)),
x2: Number(x2.toFixed(4)),
y2: Number(y2.toFixed(4)),
width,
color: sanitizeColor(shape.color, fallbackColor),
};
}
function parseMarkupShapes(markupShapes, fallbackShape, fallbackColor, fallbackWidth) {
if (Array.isArray(markupShapes)) {
return markupShapes
.map((shape) => sanitizeShape(shape, fallbackShape, fallbackColor, fallbackWidth))
.filter(Boolean);
}
if (typeof markupShapes !== 'string' || !markupShapes.trim()) return [];
try {
const parsed = JSON.parse(markupShapes);
if (!Array.isArray(parsed)) return [];
return parsed
.map((shape) => sanitizeShape(shape, fallbackShape, fallbackColor, fallbackWidth))
.filter(Boolean);
} catch {
return [];
}
}
function arrowPoints(shape, imageWidth, imageHeight) {
const x1 = shape.x1 * imageWidth;
const y1 = shape.y1 * imageHeight;
const x2 = shape.x2 * imageWidth;
const y2 = shape.y2 * imageHeight;
const dx = x2 - x1;
const dy = y2 - y1;
const length = Math.hypot(dx, dy) || 1;
const ux = dx / length;
const uy = dy / length;
const strokeWidth = Math.max(1, shape.width);
const headLength = Math.max(10, strokeWidth * 4);
const headWidth = Math.max(8, strokeWidth * 3);
const overlap = Math.max(1, strokeWidth * 0.75);
const shaftX = x2 - ux * Math.max(0, headLength - overlap);
const shaftY = y2 - uy * Math.max(0, headLength - overlap);
const headBaseX = x2 - ux * headLength;
const headBaseY = y2 - uy * headLength;
const px = -uy;
const py = ux;
const leftX = headBaseX + px * headWidth * 0.5;
const leftY = headBaseY + py * headWidth * 0.5;
const rightX = headBaseX - px * headWidth * 0.5;
const rightY = headBaseY - py * headWidth * 0.5;
return {
line: `${x1},${y1} ${shaftX},${shaftY}`,
head: `${x2},${y2} ${leftX},${leftY} ${rightX},${rightY}`,
};
}
function ShapeElement({ shape, imageWidth, imageHeight }) {
const x1 = shape.x1 * imageWidth;
const y1 = shape.y1 * imageHeight;
const x2 = shape.x2 * imageWidth;
const y2 = shape.y2 * imageHeight;
const left = Math.min(x1, x2);
const top = Math.min(y1, y2);
const width = Math.abs(x2 - x1);
const height = Math.abs(y2 - y1);
const strokeWidth = Math.max(1, shape.width);
const common = {
fill: 'none',
stroke: shape.color,
strokeWidth,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
};
if (shape.kind === 'line') {
return <line x1={x1} y1={y1} x2={x2} y2={y2} {...common} />;
}
if (shape.kind === 'rectangle') {
return <rect x={left} y={top} width={width} height={height} {...common} />;
}
if (shape.kind === 'circle') {
return (
<ellipse
cx={left + width / 2}
cy={top + height / 2}
rx={width / 2}
ry={height / 2}
{...common}
/>
);
}
const arrow = arrowPoints(shape, imageWidth, imageHeight);
return (
<>
<polyline points={arrow.line} {...common} />
<polygon
points={arrow.head}
fill={shape.color}
/>
</>
);
}
export default function MarkupOverlay({
image,
shape,
strokeColor,
strokeWidth,
markupShapes,
nodeId,
onWidgetChange,
}) {
const containerRef = useRef(null);
const imageRef = useRef(null);
const shapesRef = useRef([]);
const [draftShape, setDraftShape] = useState(null);
const [drawing, setDrawing] = useState(false);
const [imageSize, setImageSize] = useState({ width: 1, height: 1 });
const normalizedShape = useMemo(
() => (['line', 'rectangle', 'circle', 'arrow'].includes(shape) ? shape : 'line'),
[shape],
);
const normalizedColor = useMemo(() => sanitizeColor(strokeColor, '#ffd54f'), [strokeColor]);
const normalizedWidth = useMemo(
() => Math.max(1, Math.min(64, Math.round(Number(strokeWidth) || 3))),
[strokeWidth],
);
const committedShapes = useMemo(
() => parseMarkupShapes(markupShapes, normalizedShape, normalizedColor, normalizedWidth),
[markupShapes, normalizedShape, normalizedColor, normalizedWidth],
);
useEffect(() => {
shapesRef.current = committedShapes;
}, [committedShapes]);
useEffect(() => {
const img = imageRef.current;
if (!img) return;
const updateImageSize = () => {
const width = Math.max(1, img.naturalWidth || img.width || 1);
const height = Math.max(1, img.naturalHeight || img.height || 1);
setImageSize({ width, height });
};
updateImageSize();
if (!img.complete) {
img.addEventListener('load', updateImageSize);
return () => img.removeEventListener('load', updateImageSize);
}
return undefined;
}, [image]);
const getPoint = useCallback((event) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return null;
return {
x: Number(clampFraction((event.clientX - rect.left) / rect.width).toFixed(4)),
y: Number(clampFraction((event.clientY - rect.top) / rect.height).toFixed(4)),
};
}, []);
const commitShapes = useCallback((nextShapes) => {
if (!nodeId || !onWidgetChange) return;
onWidgetChange(nodeId, 'markup_shapes', JSON.stringify(nextShapes));
}, [nodeId, onWidgetChange]);
const handlePointerDown = useCallback((event) => {
if (!onWidgetChange || event.target.closest('button')) return;
const point = getPoint(event);
if (!point) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
setDrawing(true);
setDraftShape({
kind: normalizedShape,
color: normalizedColor,
width: normalizedWidth,
x1: point.x,
y1: point.y,
x2: point.x,
y2: point.y,
});
}, [getPoint, normalizedColor, normalizedShape, normalizedWidth, onWidgetChange]);
const handlePointerMove = useCallback((event) => {
if (!drawing) return;
const point = getPoint(event);
if (!point) return;
setDraftShape((current) => (current ? { ...current, x2: point.x, y2: point.y } : current));
}, [drawing, getPoint]);
const finishDrawing = useCallback(() => {
if (!draftShape) {
setDrawing(false);
return;
}
const nextShape = sanitizeShape(draftShape, normalizedShape, normalizedColor, normalizedWidth);
setDraftShape(null);
setDrawing(false);
if (!nextShape) return;
commitShapes([...shapesRef.current, nextShape]);
}, [commitShapes, draftShape, normalizedColor, normalizedShape, normalizedWidth]);
const undoLast = useCallback(() => {
if (shapesRef.current.length === 0) return;
commitShapes(shapesRef.current.slice(0, -1));
}, [commitShapes]);
const clearAll = useCallback(() => {
commitShapes([]);
}, [commitShapes]);
const renderedShapes = draftShape ? [...committedShapes, draftShape] : committedShapes;
return (
<div
ref={containerRef}
className={`nodrag nowheel markup-overlay${drawing ? ' markup-overlay-drawing' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishDrawing}
onPointerCancel={finishDrawing}
onLostPointerCapture={finishDrawing}
>
<img ref={imageRef} src={image} alt="markup source" draggable={false} className="markup-image" />
<svg
className="markup-svg"
viewBox={`0 0 ${imageSize.width} ${imageSize.height}`}
preserveAspectRatio="none"
>
{renderedShapes.map((item, index) => (
<ShapeElement
key={`${item.kind}-${index}`}
shape={item}
imageWidth={imageSize.width}
imageHeight={imageSize.height}
/>
))}
</svg>
<div className="markup-toolbar">
<button className="markup-tool-btn" type="button" onClick={undoLast} disabled={committedShapes.length === 0}>
Undo
</button>
<button className="markup-tool-btn" type="button" onClick={clearAll} disabled={committedShapes.length === 0}>
Clear
</button>
</div>
</div>
);
}

View File

@@ -40,6 +40,12 @@ export async function getChannels(filepath) {
return r.json();
}
export async function getFolderFiles(folderpath) {
const r = await fetch(`/folder-files?folder=${encodeURIComponent(folderpath)}`);
if (!r.ok) return [];
return r.json();
}
export async function runPrompt(prompt) {
const r = await fetch('/prompt', {
method: 'POST',

View File

@@ -0,0 +1,56 @@
import { extractWorkflow } from './pngMetadata.js';
const DEFAULT_WORKFLOW_CANDIDATES = [
{ path: '/default-workflow.json', type: 'json' },
{ path: '/default-workflow.png', type: 'png' },
];
async function loadCandidate(candidate, fetchImpl, extractWorkflowFn) {
let response;
try {
response = await fetchImpl(candidate.path, { cache: 'no-store' });
} catch {
return null;
}
const contentType = response.headers?.get?.('content-type') || '';
const isHtmlFallback = typeof contentType === 'string' && contentType.toLowerCase().includes('text/html');
if (!response.ok) {
if (response.status === 404 || response.status === 0) return null;
throw new Error(`Failed to load ${candidate.path} (${response.status})`);
}
if (candidate.type === 'json') {
if (isHtmlFallback) return null;
try {
return await response.json();
} catch {
throw new Error(`${candidate.path} is not valid JSON`);
}
}
if (isHtmlFallback) return null;
const workflow = await extractWorkflowFn(await response.blob());
if (!workflow) {
throw new Error(`${candidate.path} does not contain embedded workflow metadata`);
}
return workflow;
}
export async function loadDefaultWorkflowAsset({
fetchImpl = fetch,
extractWorkflowFn = extractWorkflow,
} = {}) {
for (const candidate of DEFAULT_WORKFLOW_CANDIDATES) {
const workflow = await loadCandidate(candidate, fetchImpl, extractWorkflowFn);
if (workflow) {
return {
source: candidate.path,
format: candidate.type,
workflow,
};
}
}
return null;
}

View File

@@ -0,0 +1,125 @@
const DATA_TYPES = new Set([
'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE',
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
]);
function getInputName(handleId) {
return handleId.split('::')[1];
}
function getOutputSlot(handleId) {
return parseInt(handleId.split('::')[1], 10);
}
export function getConnectedNodeIds(edges) {
const connectedNodeIds = new Set();
for (const edge of edges) {
connectedNodeIds.add(edge.source);
connectedNodeIds.add(edge.target);
}
return connectedNodeIds;
}
function isPreviewLoadNode(node) {
return ['LoadFile', 'LoadDemo'].includes(node?.data?.className);
}
function hasPreviewLoadSelection(node) {
if (node?.data?.className === 'LoadFile') {
return !!String(node.data?.widgetValues?.filename || '').trim();
}
if (node?.data?.className === 'LoadDemo') {
return !!String(node.data?.widgetValues?.name || '').trim();
}
return false;
}
function getRunnableNodeIds(nodes, edges) {
const connectedNodeIds = getConnectedNodeIds(edges);
const runnableNodeIds = new Set(connectedNodeIds);
for (const node of nodes) {
if (connectedNodeIds.has(node.id)) continue;
if (isPreviewLoadNode(node) && hasPreviewLoadSelection(node)) {
runnableNodeIds.add(node.id);
}
}
return runnableNodeIds;
}
export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = false } = {}) {
const runnableNodeIds = getRunnableNodeIds(nodes, edges);
const prompt = {};
for (const node of nodes) {
if (!runnableNodeIds.has(node.id)) continue;
const { className, definition, widgetValues } = node.data;
if (!definition) continue;
if (excludeManualTrigger && definition.manual_trigger) continue;
const inputs = {};
const allWidgets = {
...(definition.input.required || {}),
...(definition.input.optional || {}),
};
for (const [name, spec] of Object.entries(allWidgets)) {
const [type] = Array.isArray(spec) ? spec : [spec];
if (DATA_TYPES.has(type)) continue;
if (type === 'BUTTON') continue;
if (widgetValues[name] !== undefined) {
inputs[name] = widgetValues[name];
}
}
const incoming = edges.filter((edge) => edge.target === node.id);
for (const edge of incoming) {
const inputName = getInputName(edge.targetHandle);
const outputSlot = getOutputSlot(edge.sourceHandle);
inputs[inputName] = [edge.source, outputSlot];
}
prompt[node.id] = { class_type: className, inputs };
}
return prompt;
}
export function getAutoRunnableNodes(nodes, edges) {
const runnableNodeIds = getRunnableNodeIds(nodes, edges);
return nodes.filter((node) => runnableNodeIds.has(node.id));
}
export function hasBlockingAutoRunInput(node, edges) {
const def = node.data?.definition;
if (!def || def.manual_trigger) return false;
const required = def.input.required || {};
for (const [name, spec] of Object.entries(required)) {
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
const hiddenByConnectedInput = (() => {
const raw = opts?.hide_when_input_connected;
if (!raw) return false;
const inputs = Array.isArray(raw) ? raw : [raw];
return inputs.some((inputName) => edges.some(
(edge) => edge.target === node.id && getInputName(edge.targetHandle) === String(inputName)
));
})();
if (hiddenByConnectedInput) continue;
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
if (!node.data.widgetValues?.[name]) return true;
continue;
}
if (!DATA_TYPES.has(type)) continue;
const hasEdge = edges.some(
(edge) => edge.target === node.id && getInputName(edge.targetHandle) === name
);
if (!hasEdge) return true;
}
return false;
}

View File

@@ -141,6 +141,10 @@ html, body, #root {
.node-title {
padding: 5px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-weight: 600;
font-size: 12px;
color: white;
@@ -148,12 +152,36 @@ html, body, #root {
border-bottom: 1px solid rgba(0, 0, 0, 0.3);
}
.node-title-main {
min-width: 0;
}
.node-title-meta {
max-width: 48%;
min-width: 0;
padding: 1px 6px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.28);
color: rgba(255, 255, 255, 0.88);
font-size: 10px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-body {
padding: 4px 0;
display: flex;
flex-direction: column;
}
.top-widget-section {
padding-bottom: 2px;
border-bottom: 1px solid rgba(51, 65, 85, 0.35);
margin-bottom: 2px;
}
.node-warning {
padding: 3px 10px;
font-size: 10px;
@@ -226,6 +254,11 @@ html, body, #root {
gap: 4px;
}
.io-left {
flex: 1;
min-width: 0;
}
.io-label {
font-size: 10px;
color: #94a3b8;
@@ -280,8 +313,36 @@ html, body, #root {
flex-shrink: 0;
}
.io-inline-widget {
flex: 1;
min-width: 0;
margin-left: 8px;
display: flex;
align-items: center;
}
.io-inline-widget .widget-row,
.io-inline-widget label {
display: none;
}
.io-inline-widget input[type="text"],
.io-inline-widget input[type="number"],
.io-inline-widget input[type="color"],
.io-inline-widget select {
background: #0f172a;
color: #e0e0e0;
border: 1px solid #334155;
border-radius: 3px;
padding: 2px 5px;
font-size: 11px;
flex: 1;
min-width: 0;
}
.widget-row input[type="text"],
.widget-row input[type="number"],
.widget-row input[type="color"],
.widget-row select {
background: #0f172a;
color: #e0e0e0;
@@ -293,6 +354,11 @@ html, body, #root {
min-width: 0;
}
.widget-row input[type="color"] {
padding: 2px;
height: 24px;
}
.widget-row input[type="checkbox"] {
accent-color: #3a7abf;
}
@@ -314,6 +380,87 @@ html, body, #root {
border-color: #3a7abf;
}
.widget-linked-state {
flex: 1;
min-width: 0;
padding: 4px 8px;
border: 1px dashed rgba(244, 114, 182, 0.45);
border-radius: 4px;
background: rgba(30, 41, 59, 0.55);
color: #f9a8d4;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
text-align: center;
}
.colormap-editor {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.colormap-preview {
width: 100%;
height: 18px;
border-radius: 999px;
border: 1px solid #334155;
background-color: #0f172a;
}
.colormap-stop-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.colormap-stop-row {
display: grid;
grid-template-columns: 34px 34px minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
.colormap-stop-label,
.colormap-stop-boundary {
font-size: 10px;
color: #94a3b8;
}
.colormap-stop-color {
width: 34px;
height: 24px;
padding: 0;
border: 1px solid #334155;
border-radius: 4px;
background: #0f172a;
}
.colormap-stop-position {
width: 100%;
}
.colormap-stop-action {
background: #172554;
color: #dbeafe;
border: 1px solid #334155;
border-radius: 4px;
padding: 4px 8px;
font-size: 10px;
cursor: pointer;
}
.colormap-stop-action:disabled {
opacity: 0.45;
cursor: default;
}
.colormap-add-stop {
margin-top: 2px;
}
.slider-control {
display: flex;
align-items: center;
@@ -438,6 +585,54 @@ html, body, #root {
display: block;
}
.layer-gallery {
display: flex;
flex-direction: column;
gap: 6px;
}
.layer-gallery-toolbar {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 28px;
gap: 6px;
align-items: center;
}
.layer-gallery-btn {
height: 26px;
border: 1px solid #334155;
border-radius: 6px;
background: #0f172a;
color: #e2e8f0;
font-size: 14px;
cursor: pointer;
}
.layer-gallery-btn:disabled {
opacity: 0.4;
cursor: default;
}
.layer-gallery-name {
min-width: 0;
padding: 4px 8px;
border: 1px solid rgba(51, 65, 85, 0.9);
border-radius: 6px;
background: rgba(15, 23, 42, 0.8);
color: #cbd5e1;
font-size: 10px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layer-gallery-count {
font-size: 10px;
color: #64748b;
text-align: center;
}
/* ── Cross-section overlay ────────────────────────────────────────── */
.cs-overlay {
position: relative;
@@ -609,6 +804,60 @@ html, body, #root {
z-index: 2;
}
.markup-overlay {
position: relative;
overflow: hidden;
user-select: none;
touch-action: none;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
cursor: crosshair;
}
.markup-overlay-drawing {
cursor: crosshair;
}
.markup-image {
width: 100%;
display: block;
}
.markup-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: visible;
}
.markup-toolbar {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 6px;
z-index: 2;
}
.markup-tool-btn {
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.88);
color: #e2e8f0;
border-radius: 999px;
padding: 4px 9px;
font-size: 10px;
line-height: 1;
cursor: pointer;
}
.markup-tool-btn:disabled {
opacity: 0.45;
cursor: default;
}
/* ── 3D surface view ──────────────────────────────────────────────── */
.surface-view-container {
width: 100%;
@@ -830,7 +1079,7 @@ html, body, #root {
.fb-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid #0f3460;
}
@@ -852,6 +1101,17 @@ html, body, #root {
padding: 2px 6px;
}
.fb-close:hover { color: #e94560; }
.fb-select-btn {
background: #0f3460;
color: #e0e0e0;
border: 1px solid #334155;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
}
.fb-select-btn:hover { background: #1a4a8a; }
.fb-list {
overflow-y: auto;
padding: 6px 0;
@@ -868,6 +1128,13 @@ html, body, #root {
.fb-entry:hover { background: #0f3460; }
.fb-dir { color: #90caf9; }
.fb-file { color: #e0e0e0; }
.fb-file-disabled {
cursor: default;
opacity: 0.5;
}
.fb-file-disabled:hover {
background: transparent;
}
.fb-loading {
padding: 16px;
text-align: center;

View File

@@ -34,6 +34,26 @@ function getInputType(definition, inputName) {
return getSocketType(required[inputName] ?? optional[inputName]);
}
function getInputEntries(definition) {
return [
...Object.entries(definition?.input?.required || {}),
...Object.entries(definition?.input?.optional || {}),
];
}
function sanitizeWidgetValues(widgetValues, definition) {
const nextValues = { ...(widgetValues || {}) };
getInputEntries(definition).forEach(([inputName, inputDef]) => {
const type = getSocketType(inputDef);
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
nextValues[inputName] = '';
}
});
return nextValues;
}
function remapLegacyHandle(handleId, kind, nodeData) {
if (typeof handleId !== 'string') return handleId;
@@ -63,22 +83,26 @@ 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,
scalarValue: null,
},
}));
const nodes = loadedNodes.map((node) => {
const definition = mergeDefinition(node.data, defs);
return {
...node,
type: node.type || 'custom',
dragHandle: node.dragHandle || '.drag-handle',
data: {
...node.data,
label: node.data?.label || node.data?.className || 'Node',
widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition),
definition,
previewImage: null,
tableRows: null,
meshData: null,
overlay: null,
scalarValue: null,
},
};
});
const nodeById = new Map(nodes.map((node) => [String(node.id), node.data]));