diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx index 7176ba3574..80bdf6577b 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -1,9 +1,11 @@ import { useGlobalMenuClose, useToken } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useCopyPaste } from 'features/nodes/hooks/useCopyPaste'; import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection'; import { $mouseOverNode } from 'features/nodes/hooks/useMouseOverNode'; import { useWorkflowWatcher } from 'features/nodes/hooks/useWorkflowWatcher'; import { + $cursorPos, connectionEnded, connectionMade, connectionStarted, @@ -18,8 +20,6 @@ import { selectedAll, selectedEdgesChanged, selectedNodesChanged, - selectionCopied, - selectionPasted, undo, viewportChanged, } from 'features/nodes/store/nodesSlice'; @@ -41,7 +41,6 @@ import type { OnSelectionChangeFunc, ProOptions, ReactFlowProps, - XYPosition, } from 'reactflow'; import { Background, ReactFlow } from 'reactflow'; @@ -78,7 +77,6 @@ export const Flow = memo(() => { const shouldSnapToGrid = useAppSelector((s) => s.workflowSettings.shouldSnapToGrid); const selectionMode = useAppSelector((s) => s.workflowSettings.selectionMode); const flowWrapper = useRef(null); - const cursorPosition = useRef(null); const isValidConnection = useIsValidConnection(); useWorkflowWatcher(); const [borderRadius] = useToken('radii', ['base']); @@ -119,12 +117,13 @@ export const Flow = memo(() => { ); const onConnectEnd: OnConnectEnd = useCallback(() => { - if (!cursorPosition.current) { + const cursorPosition = $cursorPos.get(); + if (!cursorPosition) { return; } dispatch( connectionEnded({ - cursorPosition: cursorPosition.current, + cursorPosition, mouseOverNodeId: $mouseOverNode.get(), }) ); @@ -171,11 +170,12 @@ export const Flow = memo(() => { const onMouseMove = useCallback((event: MouseEvent) => { if (flowWrapper.current?.getBoundingClientRect()) { - cursorPosition.current = + $cursorPos.set( $flow.get()?.screenToFlowPosition({ x: event.clientX, y: event.clientY, - }) ?? null; + }) ?? null + ); } }, []); @@ -235,9 +235,11 @@ export const Flow = memo(() => { // #endregion + const { copySelection, pasteSelection } = useCopyPaste(); + useHotkeys(['Ctrl+c', 'Meta+c'], (e) => { e.preventDefault(); - dispatch(selectionCopied()); + copySelection(); }); useHotkeys(['Ctrl+a', 'Meta+a'], (e) => { @@ -246,11 +248,8 @@ export const Flow = memo(() => { }); useHotkeys(['Ctrl+v', 'Meta+v'], (e) => { - if (!cursorPosition.current) { - return; - } e.preventDefault(); - dispatch(selectionPasted({ cursorPosition: cursorPosition.current })); + pasteSelection(); }); useHotkeys( diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useCopyPaste.ts b/invokeai/frontend/web/src/features/nodes/hooks/useCopyPaste.ts new file mode 100644 index 0000000000..727c0932f7 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useCopyPaste.ts @@ -0,0 +1,63 @@ +import { getStore } from 'app/store/nanostores/store'; +import { deepClone } from 'common/util/deepClone'; +import { $copiedEdges,$copiedNodes,$cursorPos, selectionPasted, selectNodesSlice } from 'features/nodes/store/nodesSlice'; +import { findUnoccupiedPosition } from 'features/nodes/store/util/findUnoccupiedPosition'; +import { v4 as uuidv4 } from 'uuid'; + +const copySelection = () => { + // Use the imperative API here so we don't have to pass the whole slice around + const { getState } = getStore(); + const { nodes, edges } = selectNodesSlice(getState()); + const selectedNodes = nodes.filter((node) => node.selected); + const selectedEdges = edges.filter((edge) => edge.selected); + $copiedNodes.set(selectedNodes); + $copiedEdges.set(selectedEdges); +}; + +const pasteSelection = () => { + const { getState, dispatch } = getStore(); + const currentNodes = selectNodesSlice(getState()).nodes; + const cursorPos = $cursorPos.get(); + + const copiedNodes = deepClone($copiedNodes.get()); + const copiedEdges = deepClone($copiedEdges.get()); + + // Calculate an offset to reposition nodes to surround the cursor position, maintaining relative positioning + const xCoords = copiedNodes.map((node) => node.position.x); + const yCoords = copiedNodes.map((node) => node.position.y); + const minX = Math.min(...xCoords); + const minY = Math.min(...yCoords); + const offsetX = cursorPos ? cursorPos.x - minX : 50; + const offsetY = cursorPos ? cursorPos.y - minY : 50; + + copiedNodes.forEach((node) => { + const { x, y } = findUnoccupiedPosition(currentNodes, node.position.x + offsetX, node.position.y + offsetY); + node.position.x = x; + node.position.y = y; + // Pasted nodes are selected + node.selected = true; + // Also give em a fresh id + const id = uuidv4(); + // Update the edges to point to the new node id + for (const edge of copiedEdges) { + if (edge.source === node.id) { + edge.source = id; + edge.id = edge.id.replace(node.data.id, id); + } + if (edge.target === node.id) { + edge.target = id; + edge.id = edge.id.replace(node.data.id, id); + } + } + node.id = id; + node.data.id = id; + }); + + dispatch(selectionPasted({ nodes: copiedNodes, edges: copiedEdges })); +}; + +const api = { copySelection, pasteSelection }; + +export const useCopyPaste = () => { + return api; +}; diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 73cd664dd0..21092bb7df 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -43,9 +43,15 @@ import { zT2IAdapterModelFieldValue, zVAEModelFieldValue, } from 'features/nodes/types/field'; -import type { AnyNode, InvocationTemplate, NodeExecutionState } from 'features/nodes/types/invocation'; +import type { + AnyNode, + InvocationNodeEdge, + InvocationTemplate, + NodeExecutionState, +} from 'features/nodes/types/invocation'; import { isInvocationNode, isNotesNode, zNodeStatus } from 'features/nodes/types/invocation'; import { forEach } from 'lodash-es'; +import { atom } from 'nanostores'; import type { Connection, Edge, @@ -66,7 +72,6 @@ import { socketInvocationStarted, socketQueueItemStatusChanged, } from 'services/events/actions'; -import { v4 as uuidv4 } from 'uuid'; import type { z } from 'zod'; import type { NodesState } from './types'; @@ -96,8 +101,6 @@ const initialNodesState: NodesState = { selectedEdges: [], nodeExecutionStates: {}, viewport: { x: 0, y: 0, zoom: 1 }, - nodesToCopy: [], - edgesToCopy: [], }; type FieldValueAction = PayloadAction<{ @@ -539,116 +542,52 @@ export const nodesSlice = createSlice({ state.edges ); }, - selectionCopied: (state) => { - const nodesToCopy: AnyNode[] = []; - const edgesToCopy: Edge[] = []; + selectionPasted: (state, action: PayloadAction<{ nodes: AnyNode[]; edges: InvocationNodeEdge[] }>) => { + const { nodes, edges } = action.payload; - for (const node of state.nodes) { - if (node.selected) { - nodesToCopy.push(deepClone(node)); - } - } + const nodeChanges: NodeChange[] = []; - for (const edge of state.edges) { - if (edge.selected) { - edgesToCopy.push(deepClone(edge)); - } - } - - state.nodesToCopy = nodesToCopy; - state.edgesToCopy = edgesToCopy; - - if (state.nodesToCopy.length > 0) { - const averagePosition = { x: 0, y: 0 }; - state.nodesToCopy.forEach((e) => { - const xOffset = 0.15 * (e.width ?? 0); - const yOffset = 0.5 * (e.height ?? 0); - averagePosition.x += e.position.x + xOffset; - averagePosition.y += e.position.y + yOffset; + // Deselect existing nodes + state.nodes.forEach((n) => { + nodeChanges.push({ + id: n.data.id, + type: 'select', + selected: false, }); - - averagePosition.x /= state.nodesToCopy.length; - averagePosition.y /= state.nodesToCopy.length; - - state.nodesToCopy.forEach((e) => { - e.position.x -= averagePosition.x; - e.position.y -= averagePosition.y; + }); + // Add new nodes + nodes.forEach((n) => { + nodeChanges.push({ + item: n, + type: 'add', }); - } - }, - selectionPasted: (state, action: PayloadAction<{ cursorPosition?: XYPosition }>) => { - const { cursorPosition } = action.payload; - const newNodes: AnyNode[] = []; - - for (const node of state.nodesToCopy) { - newNodes.push(deepClone(node)); - } - - const oldNodeIds = newNodes.map((n) => n.data.id); - - const newEdges: Edge[] = []; - - for (const edge of state.edgesToCopy) { - if (oldNodeIds.includes(edge.source) && oldNodeIds.includes(edge.target)) { - newEdges.push(deepClone(edge)); - } - } - - 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 + (cursorPosition?.x ?? 0), - node.position.y + (cursorPosition?.y ?? 0) - ); - - 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 edgeChanges: EdgeChange[] = []; + // Deselect existing edges + state.edges.forEach((e) => { + edgeChanges.push({ + id: e.id, + type: 'select', + selected: false, + }); + }); + // Add new edges + edges.forEach((e) => { + edgeChanges.push({ + item: e, + type: 'add', + }); + }); - 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(nodeChanges, state.nodes); + state.edges = applyEdgeChanges(edgeChanges, state.edges); - state.nodes = applyNodeChanges(nodeAdditions.concat(nodeSelectionChanges), state.nodes); - - state.edges = applyEdgeChanges(edgeAdditions.concat(edgeSelectionChanges), state.edges); - - newNodes.forEach((node) => { + // Add node execution states for new nodes + nodes.forEach((node) => { state.nodeExecutionStates[node.id] = { nodeId: node.id, - ...initialNodeExecutionState, + ...deepClone(initialNodeExecutionState), }; }); }, @@ -786,7 +725,6 @@ export const { selectedAll, selectedEdgesChanged, selectedNodesChanged, - selectionCopied, selectionPasted, viewportChanged, edgeAdded, @@ -831,6 +769,10 @@ export const isAnyNodeOrEdgeMutation = isAnyOf( edgeAdded ); +export const $cursorPos = atom(null); +export const $copiedNodes = atom([]); +export const $copiedEdges = atom([]); + export const selectNodesSlice = (state: RootState) => state.nodes.present; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -850,8 +792,6 @@ export const nodesPersistConfig: PersistConfig = { 'connectionStartFieldType', 'selectedNodes', 'selectedEdges', - 'nodesToCopy', - 'edgesToCopy', 'connectionMade', 'modifyingEdge', 'addNewNodePosition', diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index fdf3e638e3..f9c859fcc5 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -21,8 +21,6 @@ export type NodesState = { selectedEdges: string[]; nodeExecutionStates: Record; viewport: Viewport; - nodesToCopy: AnyNode[]; - edgesToCopy: InvocationNodeEdge[]; isAddNodePopoverOpen: boolean; addNewNodePosition: XYPosition | null; }; diff --git a/invokeai/frontend/web/src/features/nodes/store/util/findUnoccupiedPosition.ts b/invokeai/frontend/web/src/features/nodes/store/util/findUnoccupiedPosition.ts index 114633e875..bd110a50a1 100644 --- a/invokeai/frontend/web/src/features/nodes/store/util/findUnoccupiedPosition.ts +++ b/invokeai/frontend/web/src/features/nodes/store/util/findUnoccupiedPosition.ts @@ -4,8 +4,8 @@ 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; + newX = Math.floor(newX + 50); + newY = Math.floor(newY + 50); } return { x: newX, y: newY }; };