feature focus on 3d viewer, add copy/paste

This commit is contained in:
2026-03-26 21:25:35 -07:00
parent de0b49acc5
commit 30671a5362
24 changed files with 1680 additions and 320 deletions

View File

@@ -16,6 +16,12 @@ import { embedWorkflow, extractWorkflow } from './pngMetadata';
import { captureViewportBlob as captureWorkflowViewportBlob } from './workflowCapture';
import { hydrateWorkflowState } from './workflowHydration';
import { serializeWorkflowState } from './workflowSerialization';
import {
buildNodeClipboardPayload,
instantiateNodeClipboardPayload,
NODE_CLIPBOARD_MIME,
parseNodeClipboardPayload,
} from './nodeClipboard';
import { loadDefaultWorkflowAsset } from './defaultWorkflow';
import {
serializeExecutionGraph,
@@ -49,6 +55,12 @@ function sameStringArray(a = [], b = []) {
return a.every((item, index) => item === b[index]);
}
function isEditableTarget(target) {
if (!target || !(target instanceof Element)) return false;
if (target.closest('input, textarea, select')) return true;
return target.closest('[contenteditable="true"]') !== null;
}
function compareMenuNodes(a, b) {
const orderA = Number.isFinite(a?.def?.menu_order) ? a.def.menu_order : Number.MAX_SAFE_INTEGER;
const orderB = Number.isFinite(b?.def?.menu_order) ? b.def.menu_order : Number.MAX_SAFE_INTEGER;
@@ -254,7 +266,11 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti
});
if (!hasMatch) continue;
} else {
if (!def.output.some((type) => socketTypesCompatible(type, filterType))) continue;
const hasMatch = def.output.some((type) =>
socketTypesCompatible(type, filterType)
|| (type === 'ANNOTATION_SOURCE' && (filterType === 'DATA_FIELD' || filterType === 'IMAGE'))
);
if (!hasMatch) continue;
}
}
const cat = def.category || 'uncategorized';
@@ -454,6 +470,8 @@ function Flow() {
const autoRunTimer = useRef(null);
const autoRunRef = useRef(null);
const defaultWorkflowLoadAttemptedRef = useRef(false);
const lastPastedClipboardTextRef = useRef('');
const pasteRepeatCountRef = useRef(0);
const reactFlow = useReactFlow();
// ── WebSocket ───────────────────────────────────────────────────────
@@ -554,6 +572,24 @@ function Flow() {
}
}, [reactFlow, refreshLoadNodeOutputs, setNodeOutputs]);
const refreshAnnotationNodeOutputs = useCallback((nodeId) => {
const node = reactFlow.getNode(nodeId);
if (!node) return;
const inputEdge = reactFlow.getEdges().find(
(edge) => edge.target === nodeId && getInputName(edge.targetHandle) === 'input'
);
const outputType = inputEdge ? getHandleType(inputEdge.sourceHandle) : 'ANNOTATION_SOURCE';
setNodeOutputs(nodeId, [outputType], ['Output']);
if (!inputEdge || outputType === 'ANNOTATION_SOURCE') return;
setEdges((prev) => prev.filter((edge) => {
if (edge.source !== nodeId) return true;
return socketTypesCompatible(outputType, getHandleType(edge.targetHandle));
}));
}, [reactFlow, setEdges, setNodeOutputs]);
useEffect(() => {
api.setMessageHandler((msg) => {
console.log('[argonode] WS:', msg.type, msg.data?.node_id || msg.data?.node || '');
@@ -639,14 +675,21 @@ function Flow() {
refreshLoadNodeOutputs(params.target);
}, 0);
}
const targetNode = reactFlow.getNode(params.target);
if (targetNode && (targetNode.data.className === 'Annotations' || targetNode.data.className === 'Markup')) {
setTimeout(() => {
refreshAnnotationNodeOutputs(params.target);
}, 0);
}
scheduleAutoRun();
}, [refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
}, [reactFlow, refreshAnnotationNodeOutputs, refreshLoadNodeOutputs, setEdges]); // scheduleAutoRun is stable (no deps)
const handleEdgesChange = useCallback((changes) => {
const currentEdges = reactFlow.getEdges();
onEdgesChange(changes);
const affectedPathTargets = new Set();
const affectedAnnotationTargets = new Set();
for (const change of changes) {
if (change.type !== 'remove') continue;
const removedEdge = currentEdges.find((edge) => edge.id === change.id);
@@ -654,6 +697,12 @@ function Flow() {
if (getInputName(removedEdge.targetHandle) === 'path') {
affectedPathTargets.add(removedEdge.target);
}
if (getInputName(removedEdge.targetHandle) === 'input') {
const targetNode = reactFlow.getNode(removedEdge.target);
if (targetNode && (targetNode.data.className === 'Annotations' || targetNode.data.className === 'Markup')) {
affectedAnnotationTargets.add(removedEdge.target);
}
}
}
if (affectedPathTargets.size > 0) {
@@ -663,7 +712,14 @@ function Flow() {
});
}, 0);
}
}, [onEdgesChange, reactFlow, refreshLoadNodeOutputs]);
if (affectedAnnotationTargets.size > 0) {
setTimeout(() => {
affectedAnnotationTargets.forEach((nodeId) => {
refreshAnnotationNodeOutputs(nodeId);
});
}, 0);
}
}, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshLoadNodeOutputs]);
// ── Drop-on-blank: open filtered context menu ──────────────────────
@@ -749,12 +805,6 @@ function Flow() {
});
}, [reactFlow]);
const contextValue = useMemo(() => ({
onWidgetChange,
openFileBrowser,
onManualTrigger,
}), [onWidgetChange, openFileBrowser, onManualTrigger]);
// ── Add node from context menu ──────────────────────────────────────
const addNode = useCallback((className, def) => {
@@ -789,6 +839,7 @@ function Flow() {
className,
definition: def,
widgetValues,
runtimeValues: {},
previewImage: null,
tableRows: null,
meshData: null,
@@ -842,9 +893,12 @@ function Flow() {
}
} else {
// Dragged from an input → connect from the first matching output on the new node
const outputIdx = def.output.findIndex((type) => socketTypesCompatible(type, filterType));
const outputIdx = def.output.findIndex((type) =>
socketTypesCompatible(type, filterType)
|| (type === 'ANNOTATION_SOURCE' && (filterType === 'DATA_FIELD' || filterType === 'IMAGE'))
);
if (outputIdx !== -1) {
const outputType = def.output[outputIdx];
const outputType = def.output[outputIdx] === 'ANNOTATION_SOURCE' ? filterType : def.output[outputIdx];
const sourceHandle = `output::${outputIdx}::${outputType}`;
const color = TYPE_COLORS[outputType] || 'var(--fallback-type)';
setEdges((eds) => addEdge({
@@ -907,6 +961,101 @@ function Flow() {
autoRunTimer.current = setTimeout(() => autoRunRef.current?.(), 300);
}, []);
const onRuntimeValuesChange = useCallback((nodeId, patch, { scheduleRun = false } = {}) => {
if (!patch || typeof patch !== 'object') return;
setNodes((ns) => ns.map((n) => {
if (n.id !== nodeId) return n;
return {
...n,
data: {
...n.data,
runtimeValues: { ...(n.data.runtimeValues || {}), ...patch },
},
};
}));
if (scheduleRun) {
scheduleAutoRun();
}
}, [setNodes, scheduleAutoRun]);
const pasteClipboardSelection = useCallback((clipboardText) => {
const payload = parseNodeClipboardPayload(clipboardText);
if (!payload) return false;
if (clipboardText === lastPastedClipboardTextRef.current) {
pasteRepeatCountRef.current += 1;
} else {
lastPastedClipboardTextRef.current = clipboardText;
pasteRepeatCountRef.current = 1;
}
const offsetAmount = 36 * pasteRepeatCountRef.current;
const pasted = instantiateNodeClipboardPayload(
payload,
nodeDefsRef.current,
nextIdRef.current,
{ x: offsetAmount, y: offsetAmount },
);
if (pasted.nodes.length === 0) return false;
nextIdRef.current = pasted.nextNodeId;
setNodes((existing) => [
...existing.map((node) => ({ ...node, selected: false })),
...pasted.nodes,
]);
setEdges((existing) => [
...existing.map((edge) => ({ ...edge, selected: false })),
...pasted.edges,
]);
setTimeout(() => {
pasted.nodes.forEach((node) => {
if (node.data.className === 'Folder' && node.data.widgetValues?.folder) {
refreshFolderNodeOutputs(node.id, node.data.widgetValues.folder);
}
});
pasted.nodes.forEach((node) => {
if (node.data.className === 'Image' || node.data.className === 'ImageDemo') {
refreshLoadNodeOutputs(node.id);
}
});
pasted.nodes.forEach((node) => {
if (node.data.className === 'Annotations' || node.data.className === 'Markup') {
refreshAnnotationNodeOutputs(node.id);
}
});
pasted.nodes.forEach((node) => {
reactFlow.updateNodeInternals(node.id);
});
}, 0);
setStatus({
text: `Pasted ${pasted.nodes.length} node${pasted.nodes.length === 1 ? '' : 's'}.`,
level: 'info',
});
scheduleAutoRun();
return true;
}, [
reactFlow,
refreshAnnotationNodeOutputs,
refreshFolderNodeOutputs,
refreshLoadNodeOutputs,
scheduleAutoRun,
setEdges,
setNodes,
]);
const contextValue = useMemo(() => ({
onWidgetChange,
onRuntimeValuesChange,
openFileBrowser,
onManualTrigger,
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger]);
const clearGraph = useCallback(() => {
setNodes([]);
setEdges([]);
@@ -930,8 +1079,13 @@ function Flow() {
refreshLoadNodeOutputs(node.id);
}
});
hydrated.nodes.forEach((node) => {
if (node.data.className === 'Annotations' || node.data.className === 'Markup') {
refreshAnnotationNodeOutputs(node.id);
}
});
}, 0);
}, [refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes, setEdges]);
}, [refreshAnnotationNodeOutputs, refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes, setEdges]);
const loadDefaultWorkflow = useCallback(async () => {
if (defaultWorkflowLoadAttemptedRef.current) return;
@@ -1168,6 +1322,45 @@ function Flow() {
return () => window.removeEventListener('keydown', handler);
}, [runWorkflow]);
useEffect(() => {
const handleCopy = (event) => {
if (isEditableTarget(event.target)) return;
const payload = buildNodeClipboardPayload(reactFlow.getNodes(), reactFlow.getEdges());
if (!payload) return;
const serialized = JSON.stringify(payload);
event.preventDefault();
event.clipboardData?.setData(NODE_CLIPBOARD_MIME, serialized);
event.clipboardData?.setData('text/plain', serialized);
setStatus({
text: `Copied ${payload.nodes.length} node${payload.nodes.length === 1 ? '' : 's'}.`,
level: 'info',
});
};
const handlePaste = (event) => {
if (isEditableTarget(event.target)) return;
const clipboardText = event.clipboardData?.getData(NODE_CLIPBOARD_MIME)
|| event.clipboardData?.getData('text/plain')
|| '';
if (!clipboardText) return;
const pasted = pasteClipboardSelection(clipboardText);
if (pasted) {
event.preventDefault();
}
};
window.addEventListener('copy', handleCopy);
window.addEventListener('paste', handlePaste);
return () => {
window.removeEventListener('copy', handleCopy);
window.removeEventListener('paste', handlePaste);
};
}, [pasteClipboardSelection, reactFlow]);
// ── Context menu ────────────────────────────────────────────────────
const onPaneContextMenu = useCallback((event) => {