feat(ui): updatable edges in workflow editor (#4701)

- Drag the end of an edge away from its handle to disconnect it
- Drop in empty space to delete the edge
- Drop on valid handle to reconnect it
- Update connection logic slightly to allow edge updates
This commit is contained in:
psychedelicious 2023-09-27 01:54:35 +10:00 committed by GitHub
parent a4cdaa245e
commit a953944894
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 158 additions and 55 deletions

View File

@ -717,6 +717,7 @@
"cannotConnectInputToInput": "Cannot connect input to input", "cannotConnectInputToInput": "Cannot connect input to input",
"cannotConnectOutputToOutput": "Cannot connect output to output", "cannotConnectOutputToOutput": "Cannot connect output to output",
"cannotConnectToSelf": "Cannot connect to self", "cannotConnectToSelf": "Cannot connect to self",
"cannotDuplicateConnection": "Cannot create duplicate connections",
"clipField": "Clip", "clipField": "Clip",
"clipFieldDescription": "Tokenizer and text_encoder submodels.", "clipFieldDescription": "Tokenizer and text_encoder submodels.",
"collection": "Collection", "collection": "Collection",

View File

@ -12,6 +12,7 @@ import {
OnConnect, OnConnect,
OnConnectEnd, OnConnectEnd,
OnConnectStart, OnConnectStart,
OnEdgeUpdateFunc,
OnEdgesChange, OnEdgesChange,
OnEdgesDelete, OnEdgesDelete,
OnInit, OnInit,
@ -21,6 +22,7 @@ import {
OnSelectionChangeFunc, OnSelectionChangeFunc,
ProOptions, ProOptions,
ReactFlow, ReactFlow,
ReactFlowProps,
XYPosition, XYPosition,
} from 'reactflow'; } from 'reactflow';
import { useIsValidConnection } from '../../hooks/useIsValidConnection'; import { useIsValidConnection } from '../../hooks/useIsValidConnection';
@ -28,6 +30,8 @@ import {
connectionEnded, connectionEnded,
connectionMade, connectionMade,
connectionStarted, connectionStarted,
edgeAdded,
edgeDeleted,
edgesChanged, edgesChanged,
edgesDeleted, edgesDeleted,
nodesChanged, nodesChanged,
@ -167,6 +171,63 @@ export const Flow = () => {
} }
}, []); }, []);
// #region Updatable Edges
/**
* Adapted from https://reactflow.dev/docs/examples/edges/updatable-edge/
* and https://reactflow.dev/docs/examples/edges/delete-edge-on-drop/
*
* - Edges can be dragged from one handle to another.
* - If the user drags the edge away from the node and drops it, delete the edge.
* - Do not delete the edge if the cursor didn't move (resolves annoying behaviour
* where the edge is deleted if you click it accidentally).
*/
// We have a ref for cursor position, but it is the *projected* cursor position.
// Easiest to just keep track of the last mouse event for this particular feature
const edgeUpdateMouseEvent = useRef<MouseEvent>();
const onEdgeUpdateStart: NonNullable<ReactFlowProps['onEdgeUpdateStart']> =
useCallback(
(e, edge, _handleType) => {
// update mouse event
edgeUpdateMouseEvent.current = e;
// always delete the edge when starting an updated
dispatch(edgeDeleted(edge.id));
},
[dispatch]
);
const onEdgeUpdate: OnEdgeUpdateFunc = useCallback(
(_oldEdge, newConnection) => {
// instead of updating the edge (we deleted it earlier), we instead create
// a new one.
dispatch(connectionMade(newConnection));
},
[dispatch]
);
const onEdgeUpdateEnd: NonNullable<ReactFlowProps['onEdgeUpdateEnd']> =
useCallback(
(e, edge, _handleType) => {
// Handle the case where user begins a drag but didn't move the cursor -
// bc we deleted the edge, we need to add it back
if (
// ignore touch events
!('touches' in e) &&
edgeUpdateMouseEvent.current?.clientX === e.clientX &&
edgeUpdateMouseEvent.current?.clientY === e.clientY
) {
dispatch(edgeAdded(edge));
}
// reset mouse event
edgeUpdateMouseEvent.current = undefined;
},
[dispatch]
);
// #endregion
useHotkeys(['Ctrl+c', 'Meta+c'], (e) => { useHotkeys(['Ctrl+c', 'Meta+c'], (e) => {
e.preventDefault(); e.preventDefault();
dispatch(selectionCopied()); dispatch(selectionCopied());
@ -196,6 +257,9 @@ export const Flow = () => {
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onEdgesDelete={onEdgesDelete} onEdgesDelete={onEdgesDelete}
onEdgeUpdate={onEdgeUpdate}
onEdgeUpdateStart={onEdgeUpdateStart}
onEdgeUpdateEnd={onEdgeUpdateEnd}
onNodesDelete={onNodesDelete} onNodesDelete={onNodesDelete}
onConnectStart={onConnectStart} onConnectStart={onConnectStart}
onConnect={onConnect} onConnect={onConnect}

View File

@ -53,13 +53,12 @@ export const useIsValidConnection = () => {
} }
if ( if (
edges edges.find((edge) => {
.filter((edge) => { edge.target === target &&
return edge.target === target && edge.targetHandle === targetHandle; edge.targetHandle === targetHandle &&
}) edge.source === source &&
.find((edge) => { edge.sourceHandle === sourceHandle;
edge.source === source && edge.sourceHandle === sourceHandle; })
})
) { ) {
// We already have a connection from this source to this target // We already have a connection from this source to this target
return false; return false;

View File

@ -15,6 +15,7 @@ import {
NodeChange, NodeChange,
OnConnectStartParams, OnConnectStartParams,
SelectionMode, SelectionMode,
updateEdge,
Viewport, Viewport,
XYPosition, XYPosition,
} from 'reactflow'; } from 'reactflow';
@ -182,6 +183,16 @@ const nodesSlice = createSlice({
edgesChanged: (state, action: PayloadAction<EdgeChange[]>) => { edgesChanged: (state, action: PayloadAction<EdgeChange[]>) => {
state.edges = applyEdgeChanges(action.payload, state.edges); state.edges = applyEdgeChanges(action.payload, state.edges);
}, },
edgeAdded: (state, action: PayloadAction<Edge>) => {
state.edges = addEdge(action.payload, state.edges);
},
edgeUpdated: (
state,
action: PayloadAction<{ oldEdge: Edge; newConnection: Connection }>
) => {
const { oldEdge, newConnection } = action.payload;
state.edges = updateEdge(oldEdge, newConnection, state.edges);
},
connectionStarted: (state, action: PayloadAction<OnConnectStartParams>) => { connectionStarted: (state, action: PayloadAction<OnConnectStartParams>) => {
state.connectionStartParams = action.payload; state.connectionStartParams = action.payload;
const { nodeId, handleId, handleType } = action.payload; const { nodeId, handleId, handleType } = action.payload;
@ -366,6 +377,7 @@ const nodesSlice = createSlice({
target: edge.target, target: edge.target,
type: 'collapsed', type: 'collapsed',
data: { count: 1 }, data: { count: 1 },
updatable: false,
}); });
} }
} }
@ -388,6 +400,7 @@ const nodesSlice = createSlice({
target: edge.target, target: edge.target,
type: 'collapsed', type: 'collapsed',
data: { count: 1 }, data: { count: 1 },
updatable: false,
}); });
} }
} }
@ -400,6 +413,9 @@ const nodesSlice = createSlice({
} }
} }
}, },
edgeDeleted: (state, action: PayloadAction<string>) => {
state.edges = state.edges.filter((e) => e.id !== action.payload);
},
edgesDeleted: (state, action: PayloadAction<Edge[]>) => { edgesDeleted: (state, action: PayloadAction<Edge[]>) => {
const edges = action.payload; const edges = action.payload;
const collapsedEdges = edges.filter((e) => e.type === 'collapsed'); const collapsedEdges = edges.filter((e) => e.type === 'collapsed');
@ -890,69 +906,72 @@ const nodesSlice = createSlice({
}); });
export const { export const {
nodesChanged, addNodePopoverClosed,
edgesChanged, addNodePopoverOpened,
nodeAdded, addNodePopoverToggled,
nodesDeleted, connectionEnded,
connectionMade, connectionMade,
connectionStarted, connectionStarted,
connectionEnded, edgeDeleted,
shouldShowFieldTypeLegendChanged, edgesChanged,
shouldShowMinimapPanelChanged, edgesDeleted,
nodeTemplatesBuilt, edgeUpdated,
nodeEditorReset,
imageCollectionFieldValueChanged,
fieldStringValueChanged,
fieldNumberValueChanged,
fieldBoardValueChanged, fieldBoardValueChanged,
fieldBooleanValueChanged, fieldBooleanValueChanged,
fieldImageValueChanged,
fieldColorValueChanged, fieldColorValueChanged,
fieldMainModelValueChanged,
fieldVaeModelValueChanged,
fieldLoRAModelValueChanged,
fieldEnumModelValueChanged,
fieldControlNetModelValueChanged, fieldControlNetModelValueChanged,
fieldEnumModelValueChanged,
fieldImageValueChanged,
fieldIPAdapterModelValueChanged, fieldIPAdapterModelValueChanged,
fieldLabelChanged,
fieldLoRAModelValueChanged,
fieldMainModelValueChanged,
fieldNumberValueChanged,
fieldRefinerModelValueChanged, fieldRefinerModelValueChanged,
fieldSchedulerValueChanged, fieldSchedulerValueChanged,
fieldStringValueChanged,
fieldVaeModelValueChanged,
imageCollectionFieldValueChanged,
mouseOverFieldChanged,
mouseOverNodeChanged,
nodeAdded,
nodeEditorReset,
nodeEmbedWorkflowChanged,
nodeExclusivelySelected,
nodeIsIntermediateChanged,
nodeIsOpenChanged, nodeIsOpenChanged,
nodeLabelChanged, nodeLabelChanged,
nodeNotesChanged, nodeNotesChanged,
edgesDeleted,
shouldValidateGraphChanged,
shouldAnimateEdgesChanged,
nodeOpacityChanged, nodeOpacityChanged,
shouldSnapToGridChanged, nodesChanged,
shouldColorEdgesChanged, nodesDeleted,
selectedNodesChanged, nodeTemplatesBuilt,
selectedEdgesChanged, nodeUseCacheChanged,
workflowNameChanged,
workflowDescriptionChanged,
workflowTagsChanged,
workflowAuthorChanged,
workflowNotesChanged,
workflowVersionChanged,
workflowContactChanged,
workflowLoaded,
notesNodeValueChanged, notesNodeValueChanged,
selectedAll,
selectedEdgesChanged,
selectedNodesChanged,
selectionCopied,
selectionModeChanged,
selectionPasted,
shouldAnimateEdgesChanged,
shouldColorEdgesChanged,
shouldShowFieldTypeLegendChanged,
shouldShowMinimapPanelChanged,
shouldSnapToGridChanged,
shouldValidateGraphChanged,
viewportChanged,
workflowAuthorChanged,
workflowContactChanged,
workflowDescriptionChanged,
workflowExposedFieldAdded, workflowExposedFieldAdded,
workflowExposedFieldRemoved, workflowExposedFieldRemoved,
fieldLabelChanged, workflowLoaded,
viewportChanged, workflowNameChanged,
mouseOverFieldChanged, workflowNotesChanged,
selectionCopied, workflowTagsChanged,
selectionPasted, workflowVersionChanged,
selectedAll, edgeAdded,
addNodePopoverOpened,
addNodePopoverClosed,
addNodePopoverToggled,
selectionModeChanged,
nodeEmbedWorkflowChanged,
nodeIsIntermediateChanged,
mouseOverNodeChanged,
nodeExclusivelySelected,
nodeUseCacheChanged,
} = nodesSlice.actions; } = nodesSlice.actions;
export default nodesSlice.reducer; export default nodesSlice.reducer;

View File

@ -55,9 +55,29 @@ export const makeConnectionErrorSelector = (
return i18n.t('nodes.cannotConnectInputToInput'); return i18n.t('nodes.cannotConnectInputToInput');
} }
// we have to figure out which is the target and which is the source
const target = handleType === 'target' ? nodeId : connectionNodeId;
const targetHandle =
handleType === 'target' ? fieldName : connectionFieldName;
const source = handleType === 'source' ? nodeId : connectionNodeId;
const sourceHandle =
handleType === 'source' ? fieldName : connectionFieldName;
if ( if (
edges.find((edge) => { edges.find((edge) => {
return edge.target === nodeId && edge.targetHandle === fieldName; edge.target === target &&
edge.targetHandle === targetHandle &&
edge.source === source &&
edge.sourceHandle === sourceHandle;
})
) {
// We already have a connection from this source to this target
return i18n.t('nodes.cannotDuplicateConnection');
}
if (
edges.find((edge) => {
return edge.target === target && edge.targetHandle === targetHandle;
}) && }) &&
// except CollectionItem inputs can have multiples // except CollectionItem inputs can have multiples
targetType !== 'CollectionItem' targetType !== 'CollectionItem'