diff --git a/invokeai/frontend/web/src/features/nodes/components/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/Flow.tsx index f419f5fc48..462977c477 100644 --- a/invokeai/frontend/web/src/features/nodes/components/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/Flow.tsx @@ -2,6 +2,7 @@ import { useToken } from '@chakra-ui/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { contextMenusClosed } from 'features/ui/store/uiSlice'; import { useCallback } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; import { Background, OnConnect, @@ -27,6 +28,8 @@ import { nodesDeleted, selectedEdgesChanged, selectedNodesChanged, + selectionCopied, + selectionPasted, viewportChanged, } from '../store/nodesSlice'; import { CustomConnectionLine } from './CustomConnectionLine'; @@ -121,8 +124,17 @@ export const Flow = () => { dispatch(contextMenusClosed()); }, [dispatch]); + useHotkeys(['Ctrl+c', 'Meta+c'], () => { + dispatch(selectionCopied()); + }); + + useHotkeys(['Ctrl+v', 'Meta+v'], () => { + dispatch(selectionPasted()); + }); + return ( state.nodes], @@ -34,10 +34,24 @@ export const useBuildNodeData = () => { (type: AnyInvocationType | 'current_image' | 'notes') => { const nodeId = uuidv4(); + let _x = window.innerWidth / 2; + let _y = window.innerHeight / 2; + + // attempt to center the node in the middle of the flow + const rect = document + .querySelector('#workflow-editor') + ?.getBoundingClientRect(); + + if (rect) { + _x = rect.width / 2 - NODE_WIDTH / 2; + _y = rect.height / 2 - NODE_WIDTH / 2; + } + const { x, y } = flow.project({ - x: window.innerWidth / 2.5, - y: window.innerHeight / 8, + x: _x, + y: _y, }); + if (type === 'current_image') { const node: Node = { ...SHARED_NODE_PROPERTIES, diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesPersistDenylist.ts b/invokeai/frontend/web/src/features/nodes/store/nodesPersistDenylist.ts index cf3ee3918c..fe52b63bb2 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesPersistDenylist.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesPersistDenylist.ts @@ -11,4 +11,6 @@ export const nodesPersistDenylist: (keyof NodesState)[] = [ 'selectedNodes', 'selectedEdges', 'isReady', + 'nodesToCopy', + 'edgesToCopy', ]; diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 90c7859995..23056d50a6 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -25,6 +25,7 @@ import { appSocketInvocationError, appSocketInvocationStarted, } from 'services/events/actions'; +import { v4 as uuidv4 } from 'uuid'; import { DRAG_HANDLE_CLASSNAME } from '../types/constants'; import { BooleanInputFieldValue, @@ -52,6 +53,7 @@ import { Workflow, } from '../types/types'; import { NodesState } from './types'; +import { findUnoccupiedPosition } from './util/findUnoccupiedPosition'; export const initialNodesState: NodesState = { nodes: [], @@ -83,6 +85,8 @@ export const initialNodesState: NodesState = { nodeExecutionStates: {}, viewport: { x: 0, y: 0, zoom: 1 }, mouseOverField: null, + nodesToCopy: [], + edgesToCopy: [], }; type FieldValueAction = PayloadAction<{ @@ -124,6 +128,12 @@ const nodesSlice = createSlice({ > ) => { const node = action.payload; + const position = findUnoccupiedPosition( + state.nodes, + node.position.x, + node.position.y + ); + node.position = position; state.nodes.push(node); if (!isInvocationNode(node)) { @@ -595,6 +605,85 @@ const nodesSlice = createSlice({ ) => { state.mouseOverField = action.payload; }, + selectionCopied: (state) => { + state.nodesToCopy = state.nodes.filter((n) => n.selected).map(cloneDeep); + state.edgesToCopy = state.edges.filter((e) => e.selected).map(cloneDeep); + }, + selectionPasted: (state) => { + const newNodes = state.nodesToCopy.map(cloneDeep); + const oldNodeIds = newNodes.map((n) => n.data.id); + const newEdges = state.edgesToCopy + .filter( + (e) => oldNodeIds.includes(e.source) && oldNodeIds.includes(e.target) + ) + .map(cloneDeep); + + newEdges.forEach((e) => (e.selected = true)); + + newNodes.forEach((node) => { + const newNodeId = uuidv4(); + newEdges.forEach((edge) => { + if (edge.source === node.data.id) { + edge.source = newNodeId; + edge.id = edge.id.replace(node.data.id, newNodeId); + } + if (edge.target === node.data.id) { + edge.target = newNodeId; + edge.id = edge.id.replace(node.data.id, newNodeId); + } + }); + node.selected = true; + node.id = newNodeId; + node.data.id = newNodeId; + + const position = findUnoccupiedPosition( + state.nodes, + node.position.x, + node.position.y + ); + + node.position = position; + }); + + const nodeAdditions: NodeChange[] = newNodes.map((n) => ({ + item: n, + type: 'add', + })); + const nodeSelectionChanges: NodeChange[] = state.nodes.map((n) => ({ + id: n.data.id, + type: 'select', + selected: false, + })); + + const edgeAdditions: EdgeChange[] = newEdges.map((e) => ({ + item: e, + type: 'add', + })); + const edgeSelectionChanges: EdgeChange[] = state.edges.map((e) => ({ + id: e.id, + type: 'select', + selected: false, + })); + + state.nodes = applyNodeChanges( + nodeAdditions.concat(nodeSelectionChanges), + state.nodes + ); + + state.edges = applyEdgeChanges( + edgeAdditions.concat(edgeSelectionChanges), + state.edges + ); + + newNodes.forEach((node) => { + state.nodeExecutionStates[node.id] = { + status: NodeStatus.PENDING, + error: null, + progress: null, + progressImage: null, + }; + }); + }, }, extraReducers: (builder) => { builder.addCase(receivedOpenAPISchema.pending, (state) => { @@ -703,6 +792,8 @@ export const { fieldLabelChanged, viewportChanged, mouseOverFieldChanged, + selectionCopied, + selectionPasted, } = nodesSlice.actions; export default nodesSlice.reducer; diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index 160336cef5..1a26f959fd 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -31,4 +31,6 @@ export type NodesState = { viewport: Viewport; isReady: boolean; mouseOverField: FieldIdentifier | null; + nodesToCopy: Node[]; + edgesToCopy: Edge[]; }; diff --git a/invokeai/frontend/web/src/features/nodes/store/util/findUnoccupiedPosition.ts b/invokeai/frontend/web/src/features/nodes/store/util/findUnoccupiedPosition.ts new file mode 100644 index 0000000000..57f36f4d5e --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/store/util/findUnoccupiedPosition.ts @@ -0,0 +1,11 @@ +import { Node } from 'reactflow'; + +export const findUnoccupiedPosition = (nodes: Node[], x: number, y: number) => { + let newX = x; + let newY = y; + while (nodes.find((n) => n.position.x === newX && n.position.y === newY)) { + newX = newX + 50; + newY = newY + 50; + } + return { x: newX, y: newY }; +};