mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
feat(ui): move nodes copy/paste out of slice
This commit is contained in:
parent
9c0d44b412
commit
d4df312300
@ -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<HTMLDivElement>(null);
|
||||
const cursorPosition = useRef<XYPosition | null>(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<HTMLDivElement>) => {
|
||||
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(
|
||||
|
@ -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;
|
||||
};
|
@ -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<T extends FieldValue> = 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<XYPosition | null>(null);
|
||||
export const $copiedNodes = atom<AnyNode[]>([]);
|
||||
export const $copiedEdges = atom<InvocationNodeEdge[]>([]);
|
||||
|
||||
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<NodesState> = {
|
||||
'connectionStartFieldType',
|
||||
'selectedNodes',
|
||||
'selectedEdges',
|
||||
'nodesToCopy',
|
||||
'edgesToCopy',
|
||||
'connectionMade',
|
||||
'modifyingEdge',
|
||||
'addNewNodePosition',
|
||||
|
@ -21,8 +21,6 @@ export type NodesState = {
|
||||
selectedEdges: string[];
|
||||
nodeExecutionStates: Record<string, NodeExecutionState>;
|
||||
viewport: Viewport;
|
||||
nodesToCopy: AnyNode[];
|
||||
edgesToCopy: InvocationNodeEdge[];
|
||||
isAddNodePopoverOpen: boolean;
|
||||
addNewNodePosition: XYPosition | null;
|
||||
};
|
||||
|
@ -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 };
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user