From 79e5ccfd37a69676d05321ddf7323b46a0d605c2 Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Thu, 19 Jun 2025 18:47:36 +0200 Subject: [PATCH] Set viewport when nodes dimensions are ready (#12730) Sometimes, we try to set the viewport, but the nodes' dimensions have been reset. Trying to set the viewport when the nodes' dimensions are incorrect leads to an incorrect viewport. This PR ensures we only try to set the viewport if the nodes' dimensions are valid. Otherwise, we wait for them to be computed to set the viewport automatically. The `handleNodesChanges` function is called every time the nodes change, including when the dimensions have been computed. Internally, Reactflow has a similar behavior to implement the `fitView` feature: https://github.com/xyflow/xyflow/blob/f9971a8fad54e9c2f33b71b4056b6d1ec6c33bd1/packages/react/src/store/index.ts#L111. ## Example This is more notable since I added optimistic rendering to workflow runs. https://github.com/user-attachments/assets/07232050-b808-4345-b82b-95acad72ab15 --- .../components/WorkflowDiagramCanvasBase.tsx | 251 ++++++++++++------ ...ramWaitingNodesDimensionsComponentState.ts | 9 + 2 files changed, 174 insertions(+), 86 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/states/workflowDiagramWaitingNodesDimensionsComponentState.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx index 21c6cdc85..c8bf6f184 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx @@ -1,18 +1,23 @@ import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; import { CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant'; import { useListenRightDrawerClose } from '@/ui/layout/right-drawer/hooks/useListenRightDrawerClose'; +import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { WorkflowDiagramCustomMarkers } from '@/workflow/workflow-diagram/components/WorkflowDiagramCustomMarkers'; import { useRightDrawerState } from '@/workflow/workflow-diagram/hooks/useRightDrawerState'; import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState'; +import { workflowDiagramWaitingNodesDimensionsComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramWaitingNodesDimensionsComponentState'; import { + WorkflowDiagram, WorkflowDiagramEdge, WorkflowDiagramEdgeType, WorkflowDiagramNode, WorkflowDiagramNodeType, } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrganizedDiagram'; +import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { @@ -28,18 +33,11 @@ import { useReactFlow, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useRecoilCallback } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; import { Tag, TagColor } from 'twenty-ui/components'; import { THEME_COMMON } from 'twenty-ui/theme'; -import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; const StyledResetReactflowStyles = styled.div` height: 100%; @@ -134,15 +132,25 @@ export const WorkflowDiagramCanvasBase = ({ const workflowDiagram = useRecoilComponentValueV2( workflowDiagramComponentState, ); - + const workflowDiagramState = useRecoilComponentCallbackStateV2( + workflowDiagramComponentState, + ); + const setWorkflowDiagram = useSetRecoilComponentStateV2( + workflowDiagramComponentState, + ); const setWorkflowInsertStepIds = useSetRecoilComponentStateV2( workflowInsertStepIdsComponentState, ); + const workflowDiagramWaitingNodesDimensionsState = + useRecoilComponentCallbackStateV2( + workflowDiagramWaitingNodesDimensionsComponentState, + ); + const setWorkflowDiagramWaitingNodesDimensions = useSetRecoilComponentStateV2( + workflowDiagramWaitingNodesDimensionsComponentState, + ); - const [ - workflowDiagramFlowInitializationStatus, - setWorkflowDiagramFlowInitializationStatus, - ] = useState<'not-initialized' | 'initialized'>('not-initialized'); + const [workflowDiagramFlowInitialized, setWorkflowDiagramFlowInitialized] = + useState(false); const { nodes, edges } = useMemo( () => @@ -155,10 +163,6 @@ export const WorkflowDiagramCanvasBase = ({ const { rightDrawerState } = useRightDrawerState(); const { isInRightDrawer } = useContext(ActionMenuContext); - const setWorkflowDiagram = useSetRecoilComponentStateV2( - workflowDiagramComponentState, - ); - const handleEdgesChange = ( edgeChanges: Array>, ) => { @@ -188,97 +192,172 @@ export const WorkflowDiagramCanvasBase = ({ const containerRef = useRef(null); - const setFlowViewport = useCallback( - ({ - rightDrawerState, - noAnimation, - workflowDiagramFlowInitializationStatus, - isInRightDrawer, - }: { - rightDrawerState: CommandMenuAnimationVariant; - noAnimation?: boolean; - workflowDiagramFlowInitializationStatus: - | 'not-initialized' - | 'initialized'; - isInRightDrawer: boolean; - }) => { - if ( - !isDefined(containerRef.current) || - workflowDiagramFlowInitializationStatus !== 'initialized' - ) { - return; - } + const setFlowViewport = useRecoilCallback( + () => + ({ + rightDrawerState, + noAnimation, + workflowDiagramFlowInitialized, + isInRightDrawer, + workflowDiagram, + }: { + rightDrawerState: CommandMenuAnimationVariant; + noAnimation?: boolean; + workflowDiagramFlowInitialized: boolean; + isInRightDrawer: boolean; + workflowDiagram: WorkflowDiagram | undefined; + }) => { + if ( + !isDefined(containerRef.current) || + !workflowDiagramFlowInitialized + ) { + return; + } - const currentViewport = reactflow.getViewport(); - const flowBounds = reactflow.getNodesBounds(reactflow.getNodes()); + const currentViewport = reactflow.getViewport(); + const nodes = workflowDiagram?.nodes ?? []; - let visibleRightDrawerWidth = 0; - if (rightDrawerState === 'normal' && !isInRightDrawer) { - const rightDrawerWidth = Number( - THEME_COMMON.rightDrawerWidth.replace('px', ''), + const canComputeNodesBounds = nodes.every((node) => + isDefined(node.measured), ); - visibleRightDrawerWidth = rightDrawerWidth; - } + if (!canComputeNodesBounds) { + setWorkflowDiagramWaitingNodesDimensions(true); + return; + } - const viewportX = - (containerRef.current.offsetWidth + visibleRightDrawerWidth) / 2 - - flowBounds.width / 2; + setWorkflowDiagramWaitingNodesDimensions(false); - reactflow.setViewport( - { - ...currentViewport, - x: viewportX - visibleRightDrawerWidth, - zoom: defaultFitViewOptions.maxZoom, - }, - { duration: noAnimation ? 0 : 300 }, - ); - }, - [reactflow], + let visibleRightDrawerWidth = 0; + if (rightDrawerState === 'normal' && !isInRightDrawer) { + const rightDrawerWidth = Number( + THEME_COMMON.rightDrawerWidth.replace('px', ''), + ); + + visibleRightDrawerWidth = rightDrawerWidth; + } + + const flowBounds = reactflow.getNodesBounds(nodes); + const viewportX = + (containerRef.current.offsetWidth + visibleRightDrawerWidth) / 2 - + flowBounds.width / 2; + + reactflow.setViewport( + { + ...currentViewport, + x: viewportX - visibleRightDrawerWidth, + zoom: defaultFitViewOptions.maxZoom, + }, + { duration: noAnimation ? 0 : 300 }, + ); + }, + [reactflow, setWorkflowDiagramWaitingNodesDimensions], + ); + + const handleSetFlowViewportOnChange = useRecoilCallback( + ({ snapshot }) => + ({ + rightDrawerState, + workflowDiagramFlowInitialized, + isInRightDrawer, + }: { + rightDrawerState: CommandMenuAnimationVariant; + workflowDiagramFlowInitialized: boolean; + isInRightDrawer: boolean; + }) => { + setFlowViewport({ + rightDrawerState, + isInRightDrawer, + workflowDiagramFlowInitialized, + workflowDiagram: getSnapshotValue(snapshot, workflowDiagramState), + }); + }, + [setFlowViewport, workflowDiagramState], ); useEffect(() => { - setFlowViewport({ + handleSetFlowViewportOnChange({ rightDrawerState, + workflowDiagramFlowInitialized, isInRightDrawer, - workflowDiagramFlowInitializationStatus, }); }, [ + handleSetFlowViewportOnChange, isInRightDrawer, rightDrawerState, - setFlowViewport, - workflowDiagramFlowInitializationStatus, + workflowDiagramFlowInitialized, ]); - const handleNodesChanges = (changes: NodeChange[]) => { - setWorkflowDiagram((diagram) => { - if (!isDefined(diagram)) { - return diagram; - } + const handleNodesChanges = useRecoilCallback( + ({ snapshot, set }) => + (changes: NodeChange[]) => { + const workflowDiagram = getSnapshotValue( + snapshot, + workflowDiagramState, + ); + let updatedWorkflowDiagram = workflowDiagram; + if (isDefined(workflowDiagram)) { + updatedWorkflowDiagram = { + ...workflowDiagram, + nodes: applyNodeChanges(changes, workflowDiagram.nodes), + }; + } - return { - ...diagram, - nodes: applyNodeChanges(changes, diagram.nodes), - }; - }); - }; + set(workflowDiagramState, updatedWorkflowDiagram); - const handleInit = () => { - if (!isDefined(containerRef.current)) { - return; - } + const workflowDiagramWaitingNodesDimensions = getSnapshotValue( + snapshot, + workflowDiagramWaitingNodesDimensionsState, + ); + if (!workflowDiagramWaitingNodesDimensions) { + return; + } - setFlowViewport({ - rightDrawerState, - noAnimation: true, + setFlowViewport({ + rightDrawerState, + noAnimation: true, + isInRightDrawer, + workflowDiagramFlowInitialized, + workflowDiagram: updatedWorkflowDiagram, + }); + }, + [ isInRightDrawer, - workflowDiagramFlowInitializationStatus: 'initialized', - }); + rightDrawerState, + setFlowViewport, + workflowDiagramFlowInitialized, + workflowDiagramState, + workflowDiagramWaitingNodesDimensionsState, + ], + ); - setWorkflowDiagramFlowInitializationStatus('initialized'); + const handleInit = useRecoilCallback( + ({ snapshot }) => + () => { + if (!isDefined(containerRef.current)) { + return; + } - onInit?.(); - }; + setFlowViewport({ + rightDrawerState, + noAnimation: true, + isInRightDrawer, + workflowDiagramFlowInitialized: true, + workflowDiagram: getSnapshotValue(snapshot, workflowDiagramState), + }); + + setWorkflowDiagramFlowInitialized(true); + + onInit?.(); + }, + [ + isInRightDrawer, + onInit, + rightDrawerState, + setFlowViewport, + workflowDiagramState, + ], + ); return ( diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/states/workflowDiagramWaitingNodesDimensionsComponentState.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/states/workflowDiagramWaitingNodesDimensionsComponentState.ts new file mode 100644 index 000000000..fe3cf932c --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/states/workflowDiagramWaitingNodesDimensionsComponentState.ts @@ -0,0 +1,9 @@ +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; +import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; + +export const workflowDiagramWaitingNodesDimensionsComponentState = + createComponentStateV2({ + key: 'workflowDiagramWaitingNodesDimensionsComponentState', + defaultValue: false, + componentInstanceContext: WorkflowVisualizerComponentInstanceContext, + });