feat(ui): node delete, copy, paste

This commit is contained in:
psychedelicious 2023-08-18 18:14:21 +10:00
parent 567d46b646
commit 519bcb38c1
6 changed files with 135 additions and 3 deletions

View File

@ -2,6 +2,7 @@ import { useToken } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { contextMenusClosed } from 'features/ui/store/uiSlice'; import { contextMenusClosed } from 'features/ui/store/uiSlice';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { import {
Background, Background,
OnConnect, OnConnect,
@ -27,6 +28,8 @@ import {
nodesDeleted, nodesDeleted,
selectedEdgesChanged, selectedEdgesChanged,
selectedNodesChanged, selectedNodesChanged,
selectionCopied,
selectionPasted,
viewportChanged, viewportChanged,
} from '../store/nodesSlice'; } from '../store/nodesSlice';
import { CustomConnectionLine } from './CustomConnectionLine'; import { CustomConnectionLine } from './CustomConnectionLine';
@ -121,8 +124,17 @@ export const Flow = () => {
dispatch(contextMenusClosed()); dispatch(contextMenusClosed());
}, [dispatch]); }, [dispatch]);
useHotkeys(['Ctrl+c', 'Meta+c'], () => {
dispatch(selectionCopied());
});
useHotkeys(['Ctrl+v', 'Meta+v'], () => {
dispatch(selectionPasted());
});
return ( return (
<ReactFlow <ReactFlow
id="workflow-editor"
defaultViewport={viewport} defaultViewport={viewport}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}

View File

@ -14,7 +14,7 @@ import {
OutputFieldValue, OutputFieldValue,
} from '../types/types'; } from '../types/types';
import { buildInputFieldValue } from '../util/fieldValueBuilders'; import { buildInputFieldValue } from '../util/fieldValueBuilders';
import { DRAG_HANDLE_CLASSNAME } from '../types/constants'; import { DRAG_HANDLE_CLASSNAME, NODE_WIDTH } from '../types/constants';
const templatesSelector = createSelector( const templatesSelector = createSelector(
[(state: RootState) => state.nodes], [(state: RootState) => state.nodes],
@ -34,10 +34,24 @@ export const useBuildNodeData = () => {
(type: AnyInvocationType | 'current_image' | 'notes') => { (type: AnyInvocationType | 'current_image' | 'notes') => {
const nodeId = uuidv4(); 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({ const { x, y } = flow.project({
x: window.innerWidth / 2.5, x: _x,
y: window.innerHeight / 8, y: _y,
}); });
if (type === 'current_image') { if (type === 'current_image') {
const node: Node<CurrentImageNodeData> = { const node: Node<CurrentImageNodeData> = {
...SHARED_NODE_PROPERTIES, ...SHARED_NODE_PROPERTIES,

View File

@ -11,4 +11,6 @@ export const nodesPersistDenylist: (keyof NodesState)[] = [
'selectedNodes', 'selectedNodes',
'selectedEdges', 'selectedEdges',
'isReady', 'isReady',
'nodesToCopy',
'edgesToCopy',
]; ];

View File

@ -25,6 +25,7 @@ import {
appSocketInvocationError, appSocketInvocationError,
appSocketInvocationStarted, appSocketInvocationStarted,
} from 'services/events/actions'; } from 'services/events/actions';
import { v4 as uuidv4 } from 'uuid';
import { DRAG_HANDLE_CLASSNAME } from '../types/constants'; import { DRAG_HANDLE_CLASSNAME } from '../types/constants';
import { import {
BooleanInputFieldValue, BooleanInputFieldValue,
@ -52,6 +53,7 @@ import {
Workflow, Workflow,
} from '../types/types'; } from '../types/types';
import { NodesState } from './types'; import { NodesState } from './types';
import { findUnoccupiedPosition } from './util/findUnoccupiedPosition';
export const initialNodesState: NodesState = { export const initialNodesState: NodesState = {
nodes: [], nodes: [],
@ -83,6 +85,8 @@ export const initialNodesState: NodesState = {
nodeExecutionStates: {}, nodeExecutionStates: {},
viewport: { x: 0, y: 0, zoom: 1 }, viewport: { x: 0, y: 0, zoom: 1 },
mouseOverField: null, mouseOverField: null,
nodesToCopy: [],
edgesToCopy: [],
}; };
type FieldValueAction<T extends InputFieldValue> = PayloadAction<{ type FieldValueAction<T extends InputFieldValue> = PayloadAction<{
@ -124,6 +128,12 @@ const nodesSlice = createSlice({
> >
) => { ) => {
const node = action.payload; const node = action.payload;
const position = findUnoccupiedPosition(
state.nodes,
node.position.x,
node.position.y
);
node.position = position;
state.nodes.push(node); state.nodes.push(node);
if (!isInvocationNode(node)) { if (!isInvocationNode(node)) {
@ -595,6 +605,85 @@ const nodesSlice = createSlice({
) => { ) => {
state.mouseOverField = action.payload; 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) => { extraReducers: (builder) => {
builder.addCase(receivedOpenAPISchema.pending, (state) => { builder.addCase(receivedOpenAPISchema.pending, (state) => {
@ -703,6 +792,8 @@ export const {
fieldLabelChanged, fieldLabelChanged,
viewportChanged, viewportChanged,
mouseOverFieldChanged, mouseOverFieldChanged,
selectionCopied,
selectionPasted,
} = nodesSlice.actions; } = nodesSlice.actions;
export default nodesSlice.reducer; export default nodesSlice.reducer;

View File

@ -31,4 +31,6 @@ export type NodesState = {
viewport: Viewport; viewport: Viewport;
isReady: boolean; isReady: boolean;
mouseOverField: FieldIdentifier | null; mouseOverField: FieldIdentifier | null;
nodesToCopy: Node<NodeData>[];
edgesToCopy: Edge<InvocationEdgeExtra>[];
}; };

View File

@ -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 };
};