Skip to content

Commit f87b042

Browse files
feat(nodes): Center pasted nodes at mouse location (#4595)
* Initial commit. Feature works, but code might need some cleanup * Cleaned up diff * Made mousePosition a XYPosition again so its nicely typed * Fixed yarn issues * Paste now properly takes node width/height into account when pasting * feat(ui): use react's types in the `onMouseMove` `reactflow` handler * feat(ui): use refs to access `reactflow`'s DOM elements * feat(ui): use a ref to store cursor position in nodes --------- Co-authored-by: psychedelicious <[email protected]>
1 parent 183e2c3 commit f87b042

File tree

2 files changed

+44
-6
lines changed

2 files changed

+44
-6
lines changed

invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
55
import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
66
import { $flow } from 'features/nodes/store/reactFlowInstance';
77
import { contextMenusClosed } from 'features/ui/store/uiSlice';
8-
import { useCallback } from 'react';
8+
import { MouseEvent, useCallback, useRef } from 'react';
99
import { useHotkeys } from 'react-hotkeys-hook';
1010
import {
1111
Background,
@@ -21,6 +21,7 @@ import {
2121
OnSelectionChangeFunc,
2222
ProOptions,
2323
ReactFlow,
24+
XYPosition,
2425
} from 'reactflow';
2526
import { useIsValidConnection } from '../../hooks/useIsValidConnection';
2627
import {
@@ -79,7 +80,8 @@ export const Flow = () => {
7980
const edges = useAppSelector((state) => state.nodes.edges);
8081
const viewport = useAppSelector((state) => state.nodes.viewport);
8182
const { shouldSnapToGrid, selectionMode } = useAppSelector(selector);
82-
83+
const flowWrapper = useRef<HTMLDivElement>(null);
84+
const cursorPosition = useRef<XYPosition>();
8385
const isValidConnection = useIsValidConnection();
8486

8587
const [borderRadius] = useToken('radii', ['base']);
@@ -154,6 +156,17 @@ export const Flow = () => {
154156
flow.fitView();
155157
}, []);
156158

159+
const onMouseMove = useCallback((event: MouseEvent<HTMLDivElement>) => {
160+
const bounds = flowWrapper.current?.getBoundingClientRect();
161+
if (bounds) {
162+
const pos = $flow.get()?.project({
163+
x: event.clientX - bounds.left,
164+
y: event.clientY - bounds.top,
165+
});
166+
cursorPosition.current = pos;
167+
}
168+
}, []);
169+
157170
useHotkeys(['Ctrl+c', 'Meta+c'], (e) => {
158171
e.preventDefault();
159172
dispatch(selectionCopied());
@@ -166,18 +179,20 @@ export const Flow = () => {
166179

167180
useHotkeys(['Ctrl+v', 'Meta+v'], (e) => {
168181
e.preventDefault();
169-
dispatch(selectionPasted());
182+
dispatch(selectionPasted({ cursorPosition: cursorPosition.current }));
170183
});
171184

172185
return (
173186
<ReactFlow
174187
id="workflow-editor"
188+
ref={flowWrapper}
175189
defaultViewport={viewport}
176190
nodeTypes={nodeTypes}
177191
edgeTypes={edgeTypes}
178192
nodes={nodes}
179193
edges={edges}
180194
onInit={onInit}
195+
onMouseMove={onMouseMove}
181196
onNodesChange={onNodesChange}
182197
onEdgesChange={onEdgesChange}
183198
onEdgesDelete={onEdgesDelete}

invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
OnConnectStartParams,
1717
SelectionMode,
1818
Viewport,
19+
XYPosition,
1920
} from 'reactflow';
2021
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
2122
import { sessionCanceled, sessionInvoked } from 'services/api/thunks/session';
@@ -715,8 +716,30 @@ const nodesSlice = createSlice({
715716
selectionCopied: (state) => {
716717
state.nodesToCopy = state.nodes.filter((n) => n.selected).map(cloneDeep);
717718
state.edgesToCopy = state.edges.filter((e) => e.selected).map(cloneDeep);
719+
720+
if (state.nodesToCopy.length > 0) {
721+
const averagePosition = { x: 0, y: 0 };
722+
state.nodesToCopy.forEach((e) => {
723+
const xOffset = 0.15 * (e.width ?? 0);
724+
const yOffset = 0.5 * (e.height ?? 0);
725+
averagePosition.x += e.position.x + xOffset;
726+
averagePosition.y += e.position.y + yOffset;
727+
});
728+
729+
averagePosition.x /= state.nodesToCopy.length;
730+
averagePosition.y /= state.nodesToCopy.length;
731+
732+
state.nodesToCopy.forEach((e) => {
733+
e.position.x -= averagePosition.x;
734+
e.position.y -= averagePosition.y;
735+
});
736+
}
718737
},
719-
selectionPasted: (state) => {
738+
selectionPasted: (
739+
state,
740+
action: PayloadAction<{ cursorPosition?: XYPosition }>
741+
) => {
742+
const { cursorPosition } = action.payload;
720743
const newNodes = state.nodesToCopy.map(cloneDeep);
721744
const oldNodeIds = newNodes.map((n) => n.data.id);
722745
const newEdges = state.edgesToCopy
@@ -745,8 +768,8 @@ const nodesSlice = createSlice({
745768

746769
const position = findUnoccupiedPosition(
747770
state.nodes,
748-
node.position.x,
749-
node.position.y
771+
node.position.x + (cursorPosition?.x ?? 0),
772+
node.position.y + (cursorPosition?.y ?? 0)
750773
);
751774

752775
node.position = position;

0 commit comments

Comments
 (0)