multi selection, copy paste and drag clone

This commit is contained in:
2026-03-26 21:31:37 -07:00
parent 30671a5362
commit d0a1bc6241
3 changed files with 252 additions and 51 deletions

View File

@@ -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' }}
>

View File

@@ -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,

View File

@@ -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 = [
{