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:
psychedelicious 2023-08-14 19:49:58 +10:00
parent 94bfef3543
commit f65c8092cb
6 changed files with 96 additions and 24 deletions

View File

@ -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
); );
/** /**

View File

@ -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 />

View File

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

View File

@ -10,4 +10,5 @@ export const nodesPersistDenylist: (keyof NodesState)[] = [
'currentConnectionFieldType', 'currentConnectionFieldType',
'selectedNodes', 'selectedNodes',
'selectedEdges', 'selectedEdges',
'isReady',
]; ];

View File

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

View File

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