diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a89b236..c3662ba 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -18,6 +18,7 @@ import { hydrateWorkflowState } from './workflowHydration'; import { serializeWorkflowState } from './workflowSerialization'; import { buildNodeClipboardPayload, + buildNodeClipboardPayloadForIds, instantiateNodeClipboardPayload, NODE_CLIPBOARD_MIME, parseNodeClipboardPayload, @@ -472,6 +473,7 @@ function Flow() { const defaultWorkflowLoadAttemptedRef = useRef(false); const lastPastedClipboardTextRef = useRef(''); const pasteRepeatCountRef = useRef(0); + const duplicateDragRef = useRef(null); const reactFlow = useReactFlow(); // ── WebSocket ─────────────────────────────────────────────────────── @@ -980,6 +982,29 @@ function Flow() { } }, [setNodes, scheduleAutoRun]); + const initializeDynamicNodes = useCallback((nodesToInitialize) => { + setTimeout(() => { + nodesToInitialize.forEach((node) => { + if (node.data.className === 'Folder' && node.data.widgetValues?.folder) { + refreshFolderNodeOutputs(node.id, node.data.widgetValues.folder); + } + }); + nodesToInitialize.forEach((node) => { + if (node.data.className === 'Image' || node.data.className === 'ImageDemo') { + refreshLoadNodeOutputs(node.id); + } + }); + nodesToInitialize.forEach((node) => { + if (node.data.className === 'Annotations' || node.data.className === 'Markup') { + refreshAnnotationNodeOutputs(node.id); + } + }); + nodesToInitialize.forEach((node) => { + reactFlow.updateNodeInternals(node.id); + }); + }, 0); + }, [reactFlow, refreshAnnotationNodeOutputs, refreshFolderNodeOutputs, refreshLoadNodeOutputs]); + const pasteClipboardSelection = useCallback((clipboardText) => { const payload = parseNodeClipboardPayload(clipboardText); if (!payload) return false; @@ -1012,26 +1037,7 @@ function Flow() { ...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); + initializeDynamicNodes(pasted.nodes); setStatus({ text: `Pasted ${pasted.nodes.length} node${pasted.nodes.length === 1 ? '' : 's'}.`, @@ -1040,10 +1046,8 @@ function Flow() { scheduleAutoRun(); return true; }, [ + initializeDynamicNodes, reactFlow, - refreshAnnotationNodeOutputs, - refreshFolderNodeOutputs, - refreshLoadNodeOutputs, scheduleAutoRun, setEdges, setNodes, @@ -1068,24 +1072,8 @@ function Flow() { setNodes(hydrated.nodes); setEdges(hydrated.edges); nextIdRef.current = hydrated.nextNodeId; - 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 === 'Image' || node.data.className === 'ImageDemo') { - refreshLoadNodeOutputs(node.id); - } - }); - hydrated.nodes.forEach((node) => { - if (node.data.className === 'Annotations' || node.data.className === 'Markup') { - refreshAnnotationNodeOutputs(node.id); - } - }); - }, 0); - }, [refreshAnnotationNodeOutputs, refreshFolderNodeOutputs, refreshLoadNodeOutputs, setNodes, setEdges]); + initializeDynamicNodes(hydrated.nodes); + }, [initializeDynamicNodes, setNodes, setEdges]); const loadDefaultWorkflow = useCallback(async () => { if (defaultWorkflowLoadAttemptedRef.current) return; @@ -1309,6 +1297,99 @@ function Flow() { } }, []); + const onNodeDragStart = useCallback((event, node) => { + if (!(event.ctrlKey || event.metaKey)) { + duplicateDragRef.current = null; + return; + } + + const currentNodes = reactFlow.getNodes(); + const draggedNodes = node.selected + ? currentNodes.filter((candidate) => candidate.selected) + : currentNodes.filter((candidate) => candidate.id === node.id); + if (draggedNodes.length === 0) return; + + const draggedIds = draggedNodes.map((candidate) => String(candidate.id)); + const payload = buildNodeClipboardPayloadForIds( + currentNodes, + reactFlow.getEdges(), + draggedIds, + { includeIncomingExternalEdges: true }, + ); + if (!payload) return; + + duplicateDragRef.current = { + draggedIds, + originPositions: Object.fromEntries( + draggedNodes.map((candidate) => [ + String(candidate.id), + { + x: Number(candidate.position?.x) || 0, + y: Number(candidate.position?.y) || 0, + }, + ]), + ), + payload, + }; + }, [reactFlow]); + + const onNodeDragStop = useCallback((_event, node) => { + const duplicateState = duplicateDragRef.current; + duplicateDragRef.current = null; + if (!duplicateState) return; + + const currentNodes = reactFlow.getNodes(); + const anchorId = duplicateState.draggedIds.includes(String(node.id)) + ? String(node.id) + : duplicateState.draggedIds[0]; + const anchorNode = currentNodes.find((candidate) => String(candidate.id) === anchorId); + const anchorOrigin = duplicateState.originPositions[anchorId]; + if (!anchorNode || !anchorOrigin) return; + + const offset = { + x: (Number(anchorNode.position?.x) || 0) - anchorOrigin.x, + y: (Number(anchorNode.position?.y) || 0) - anchorOrigin.y, + }; + + const duplicated = instantiateNodeClipboardPayload( + duplicateState.payload, + nodeDefsRef.current, + nextIdRef.current, + offset, + { keepExternalSources: true }, + ); + if (duplicated.nodes.length === 0) return; + + nextIdRef.current = duplicated.nextNodeId; + const draggedIdSet = new Set(duplicateState.draggedIds); + + setNodes((existing) => [ + ...existing.map((candidate) => { + const originalPosition = duplicateState.originPositions[String(candidate.id)]; + if (!draggedIdSet.has(String(candidate.id)) || !originalPosition) { + return { ...candidate, selected: false }; + } + return { + ...candidate, + selected: false, + position: originalPosition, + }; + }), + ...duplicated.nodes, + ]); + setEdges((existing) => [ + ...existing.map((edge) => ({ ...edge, selected: false })), + ...duplicated.edges, + ]); + + initializeDynamicNodes(duplicated.nodes); + setStatus({ + text: `Duplicated ${duplicated.nodes.length} node${duplicated.nodes.length === 1 ? '' : 's'}.`, + level: 'info', + }); + scheduleAutoRun(); + }, [initializeDynamicNodes, reactFlow, scheduleAutoRun, setEdges, setNodes]); + // ── Keyboard shortcut ─────────────────────────────────────────────── useEffect(() => { @@ -1420,12 +1501,15 @@ function Flow() { edges={edges} onNodesChange={onNodesChange} onEdgesChange={handleEdgesChange} + onNodeDragStart={onNodeDragStart} + onNodeDragStop={onNodeDragStop} onConnect={onConnect} onConnectEnd={onConnectEnd} isValidConnection={isValidConnection} nodeTypes={NODE_TYPES} onPaneContextMenu={onPaneContextMenu} colorMode="dark" + multiSelectionKeyCode={['Shift']} deleteKeyCode={['Backspace', 'Delete']} defaultEdgeOptions={{ type: 'default' }} > diff --git a/frontend/src/nodeClipboard.js b/frontend/src/nodeClipboard.js index b87e533..b00506f 100644 --- a/frontend/src/nodeClipboard.js +++ b/frontend/src/nodeClipboard.js @@ -18,13 +18,26 @@ function clonePlainObject(value) { return cloneValue(value) || {}; } -export function buildNodeClipboardPayload(nodes, edges) { - const selectedNodes = Array.isArray(nodes) ? nodes.filter((node) => node?.selected) : []; +export function buildNodeClipboardPayloadForIds( + nodes, + edges, + nodeIds, + { includeIncomingExternalEdges = false } = {}, +) { + const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id))); + const selectedNodes = Array.isArray(nodes) + ? nodes.filter((node) => selectedIdSet.has(String(node.id))) + : []; if (selectedNodes.length === 0) return null; - const selectedIds = new Set(selectedNodes.map((node) => String(node.id))); - const internalEdges = Array.isArray(edges) - ? edges.filter((edge) => selectedIds.has(String(edge.source)) && selectedIds.has(String(edge.target))) + const capturedEdges = Array.isArray(edges) + ? edges.filter((edge) => ( + selectedIdSet.has(String(edge.target)) + && ( + selectedIdSet.has(String(edge.source)) + || (includeIncomingExternalEdges && !selectedIdSet.has(String(edge.source))) + ) + )) : []; return { @@ -45,7 +58,7 @@ export function buildNodeClipboardPayload(nodes, edges) { runtimeValues: clonePlainObject(node.data?.runtimeValues), }, })), - edges: internalEdges.map((edge) => ({ + edges: capturedEdges.map((edge) => ({ source: String(edge.source), sourceHandle: edge.sourceHandle, target: String(edge.target), @@ -55,6 +68,13 @@ export function buildNodeClipboardPayload(nodes, edges) { }; } +export function buildNodeClipboardPayload(nodes, edges) { + const selectedIds = Array.isArray(nodes) + ? nodes.filter((node) => node?.selected).map((node) => String(node.id)) + : []; + return buildNodeClipboardPayloadForIds(nodes, edges, selectedIds); +} + export function parseNodeClipboardPayload(text) { if (typeof text !== 'string' || !text.trim()) return null; @@ -68,7 +88,13 @@ export function parseNodeClipboardPayload(text) { } } -export function instantiateNodeClipboardPayload(payload, defs = {}, nextNodeId = 1, offset = { x: 40, y: 40 }) { +export function instantiateNodeClipboardPayload( + payload, + defs = {}, + nextNodeId = 1, + offset = { x: 40, y: 40 }, + { keepExternalSources = false } = {}, +) { if (!payload || !Array.isArray(payload.nodes) || payload.nodes.length === 0) { return { nodes: [], edges: [], nextNodeId }; } @@ -109,10 +135,13 @@ export function instantiateNodeClipboardPayload(payload, defs = {}, nextNodeId = }); const edges = payload.edges - .filter((edge) => idMap.has(String(edge.source)) && idMap.has(String(edge.target))) + .filter((edge) => ( + idMap.has(String(edge.target)) + && (idMap.has(String(edge.source)) || keepExternalSources) + )) .map((edge, index) => ({ - id: `e${idMap.get(String(edge.source))}-${idMap.get(String(edge.target))}-${index}`, - source: idMap.get(String(edge.source)), + id: `e${idMap.get(String(edge.source)) || String(edge.source)}-${idMap.get(String(edge.target))}-${index}`, + source: idMap.get(String(edge.source)) || String(edge.source), sourceHandle: edge.sourceHandle, target: idMap.get(String(edge.target)), targetHandle: edge.targetHandle, diff --git a/frontend/tests/nodeClipboard.test.mjs b/frontend/tests/nodeClipboard.test.mjs index 7ea25b0..682ba4f 100644 --- a/frontend/tests/nodeClipboard.test.mjs +++ b/frontend/tests/nodeClipboard.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { buildNodeClipboardPayload, + buildNodeClipboardPayloadForIds, instantiateNodeClipboardPayload, NODE_CLIPBOARD_KIND, parseNodeClipboardPayload, @@ -147,6 +148,93 @@ test('instantiateNodeClipboardPayload remaps ids, offsets positions, and hydrate ]); }); +test('buildNodeClipboardPayloadForIds can include upstream external edges for duplicated nodes', () => { + const nodes = [ + { id: '1', position: { x: 0, y: 0 }, data: { className: 'Image' } }, + { id: '2', position: { x: 100, y: 0 }, data: { className: 'Preview' } }, + { id: '3', position: { x: 200, y: 0 }, data: { className: 'Save' } }, + ]; + + const edges = [ + { + source: '1', + sourceHandle: 'output::0::DATA_FIELD', + target: '2', + targetHandle: 'input::field::DATA_FIELD', + }, + { + source: '2', + sourceHandle: 'output::0::IMAGE', + target: '3', + targetHandle: 'input::value::SAVE_VALUE', + }, + ]; + + const payload = buildNodeClipboardPayloadForIds(nodes, edges, ['2'], { + includeIncomingExternalEdges: true, + }); + + assert.equal(payload.nodes.length, 1); + assert.deepEqual(payload.edges, [ + { + source: '1', + sourceHandle: 'output::0::DATA_FIELD', + target: '2', + targetHandle: 'input::field::DATA_FIELD', + }, + ]); +}); + +test('instantiateNodeClipboardPayload can keep external upstream sources when duplicating nodes', () => { + const payload = { + kind: NODE_CLIPBOARD_KIND, + version: 1, + nodes: [ + { + id: '2', + position: { x: 100, y: 0 }, + data: { + label: 'Preview', + className: 'Preview', + widgetValues: { colormap: 'viridis' }, + }, + }, + ], + edges: [ + { + source: '1', + sourceHandle: 'output::0::DATA_FIELD', + target: '2', + targetHandle: 'input::field::DATA_FIELD', + }, + ], + }; + + const defs = { + Preview: { output: ['IMAGE'], output_name: ['preview'] }, + }; + + const instantiated = instantiateNodeClipboardPayload( + payload, + defs, + 7, + { x: 50, y: 25 }, + { keepExternalSources: true }, + ); + + assert.deepEqual(instantiated.nodes.map((node) => node.id), ['7']); + assert.deepEqual(instantiated.edges, [ + { + id: 'e1-7-0', + source: '1', + sourceHandle: 'output::0::DATA_FIELD', + target: '7', + targetHandle: 'input::field::DATA_FIELD', + selected: false, + }, + ]); +}); + test('clipboard payload deep-copies local widget and runtime fields', () => { const nodes = [ {