multi selection, copy paste and drag clone
This commit is contained in:
@@ -18,6 +18,7 @@ import { hydrateWorkflowState } from './workflowHydration';
|
|||||||
import { serializeWorkflowState } from './workflowSerialization';
|
import { serializeWorkflowState } from './workflowSerialization';
|
||||||
import {
|
import {
|
||||||
buildNodeClipboardPayload,
|
buildNodeClipboardPayload,
|
||||||
|
buildNodeClipboardPayloadForIds,
|
||||||
instantiateNodeClipboardPayload,
|
instantiateNodeClipboardPayload,
|
||||||
NODE_CLIPBOARD_MIME,
|
NODE_CLIPBOARD_MIME,
|
||||||
parseNodeClipboardPayload,
|
parseNodeClipboardPayload,
|
||||||
@@ -472,6 +473,7 @@ function Flow() {
|
|||||||
const defaultWorkflowLoadAttemptedRef = useRef(false);
|
const defaultWorkflowLoadAttemptedRef = useRef(false);
|
||||||
const lastPastedClipboardTextRef = useRef('');
|
const lastPastedClipboardTextRef = useRef('');
|
||||||
const pasteRepeatCountRef = useRef(0);
|
const pasteRepeatCountRef = useRef(0);
|
||||||
|
const duplicateDragRef = useRef(null);
|
||||||
const reactFlow = useReactFlow();
|
const reactFlow = useReactFlow();
|
||||||
|
|
||||||
// ── WebSocket ───────────────────────────────────────────────────────
|
// ── WebSocket ───────────────────────────────────────────────────────
|
||||||
@@ -980,6 +982,29 @@ function Flow() {
|
|||||||
}
|
}
|
||||||
}, [setNodes, scheduleAutoRun]);
|
}, [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 pasteClipboardSelection = useCallback((clipboardText) => {
|
||||||
const payload = parseNodeClipboardPayload(clipboardText);
|
const payload = parseNodeClipboardPayload(clipboardText);
|
||||||
if (!payload) return false;
|
if (!payload) return false;
|
||||||
@@ -1012,26 +1037,7 @@ function Flow() {
|
|||||||
...pasted.edges,
|
...pasted.edges,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setTimeout(() => {
|
initializeDynamicNodes(pasted.nodes);
|
||||||
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({
|
setStatus({
|
||||||
text: `Pasted ${pasted.nodes.length} node${pasted.nodes.length === 1 ? '' : 's'}.`,
|
text: `Pasted ${pasted.nodes.length} node${pasted.nodes.length === 1 ? '' : 's'}.`,
|
||||||
@@ -1040,10 +1046,8 @@ function Flow() {
|
|||||||
scheduleAutoRun();
|
scheduleAutoRun();
|
||||||
return true;
|
return true;
|
||||||
}, [
|
}, [
|
||||||
|
initializeDynamicNodes,
|
||||||
reactFlow,
|
reactFlow,
|
||||||
refreshAnnotationNodeOutputs,
|
|
||||||
refreshFolderNodeOutputs,
|
|
||||||
refreshLoadNodeOutputs,
|
|
||||||
scheduleAutoRun,
|
scheduleAutoRun,
|
||||||
setEdges,
|
setEdges,
|
||||||
setNodes,
|
setNodes,
|
||||||
@@ -1068,24 +1072,8 @@ function Flow() {
|
|||||||
setNodes(hydrated.nodes);
|
setNodes(hydrated.nodes);
|
||||||
setEdges(hydrated.edges);
|
setEdges(hydrated.edges);
|
||||||
nextIdRef.current = hydrated.nextNodeId;
|
nextIdRef.current = hydrated.nextNodeId;
|
||||||
setTimeout(() => {
|
initializeDynamicNodes(hydrated.nodes);
|
||||||
hydrated.nodes.forEach((node) => {
|
}, [initializeDynamicNodes, setNodes, setEdges]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const loadDefaultWorkflow = useCallback(async () => {
|
const loadDefaultWorkflow = useCallback(async () => {
|
||||||
if (defaultWorkflowLoadAttemptedRef.current) return;
|
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 ───────────────────────────────────────────────
|
// ── Keyboard shortcut ───────────────────────────────────────────────
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1420,12 +1501,15 @@ function Flow() {
|
|||||||
edges={edges}
|
edges={edges}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={handleEdgesChange}
|
onEdgesChange={handleEdgesChange}
|
||||||
|
onNodeDragStart={onNodeDragStart}
|
||||||
|
onNodeDragStop={onNodeDragStop}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onConnectEnd={onConnectEnd}
|
onConnectEnd={onConnectEnd}
|
||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
nodeTypes={NODE_TYPES}
|
nodeTypes={NODE_TYPES}
|
||||||
onPaneContextMenu={onPaneContextMenu}
|
onPaneContextMenu={onPaneContextMenu}
|
||||||
colorMode="dark"
|
colorMode="dark"
|
||||||
|
multiSelectionKeyCode={['Shift']}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
defaultEdgeOptions={{ type: 'default' }}
|
defaultEdgeOptions={{ type: 'default' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,13 +18,26 @@ function clonePlainObject(value) {
|
|||||||
return cloneValue(value) || {};
|
return cloneValue(value) || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildNodeClipboardPayload(nodes, edges) {
|
export function buildNodeClipboardPayloadForIds(
|
||||||
const selectedNodes = Array.isArray(nodes) ? nodes.filter((node) => node?.selected) : [];
|
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;
|
if (selectedNodes.length === 0) return null;
|
||||||
|
|
||||||
const selectedIds = new Set(selectedNodes.map((node) => String(node.id)));
|
const capturedEdges = Array.isArray(edges)
|
||||||
const internalEdges = Array.isArray(edges)
|
? edges.filter((edge) => (
|
||||||
? edges.filter((edge) => selectedIds.has(String(edge.source)) && selectedIds.has(String(edge.target)))
|
selectedIdSet.has(String(edge.target))
|
||||||
|
&& (
|
||||||
|
selectedIdSet.has(String(edge.source))
|
||||||
|
|| (includeIncomingExternalEdges && !selectedIdSet.has(String(edge.source)))
|
||||||
|
)
|
||||||
|
))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -45,7 +58,7 @@ export function buildNodeClipboardPayload(nodes, edges) {
|
|||||||
runtimeValues: clonePlainObject(node.data?.runtimeValues),
|
runtimeValues: clonePlainObject(node.data?.runtimeValues),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
edges: internalEdges.map((edge) => ({
|
edges: capturedEdges.map((edge) => ({
|
||||||
source: String(edge.source),
|
source: String(edge.source),
|
||||||
sourceHandle: edge.sourceHandle,
|
sourceHandle: edge.sourceHandle,
|
||||||
target: String(edge.target),
|
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) {
|
export function parseNodeClipboardPayload(text) {
|
||||||
if (typeof text !== 'string' || !text.trim()) return null;
|
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) {
|
if (!payload || !Array.isArray(payload.nodes) || payload.nodes.length === 0) {
|
||||||
return { nodes: [], edges: [], nextNodeId };
|
return { nodes: [], edges: [], nextNodeId };
|
||||||
}
|
}
|
||||||
@@ -109,10 +135,13 @@ export function instantiateNodeClipboardPayload(payload, defs = {}, nextNodeId =
|
|||||||
});
|
});
|
||||||
|
|
||||||
const edges = payload.edges
|
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) => ({
|
.map((edge, index) => ({
|
||||||
id: `e${idMap.get(String(edge.source))}-${idMap.get(String(edge.target))}-${index}`,
|
id: `e${idMap.get(String(edge.source)) || String(edge.source)}-${idMap.get(String(edge.target))}-${index}`,
|
||||||
source: idMap.get(String(edge.source)),
|
source: idMap.get(String(edge.source)) || String(edge.source),
|
||||||
sourceHandle: edge.sourceHandle,
|
sourceHandle: edge.sourceHandle,
|
||||||
target: idMap.get(String(edge.target)),
|
target: idMap.get(String(edge.target)),
|
||||||
targetHandle: edge.targetHandle,
|
targetHandle: edge.targetHandle,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildNodeClipboardPayload,
|
buildNodeClipboardPayload,
|
||||||
|
buildNodeClipboardPayloadForIds,
|
||||||
instantiateNodeClipboardPayload,
|
instantiateNodeClipboardPayload,
|
||||||
NODE_CLIPBOARD_KIND,
|
NODE_CLIPBOARD_KIND,
|
||||||
parseNodeClipboardPayload,
|
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', () => {
|
test('clipboard payload deep-copies local widget and runtime fields', () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user