mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
fix(ui): fix issue with node editor state not restoring correctly on mount
If `reactflow` initializes before the node templates are parsed, edges may not be rendered and the viewport may get reset. - Add `isReady` state to `NodesState`. This is false when we are loading or parsing node templates and true when that is finished. - Conditionally render `reactflow` based on `isReady`. - Add `viewport` to `NodesState` & handlers to keep it synced. This allows `reactflow` to mount and unmount freely and not lose viewport.
This commit is contained in:
parent
94bfef3543
commit
f65c8092cb
@ -8,7 +8,8 @@ import { useCallback } from 'react';
|
|||||||
|
|
||||||
const selectZoom = createSelector(
|
const selectZoom = createSelector(
|
||||||
[stateSelector, activeTabNameSelector],
|
[stateSelector, activeTabNameSelector],
|
||||||
({ nodes }, activeTabName) => (activeTabName === 'nodes' ? nodes.zoom : 1)
|
({ nodes }, activeTabName) =>
|
||||||
|
activeTabName === 'nodes' ? nodes.viewport.zoom : 1
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { useToken } from '@chakra-ui/react';
|
||||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
@ -7,8 +8,7 @@ import {
|
|||||||
OnConnectStart,
|
OnConnectStart,
|
||||||
OnEdgesChange,
|
OnEdgesChange,
|
||||||
OnEdgesDelete,
|
OnEdgesDelete,
|
||||||
OnInit,
|
OnMoveEnd,
|
||||||
OnMove,
|
|
||||||
OnNodesChange,
|
OnNodesChange,
|
||||||
OnNodesDelete,
|
OnNodesDelete,
|
||||||
OnSelectionChangeFunc,
|
OnSelectionChangeFunc,
|
||||||
@ -26,7 +26,7 @@ import {
|
|||||||
nodesDeleted,
|
nodesDeleted,
|
||||||
selectedEdgesChanged,
|
selectedEdgesChanged,
|
||||||
selectedNodesChanged,
|
selectedNodesChanged,
|
||||||
zoomChanged,
|
viewportChanged,
|
||||||
} from '../store/nodesSlice';
|
} from '../store/nodesSlice';
|
||||||
import { CustomConnectionLine } from './CustomConnectionLine';
|
import { CustomConnectionLine } from './CustomConnectionLine';
|
||||||
import { edgeTypes } from './CustomEdges';
|
import { edgeTypes } from './CustomEdges';
|
||||||
@ -44,12 +44,15 @@ export const Flow = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const nodes = useAppSelector((state) => state.nodes.nodes);
|
const nodes = useAppSelector((state) => state.nodes.nodes);
|
||||||
const edges = useAppSelector((state) => state.nodes.edges);
|
const edges = useAppSelector((state) => state.nodes.edges);
|
||||||
|
const viewport = useAppSelector((state) => state.nodes.viewport);
|
||||||
const shouldSnapToGrid = useAppSelector(
|
const shouldSnapToGrid = useAppSelector(
|
||||||
(state) => state.nodes.shouldSnapToGrid
|
(state) => state.nodes.shouldSnapToGrid
|
||||||
);
|
);
|
||||||
|
|
||||||
const isValidConnection = useIsValidConnection();
|
const isValidConnection = useIsValidConnection();
|
||||||
|
|
||||||
|
const [borderRadius] = useToken('radii', ['base']);
|
||||||
|
|
||||||
const onNodesChange: OnNodesChange = useCallback(
|
const onNodesChange: OnNodesChange = useCallback(
|
||||||
(changes) => {
|
(changes) => {
|
||||||
dispatch(nodesChanged(changes));
|
dispatch(nodesChanged(changes));
|
||||||
@ -82,10 +85,6 @@ export const Flow = () => {
|
|||||||
dispatch(connectionEnded());
|
dispatch(connectionEnded());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const onInit: OnInit = useCallback((v) => {
|
|
||||||
v.fitView();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onEdgesDelete: OnEdgesDelete = useCallback(
|
const onEdgesDelete: OnEdgesDelete = useCallback(
|
||||||
(edges) => {
|
(edges) => {
|
||||||
dispatch(edgesDeleted(edges));
|
dispatch(edgesDeleted(edges));
|
||||||
@ -108,16 +107,16 @@ export const Flow = () => {
|
|||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMove: OnMove = useCallback(
|
const handleMoveEnd: OnMoveEnd = useCallback(
|
||||||
(e, viewport) => {
|
(e, viewport) => {
|
||||||
const { zoom } = viewport;
|
dispatch(viewportChanged(viewport));
|
||||||
dispatch(zoomChanged(zoom));
|
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
|
defaultViewport={viewport}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
@ -129,16 +128,16 @@ export const Flow = () => {
|
|||||||
onConnectStart={onConnectStart}
|
onConnectStart={onConnectStart}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onConnectEnd={onConnectEnd}
|
onConnectEnd={onConnectEnd}
|
||||||
onMove={handleMove}
|
onMoveEnd={handleMoveEnd}
|
||||||
connectionLineComponent={CustomConnectionLine}
|
connectionLineComponent={CustomConnectionLine}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
onInit={onInit}
|
|
||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
minZoom={0.2}
|
minZoom={0.2}
|
||||||
snapToGrid={shouldSnapToGrid}
|
snapToGrid={shouldSnapToGrid}
|
||||||
snapGrid={[25, 25]}
|
snapGrid={[25, 25]}
|
||||||
connectionRadius={30}
|
connectionRadius={30}
|
||||||
proOptions={proOptions}
|
proOptions={proOptions}
|
||||||
|
style={{ borderRadius }}
|
||||||
>
|
>
|
||||||
<TopLeftPanel />
|
<TopLeftPanel />
|
||||||
<TopCenterPanel />
|
<TopCenterPanel />
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
import { Box } from '@chakra-ui/react';
|
import { Flex } from '@chakra-ui/react';
|
||||||
|
import { useAppSelector } from 'app/store/storeHooks';
|
||||||
|
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||||
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
|
import ResizeHandle from 'features/ui/components/tabs/ResizeHandle';
|
||||||
import { memo, useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
|
import { MdDeviceHub } from 'react-icons/md';
|
||||||
import { Panel, PanelGroup } from 'react-resizable-panels';
|
import { Panel, PanelGroup } from 'react-resizable-panels';
|
||||||
import 'reactflow/dist/style.css';
|
import 'reactflow/dist/style.css';
|
||||||
import { Flow } from './Flow';
|
|
||||||
import NodeEditorPanelGroup from './panel/NodeEditorPanelGroup';
|
import NodeEditorPanelGroup from './panel/NodeEditorPanelGroup';
|
||||||
|
import { Flow } from './Flow';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
const NodeEditor = () => {
|
const NodeEditor = () => {
|
||||||
const [isPanelCollapsed, setIsPanelCollapsed] = useState(false);
|
const [isPanelCollapsed, setIsPanelCollapsed] = useState(false);
|
||||||
|
const isReady = useAppSelector((state) => state.nodes.isReady);
|
||||||
return (
|
return (
|
||||||
<PanelGroup
|
<PanelGroup
|
||||||
id="node-editor"
|
id="node-editor"
|
||||||
@ -27,17 +32,76 @@ const NodeEditor = () => {
|
|||||||
collapsedDirection={isPanelCollapsed ? 'left' : undefined}
|
collapsedDirection={isPanelCollapsed ? 'left' : undefined}
|
||||||
/>
|
/>
|
||||||
<Panel id="node-editor-content">
|
<Panel id="node-editor-content">
|
||||||
<Box
|
<Flex
|
||||||
layerStyle={'first'}
|
layerStyle={'first'}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: 'full',
|
width: 'full',
|
||||||
height: 'full',
|
height: 'full',
|
||||||
borderRadius: 'base',
|
borderRadius: 'base',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isReady && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="node-editor-flow"
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
transition: { duration: 0.2 },
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.2 },
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
>
|
>
|
||||||
<Flow />
|
<Flow />
|
||||||
</Box>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isReady && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="node-editor-loading"
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
transition: { duration: 0.2 },
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.2 },
|
||||||
|
}}
|
||||||
|
style={{ position: 'absolute', width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
layerStyle={'first'}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
width: 'full',
|
||||||
|
height: 'full',
|
||||||
|
borderRadius: 'base',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IAINoContentFallback
|
||||||
|
label="Loading Nodes..."
|
||||||
|
icon={MdDeviceHub}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Flex>
|
||||||
</Panel>
|
</Panel>
|
||||||
</PanelGroup>
|
</PanelGroup>
|
||||||
);
|
);
|
||||||
|
@ -10,4 +10,5 @@ export const nodesPersistDenylist: (keyof NodesState)[] = [
|
|||||||
'currentConnectionFieldType',
|
'currentConnectionFieldType',
|
||||||
'selectedNodes',
|
'selectedNodes',
|
||||||
'selectedEdges',
|
'selectedEdges',
|
||||||
|
'isReady',
|
||||||
];
|
];
|
||||||
|
@ -14,6 +14,7 @@ import {
|
|||||||
Node,
|
Node,
|
||||||
NodeChange,
|
NodeChange,
|
||||||
OnConnectStartParams,
|
OnConnectStartParams,
|
||||||
|
Viewport,
|
||||||
} from 'reactflow';
|
} from 'reactflow';
|
||||||
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
|
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
|
||||||
import { sessionInvoked } from 'services/api/thunks/session';
|
import { sessionInvoked } from 'services/api/thunks/session';
|
||||||
@ -56,6 +57,7 @@ export const initialNodesState: NodesState = {
|
|||||||
edges: [],
|
edges: [],
|
||||||
schema: null,
|
schema: null,
|
||||||
nodeTemplates: {},
|
nodeTemplates: {},
|
||||||
|
isReady: false,
|
||||||
connectionStartParams: null,
|
connectionStartParams: null,
|
||||||
currentConnectionFieldType: null,
|
currentConnectionFieldType: null,
|
||||||
shouldShowFieldTypeLegend: false,
|
shouldShowFieldTypeLegend: false,
|
||||||
@ -78,7 +80,7 @@ export const initialNodesState: NodesState = {
|
|||||||
exposedFields: [],
|
exposedFields: [],
|
||||||
},
|
},
|
||||||
nodeExecutionStates: {},
|
nodeExecutionStates: {},
|
||||||
zoom: 1,
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
type FieldValueAction<T extends InputFieldValue> = PayloadAction<{
|
type FieldValueAction<T extends InputFieldValue> = PayloadAction<{
|
||||||
@ -521,6 +523,7 @@ const nodesSlice = createSlice({
|
|||||||
action: PayloadAction<Record<string, InvocationTemplate>>
|
action: PayloadAction<Record<string, InvocationTemplate>>
|
||||||
) => {
|
) => {
|
||||||
state.nodeTemplates = action.payload;
|
state.nodeTemplates = action.payload;
|
||||||
|
state.isReady = true;
|
||||||
},
|
},
|
||||||
nodeEditorReset: (state) => {
|
nodeEditorReset: (state) => {
|
||||||
state.nodes = [];
|
state.nodes = [];
|
||||||
@ -587,11 +590,14 @@ const nodesSlice = createSlice({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
zoomChanged: (state, action: PayloadAction<number>) => {
|
viewportChanged: (state, action: PayloadAction<Viewport>) => {
|
||||||
state.zoom = action.payload;
|
state.viewport = action.payload;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
|
builder.addCase(receivedOpenAPISchema.pending, (state) => {
|
||||||
|
state.isReady = false;
|
||||||
|
});
|
||||||
builder.addCase(receivedOpenAPISchema.fulfilled, (state, action) => {
|
builder.addCase(receivedOpenAPISchema.fulfilled, (state, action) => {
|
||||||
state.schema = action.payload;
|
state.schema = action.payload;
|
||||||
});
|
});
|
||||||
@ -693,7 +699,7 @@ export const {
|
|||||||
workflowExposedFieldAdded,
|
workflowExposedFieldAdded,
|
||||||
workflowExposedFieldRemoved,
|
workflowExposedFieldRemoved,
|
||||||
fieldLabelChanged,
|
fieldLabelChanged,
|
||||||
zoomChanged,
|
viewportChanged,
|
||||||
} = nodesSlice.actions;
|
} = nodesSlice.actions;
|
||||||
|
|
||||||
export default nodesSlice.reducer;
|
export default nodesSlice.reducer;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { OpenAPIV3 } from 'openapi-types';
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
import { Edge, Node, OnConnectStartParams } from 'reactflow';
|
import { Edge, Node, OnConnectStartParams, Viewport } from 'reactflow';
|
||||||
import {
|
import {
|
||||||
FieldType,
|
FieldType,
|
||||||
InvocationEdgeExtra,
|
InvocationEdgeExtra,
|
||||||
@ -27,5 +27,6 @@ export type NodesState = {
|
|||||||
selectedEdges: string[];
|
selectedEdges: string[];
|
||||||
workflow: Omit<Workflow, 'nodes' | 'edges'>;
|
workflow: Omit<Workflow, 'nodes' | 'edges'>;
|
||||||
nodeExecutionStates: Record<string, NodeExecutionState>;
|
nodeExecutionStates: Record<string, NodeExecutionState>;
|
||||||
zoom: number;
|
viewport: Viewport;
|
||||||
|
isReady: boolean;
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user