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 16af1fe12c..57e5825fb9 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { $flow } from 'features/nodes/store/reactFlowInstance'; import { contextMenusClosed } from 'features/ui/store/uiSlice'; -import { useCallback } from 'react'; +import { MouseEvent, useCallback, useRef } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { Background, @@ -21,6 +21,7 @@ import { OnSelectionChangeFunc, ProOptions, ReactFlow, + XYPosition, } from 'reactflow'; import { useIsValidConnection } from '../../hooks/useIsValidConnection'; import { @@ -79,7 +80,8 @@ export const Flow = () => { const edges = useAppSelector((state) => state.nodes.edges); const viewport = useAppSelector((state) => state.nodes.viewport); const { shouldSnapToGrid, selectionMode } = useAppSelector(selector); - + const flowWrapper = useRef(null); + const cursorPosition = useRef(); const isValidConnection = useIsValidConnection(); const [borderRadius] = useToken('radii', ['base']); @@ -154,6 +156,17 @@ export const Flow = () => { flow.fitView(); }, []); + const onMouseMove = useCallback((event: MouseEvent) => { + const bounds = flowWrapper.current?.getBoundingClientRect(); + if (bounds) { + const pos = $flow.get()?.project({ + x: event.clientX - bounds.left, + y: event.clientY - bounds.top, + }); + cursorPosition.current = pos; + } + }, []); + useHotkeys(['Ctrl+c', 'Meta+c'], (e) => { e.preventDefault(); dispatch(selectionCopied()); @@ -166,18 +179,20 @@ export const Flow = () => { useHotkeys(['Ctrl+v', 'Meta+v'], (e) => { e.preventDefault(); - dispatch(selectionPasted()); + dispatch(selectionPasted({ cursorPosition: cursorPosition.current })); }); return ( { state.nodesToCopy = state.nodes.filter((n) => n.selected).map(cloneDeep); state.edgesToCopy = state.edges.filter((e) => e.selected).map(cloneDeep); + + 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; + }); + + averagePosition.x /= state.nodesToCopy.length; + averagePosition.y /= state.nodesToCopy.length; + + state.nodesToCopy.forEach((e) => { + e.position.x -= averagePosition.x; + e.position.y -= averagePosition.y; + }); + } }, - selectionPasted: (state) => { + selectionPasted: ( + state, + action: PayloadAction<{ cursorPosition?: XYPosition }> + ) => { + const { cursorPosition } = action.payload; const newNodes = state.nodesToCopy.map(cloneDeep); const oldNodeIds = newNodes.map((n) => n.data.id); const newEdges = state.edgesToCopy @@ -745,8 +768,8 @@ const nodesSlice = createSlice({ const position = findUnoccupiedPosition( state.nodes, - node.position.x, - node.position.y + node.position.x + (cursorPosition?.x ?? 0), + node.position.y + (cursorPosition?.y ?? 0) ); node.position = position;