From 26cd94c0ddc1079b504e47250b2d2c5f2af19a19 Mon Sep 17 00:00:00 2001 From: CrypticWit Date: Wed, 20 Sep 2023 17:03:35 +1200 Subject: [PATCH 1/8] Initial commit. Feature works, but code might need some cleanup --- .../features/nodes/components/flow/Flow.tsx | 16 +++++++++++- .../src/features/nodes/store/nodesSlice.ts | 26 +++++++++++++++++-- .../web/src/features/nodes/store/types.ts | 2 ++ 3 files changed, 41 insertions(+), 3 deletions(-) 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 16af1fe12c5..5c15a0ca30d 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 { useCallback, useRef } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { Background, @@ -34,6 +34,7 @@ import { selectedAll, selectedEdgesChanged, selectedNodesChanged, + mouseMoved, selectionCopied, selectionPasted, viewportChanged, @@ -79,6 +80,7 @@ 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 isValidConnection = useIsValidConnection(); @@ -152,8 +154,19 @@ export const Flow = () => { const onInit: OnInit = useCallback((flow) => { $flow.set(flow); flow.fitView(); + flowWrapper.current = document.getElementById('workflow-editor'); }, []); + const onMouseMove = (event: MouseEvent) => { + const bounds = flowWrapper.current?.getBoundingClientRect(); + const pos = $flow.get().project({ + x: event.clientX - bounds.left, + y: event.clientY - bounds.top, + }); + + dispatch(mouseMoved(pos)); + }; + useHotkeys(['Ctrl+c', 'Meta+c'], (e) => { e.preventDefault(); dispatch(selectionCopied()); @@ -178,6 +191,7 @@ export const Flow = () => { nodes={nodes} edges={edges} onInit={onInit} + onMouseMove={onMouseMove} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onEdgesDelete={onEdgesDelete} diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 3e330cf7103..b6b3d1c6ce2 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -55,6 +55,7 @@ import { VaeModelInputFieldValue, Workflow, } from '../types/types'; + import { NodesState } from './types'; import { findUnoccupiedPosition } from './util/findUnoccupiedPosition'; @@ -102,6 +103,7 @@ export const initialNodesState: NodesState = { workflow: initialWorkflow, nodeExecutionStates: {}, viewport: { x: 0, y: 0, zoom: 1 }, + mousePosition: { x: 100, y: 100 }, mouseOverField: null, mouseOverNode: null, nodesToCopy: [], @@ -697,9 +699,28 @@ const nodesSlice = createSlice({ state.edges ); }, + mouseMoved: (state, action) => { + state.mousePosition = action.payload; + }, selectionCopied: (state) => { 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) => { + averagePosition.x += e.position.x; + averagePosition.y += e.position.y; + }); + + 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) => { const newNodes = state.nodesToCopy.map(cloneDeep); @@ -730,8 +751,8 @@ const nodesSlice = createSlice({ const position = findUnoccupiedPosition( state.nodes, - node.position.x, - node.position.y + node.position.x + state.mousePosition.x, + node.position.y + state.mousePosition.y ); node.position = position; @@ -901,6 +922,7 @@ export const { fieldLabelChanged, viewportChanged, mouseOverFieldChanged, + mouseMoved, selectionCopied, selectionPasted, selectedAll, diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index 78410c2dbaa..2bf8db802f6 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -4,6 +4,7 @@ import { OnConnectStartParams, SelectionMode, Viewport, + XYPosition, } from 'reactflow'; import { FieldIdentifier, @@ -33,6 +34,7 @@ export type NodesState = { workflow: Omit; nodeExecutionStates: Record; viewport: Viewport; + mousePosition: XYPosition; isReady: boolean; mouseOverField: FieldIdentifier | null; mouseOverNode: string | null; From 7945fc255df4c528273f875a82c342fdfced3ec9 Mon Sep 17 00:00:00 2001 From: CrypticWit Date: Wed, 20 Sep 2023 17:11:07 +1200 Subject: [PATCH 2/8] Cleaned up diff --- invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts | 3 +-- invokeai/frontend/web/src/features/nodes/store/types.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index b6b3d1c6ce2..f2c70a1d14f 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -55,7 +55,6 @@ import { VaeModelInputFieldValue, Workflow, } from '../types/types'; - import { NodesState } from './types'; import { findUnoccupiedPosition } from './util/findUnoccupiedPosition'; @@ -103,7 +102,7 @@ export const initialNodesState: NodesState = { workflow: initialWorkflow, nodeExecutionStates: {}, viewport: { x: 0, y: 0, zoom: 1 }, - mousePosition: { x: 100, y: 100 }, + mousePosition: { x: 0, y: 0 }, mouseOverField: null, mouseOverNode: null, nodesToCopy: [], diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index 2bf8db802f6..2d674961f0e 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -4,7 +4,6 @@ import { OnConnectStartParams, SelectionMode, Viewport, - XYPosition, } from 'reactflow'; import { FieldIdentifier, @@ -34,7 +33,7 @@ export type NodesState = { workflow: Omit; nodeExecutionStates: Record; viewport: Viewport; - mousePosition: XYPosition; + mousePosition: object; isReady: boolean; mouseOverField: FieldIdentifier | null; mouseOverNode: string | null; From 68cdc10548e0bdc6b17edb4a451f10147b217904 Mon Sep 17 00:00:00 2001 From: CrypticWit Date: Wed, 20 Sep 2023 20:08:46 +1200 Subject: [PATCH 3/8] Made mousePosition a XYPosition again so its nicely typed --- invokeai/frontend/web/src/features/nodes/store/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index 2d674961f0e..2bf8db802f6 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -4,6 +4,7 @@ import { OnConnectStartParams, SelectionMode, Viewport, + XYPosition, } from 'reactflow'; import { FieldIdentifier, @@ -33,7 +34,7 @@ export type NodesState = { workflow: Omit; nodeExecutionStates: Record; viewport: Viewport; - mousePosition: object; + mousePosition: XYPosition; isReady: boolean; mouseOverField: FieldIdentifier | null; mouseOverNode: string | null; From a68be8ba2eb09c4c6633775bb63c54238aed1e72 Mon Sep 17 00:00:00 2001 From: CrypticWit Date: Wed, 20 Sep 2023 21:11:40 +1200 Subject: [PATCH 4/8] Fixed yarn issues --- .../features/nodes/components/flow/Flow.tsx | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) 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 5c15a0ca30d..dca4d8d59c8 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -80,7 +80,7 @@ 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 flowWrapper = useRef(null); const isValidConnection = useIsValidConnection(); @@ -151,20 +151,25 @@ export const Flow = () => { dispatch(contextMenusClosed()); }, [dispatch]); - const onInit: OnInit = useCallback((flow) => { - $flow.set(flow); - flow.fitView(); - flowWrapper.current = document.getElementById('workflow-editor'); - }, []); + const onInit: OnInit = useCallback( + (flow) => { + $flow.set(flow); + flow.fitView(); + flowWrapper.current = document.getElementById('workflow-editor'); + }, + [flowWrapper] + ); const onMouseMove = (event: MouseEvent) => { const bounds = flowWrapper.current?.getBoundingClientRect(); - const pos = $flow.get().project({ - x: event.clientX - bounds.left, - y: event.clientY - bounds.top, - }); - - dispatch(mouseMoved(pos)); + if (bounds) { + const pos = $flow.get()?.project({ + x: event.clientX - bounds.left, + y: event.clientY - bounds.top, + }); + + dispatch(mouseMoved(pos)); + } }; useHotkeys(['Ctrl+c', 'Meta+c'], (e) => { @@ -191,7 +196,7 @@ export const Flow = () => { nodes={nodes} edges={edges} onInit={onInit} - onMouseMove={onMouseMove} + onMouseMove={(event) => onMouseMove(event.nativeEvent)} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onEdgesDelete={onEdgesDelete} From 4ae32573bea28c85bb8b0f37387801bf9a7b7c26 Mon Sep 17 00:00:00 2001 From: CrypticWit Date: Thu, 21 Sep 2023 09:02:45 +1200 Subject: [PATCH 5/8] Paste now properly takes node width/height into account when pasting --- .../frontend/web/src/features/nodes/store/nodesSlice.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 0545098bed5..277d75f9947 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -723,8 +723,10 @@ const nodesSlice = createSlice({ if (state.nodesToCopy.length > 0) { const averagePosition = { x: 0, y: 0 }; state.nodesToCopy.forEach((e) => { - averagePosition.x += e.position.x; - averagePosition.y += e.position.y; + 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; From b856c078cd819b14c0c76e5ab562ebc4c3e79b4b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 21 Sep 2023 10:27:42 +1000 Subject: [PATCH 6/8] feat(ui): use react's types in the `onMouseMove` `reactflow` handler --- .../features/nodes/components/flow/Flow.tsx | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) 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 dca4d8d59c8..9a66334b147 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, useRef } from 'react'; +import { MouseEvent, useCallback, useRef } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { Background, @@ -160,17 +160,20 @@ export const Flow = () => { [flowWrapper] ); - const onMouseMove = (event: MouseEvent) => { - const bounds = flowWrapper.current?.getBoundingClientRect(); - if (bounds) { - const pos = $flow.get()?.project({ - x: event.clientX - bounds.left, - y: event.clientY - bounds.top, - }); - - dispatch(mouseMoved(pos)); - } - }; + 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, + }); + + dispatch(mouseMoved(pos)); + } + }, + [dispatch] + ); useHotkeys(['Ctrl+c', 'Meta+c'], (e) => { e.preventDefault(); @@ -196,7 +199,7 @@ export const Flow = () => { nodes={nodes} edges={edges} onInit={onInit} - onMouseMove={(event) => onMouseMove(event.nativeEvent)} + onMouseMove={onMouseMove} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onEdgesDelete={onEdgesDelete} From ca3c57109468aed51f2fab91195e36f93afb1a97 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 21 Sep 2023 10:31:49 +1000 Subject: [PATCH 7/8] feat(ui): use refs to access `reactflow`'s DOM elements --- .../src/features/nodes/components/flow/Flow.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) 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 9a66334b147..01bb930d4cf 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -80,7 +80,7 @@ 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 flowWrapper = useRef(null); const isValidConnection = useIsValidConnection(); @@ -151,14 +151,10 @@ export const Flow = () => { dispatch(contextMenusClosed()); }, [dispatch]); - const onInit: OnInit = useCallback( - (flow) => { - $flow.set(flow); - flow.fitView(); - flowWrapper.current = document.getElementById('workflow-editor'); - }, - [flowWrapper] - ); + const onInit: OnInit = useCallback((flow) => { + $flow.set(flow); + flow.fitView(); + }, []); const onMouseMove = useCallback( (event: MouseEvent) => { @@ -193,6 +189,7 @@ export const Flow = () => { return ( Date: Thu, 21 Sep 2023 10:44:38 +1000 Subject: [PATCH 8/8] feat(ui): use a ref to store cursor position in nodes --- .../features/nodes/components/flow/Flow.tsx | 30 ++++++++----------- .../src/features/nodes/store/nodesSlice.ts | 16 +++++----- .../web/src/features/nodes/store/types.ts | 2 -- 3 files changed, 21 insertions(+), 27 deletions(-) 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 01bb930d4cf..57e5825fb9c 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -21,6 +21,7 @@ import { OnSelectionChangeFunc, ProOptions, ReactFlow, + XYPosition, } from 'reactflow'; import { useIsValidConnection } from '../../hooks/useIsValidConnection'; import { @@ -34,7 +35,6 @@ import { selectedAll, selectedEdgesChanged, selectedNodesChanged, - mouseMoved, selectionCopied, selectionPasted, viewportChanged, @@ -81,7 +81,7 @@ export const Flow = () => { 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']); @@ -156,20 +156,16 @@ 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, - }); - - dispatch(mouseMoved(pos)); - } - }, - [dispatch] - ); + 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(); @@ -183,7 +179,7 @@ export const Flow = () => { useHotkeys(['Ctrl+v', 'Meta+v'], (e) => { e.preventDefault(); - dispatch(selectionPasted()); + dispatch(selectionPasted({ cursorPosition: cursorPosition.current })); }); return ( diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 277d75f9947..e59105348f3 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -16,6 +16,7 @@ import { OnConnectStartParams, SelectionMode, Viewport, + XYPosition, } from 'reactflow'; import { receivedOpenAPISchema } from 'services/api/thunks/schema'; import { sessionCanceled, sessionInvoked } from 'services/api/thunks/session'; @@ -103,7 +104,6 @@ export const initialNodesState: NodesState = { workflow: initialWorkflow, nodeExecutionStates: {}, viewport: { x: 0, y: 0, zoom: 1 }, - mousePosition: { x: 0, y: 0 }, mouseOverField: null, mouseOverNode: null, nodesToCopy: [], @@ -713,9 +713,6 @@ const nodesSlice = createSlice({ state.edges ); }, - mouseMoved: (state, action) => { - state.mousePosition = action.payload; - }, selectionCopied: (state) => { state.nodesToCopy = state.nodes.filter((n) => n.selected).map(cloneDeep); state.edgesToCopy = state.edges.filter((e) => e.selected).map(cloneDeep); @@ -738,7 +735,11 @@ const nodesSlice = createSlice({ }); } }, - 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 @@ -767,8 +768,8 @@ const nodesSlice = createSlice({ const position = findUnoccupiedPosition( state.nodes, - node.position.x + state.mousePosition.x, - node.position.y + state.mousePosition.y + node.position.x + (cursorPosition?.x ?? 0), + node.position.y + (cursorPosition?.y ?? 0) ); node.position = position; @@ -951,7 +952,6 @@ export const { fieldLabelChanged, viewportChanged, mouseOverFieldChanged, - mouseMoved, selectionCopied, selectionPasted, selectedAll, diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts index 2bf8db802f6..78410c2dbaa 100644 --- a/invokeai/frontend/web/src/features/nodes/store/types.ts +++ b/invokeai/frontend/web/src/features/nodes/store/types.ts @@ -4,7 +4,6 @@ import { OnConnectStartParams, SelectionMode, Viewport, - XYPosition, } from 'reactflow'; import { FieldIdentifier, @@ -34,7 +33,6 @@ export type NodesState = { workflow: Omit; nodeExecutionStates: Record; viewport: Viewport; - mousePosition: XYPosition; isReady: boolean; mouseOverField: FieldIdentifier | null; mouseOverNode: string | null;