improvement(preview): consolidate block rendering and fix handle configurations (#3013)

* improvement(preview): consolidate block rendering and fix handle configurations

* refactor(preview): extract SubflowContainerProps interface
This commit is contained in:
Waleed
2026-01-26 14:56:06 -08:00
committed by GitHub
parent ebf2852733
commit 36945deaa5
7 changed files with 339 additions and 235 deletions

View File

@@ -207,6 +207,7 @@ function TemplateCardInner({
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
) : (
<div className='h-full w-full bg-[var(--surface-4)]' />

View File

@@ -214,6 +214,7 @@ function TemplateCardInner({
defaultZoom={0.8}
fitPadding={0.2}
cursorStyle='pointer'
lightweight
/>
) : (
<div className='h-full w-full bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />

View File

@@ -446,6 +446,7 @@ const OGCaptureContainer = forwardRef<HTMLDivElement>((_, ref) => {
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
</div>
)

View File

@@ -466,6 +466,7 @@ export function Editor() {
defaultZoom={0.6}
fitPadding={0.15}
cursorStyle='grab'
lightweight
/>
</div>
<Tooltip.Root>

View File

@@ -1,6 +1,7 @@
'use client'
import { memo, useMemo } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import {
@@ -10,14 +11,12 @@ import {
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import type { ExecutionStatus } from '@/app/workspace/[workspaceId]/w/components/preview/preview'
import { getBlock } from '@/blocks'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
/** Execution status for blocks in preview mode */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
/** Subblock value structure matching workflow state */
interface SubBlockValueEntry {
value: unknown
@@ -35,6 +34,16 @@ interface WorkflowPreviewBlockData {
executionStatus?: ExecutionStatus
/** Subblock values from the workflow state */
subBlockValues?: Record<string, SubBlockValueEntry | unknown>
/** Skips expensive subblock computations for thumbnails */
lightweight?: boolean
/** Whether this is a subflow container (loop/parallel) */
isSubflow?: boolean
/** Type of subflow container */
subflowKind?: 'loop' | 'parallel'
/** Width of subflow container */
width?: number
/** Height of subflow container */
height?: number
}
/**
@@ -204,21 +213,16 @@ function resolveToolsDisplay(
* - Shows '-' for other selector types that need hydration
*/
function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
// Mask password fields
const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
// Resolve various display names (synchronous access, matching WorkflowBlock priority)
const dropdownLabel = resolveDropdownLabel(subBlock, rawValue)
const variablesDisplay = resolveVariablesDisplay(subBlock, rawValue)
const toolsDisplay = resolveToolsDisplay(subBlock, rawValue)
const workflowName = resolveWorkflowName(subBlock, rawValue)
// Check if this is a selector type that needs hydration (show '-' for raw IDs)
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
// Compute final display value matching WorkflowBlock logic
// Priority order matches WorkflowBlock: masked > hydrated names > selector fallback > raw value
const hydratedName = dropdownLabel || variablesDisplay || toolsDisplay || workflowName
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
@@ -242,11 +246,115 @@ function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
)
}
interface SubflowContainerProps {
name: string
width?: number
height?: number
kind: 'loop' | 'parallel'
isPreviewSelected?: boolean
}
/**
* Renders a subflow container (loop/parallel) for preview mode.
*/
function SubflowContainer({
name,
width = 500,
height = 300,
kind,
isPreviewSelected = false,
}: SubflowContainerProps) {
const isLoop = kind === 'loop'
const BlockIcon = isLoop ? RepeatIcon : SplitIcon
const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
const blockName = name || (isLoop ? 'Loop' : 'Parallel')
const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
const leftHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
const rightHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none'
return (
<div
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
style={{ width, height }}
>
{/* Selection ring overlay */}
{isPreviewSelected && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-secondary)]' />
)}
{/* Target handle on left (input to the subflow) */}
<Handle
type='target'
position={Position.Left}
id='target'
className={leftHandleClass}
style={{
left: '-8px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
{/* Header - matches actual subflow header structure */}
<div className='flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ backgroundColor: blockIconBg }}
>
<BlockIcon className='h-[16px] w-[16px] text-white' />
</div>
<span className='font-medium text-[16px]' title={blockName}>
{blockName}
</span>
</div>
</div>
{/* Content area - matches workflow structure */}
<div
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
style={{ position: 'relative' }}
>
{/* Subflow Start - connects to first block in subflow */}
<div className='absolute top-[16px] left-[16px] flex items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] px-[12px] py-[6px]'>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Start</span>
<Handle
type='source'
position={Position.Right}
id={startHandleId}
className={rightHandleClass}
style={{ right: '-8px', top: '50%', transform: 'translateY(-50%)' }}
/>
</div>
</div>
{/* End source handle on right (output from the subflow) */}
<Handle
type='source'
position={Position.Right}
id={endHandleId}
className={rightHandleClass}
style={{
right: '-8px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
</div>
)
}
/**
* Preview block component for workflow visualization.
* Renders block header, subblock values, and handles without
* hooks, store subscriptions, or interactive features.
* Matches the visual structure of WorkflowBlock exactly.
* Also handles subflow containers (loop/parallel) when isSubflow is true.
*/
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
const {
@@ -258,53 +366,84 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
isPreviewSelected = false,
executionStatus,
subBlockValues,
lightweight = false,
isSubflow = false,
subflowKind,
width,
height,
} = data
if (isSubflow && subflowKind) {
return (
<SubflowContainer
name={name}
width={width}
height={height}
kind={subflowKind}
isPreviewSelected={isPreviewSelected}
/>
)
}
const blockConfig = getBlock(type)
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
() =>
lightweight
? { groupsById: {}, canonicalIdBySubBlockId: {} }
: buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks, lightweight]
)
const rawValues = useMemo(() => {
if (!subBlockValues) return {}
if (lightweight || !subBlockValues) return {}
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
acc[key] = extractValue(entry)
return acc
}, {})
}, [subBlockValues])
}, [subBlockValues, lightweight])
const visibleSubBlocks = useMemo(() => {
if (!blockConfig?.subBlocks) return []
if (lightweight || !blockConfig?.subBlocks) return []
const isStarterOrTrigger =
blockConfig.category === 'triggers' || type === 'starter' || isTrigger
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
const effectiveTrigger = isTrigger || type === 'starter'
return blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden) return false
if (subBlock.hideFromPreview) return false
if (!isSubBlockFeatureEnabled(subBlock)) return false
// Handle trigger mode visibility
if (subBlock.mode === 'trigger' && !isStarterOrTrigger) return false
if (effectiveTrigger) {
const isValidTriggerSubblock = isPureTriggerBlock
? subBlock.mode === 'trigger' || !subBlock.mode
: subBlock.mode === 'trigger'
if (!isValidTriggerSubblock) return false
} else {
if (subBlock.mode === 'trigger') return false
}
// Check advanced mode visibility
if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) {
return false
}
// Check condition visibility
if (!subBlock.condition) return true
return evaluateSubBlockCondition(subBlock.condition, rawValues)
})
}, [blockConfig?.subBlocks, blockConfig?.category, type, isTrigger, canonicalIndex, rawValues])
}, [
lightweight,
blockConfig?.subBlocks,
blockConfig?.category,
blockConfig?.triggers?.enabled,
type,
isTrigger,
canonicalIndex,
rawValues,
])
/**
* Compute condition rows for condition blocks
*/
const conditionRows = useMemo(() => {
if (type !== 'condition') return []
if (lightweight || type !== 'condition') return []
const conditionsValue = rawValues.conditions
const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
@@ -332,13 +471,10 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
{ id: 'if', title: 'if', value: '' },
{ id: 'else', title: 'else', value: '' },
]
}, [type, rawValues])
}, [lightweight, type, rawValues])
/**
* Compute router rows for router_v2 blocks
*/
const routerRows = useMemo(() => {
if (type !== 'router_v2') return []
if (lightweight || type !== 'router_v2') return []
const routesValue = rawValues.routes
const raw = typeof routesValue === 'string' ? routesValue : undefined
@@ -361,7 +497,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
}
return [{ id: 'route1', value: '' }]
}, [type, rawValues])
}, [lightweight, type, rawValues])
if (!blockConfig) {
return null
@@ -439,12 +575,10 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
{hasContentBelowHeader && (
<div className='flex flex-col gap-[8px] p-[8px]'>
{type === 'condition' ? (
// Condition block: render condition rows
conditionRows.map((cond) => (
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} />
))
) : type === 'router_v2' ? (
// Router block: render context + route rows
<>
<SubBlockRow
key='context'
@@ -460,7 +594,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
))}
</>
) : (
// Standard blocks: render visible subblocks
visibleSubBlocks.map((subBlock) => {
const rawValue = rawValues[subBlock.id]
return (
@@ -479,18 +612,102 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
</div>
)}
{/* Source handle */}
<Handle
type='source'
position={horizontalHandles ? Position.Right : Position.Bottom}
id='source'
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
style={
horizontalHandles
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
: { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' }
}
/>
{/* Condition block handles - one per condition branch + error */}
{type === 'condition' && (
<>
{conditionRows.map((cond, condIndex) => {
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y + condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${cond.id}`}
type='source'
position={Position.Right}
id={`condition-${cond.id}`}
className={horizontalHandleClass}
style={{ right: '-7px', top: `${topOffset}px`, transform: 'translateY(-50%)' }}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className='!border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-[2px]'
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
/>
</>
)}
{/* Router block handles - one per route + error */}
{type === 'router_v2' && (
<>
{routerRows.map((route, routeIndex) => {
// +1 row offset for context row at the top
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y +
(routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${route.id}`}
type='source'
position={Position.Right}
id={`router-${route.id}`}
className={horizontalHandleClass}
style={{ right: '-7px', top: `${topOffset}px`, transform: 'translateY(-50%)' }}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className='!border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-[2px]'
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
/>
</>
)}
{/* Standard block handles - source + error (not for condition, router, or response) */}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
<>
<Handle
type='source'
position={horizontalHandles ? Position.Right : Position.Bottom}
id='source'
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
style={
horizontalHandles
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
: { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' }
}
/>
{shouldShowDefaultHandles && (
<Handle
type='source'
position={Position.Right}
id='error'
className='!border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-[2px]'
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
/>
)}
</>
)}
</div>
)
}
@@ -499,7 +716,6 @@ function shouldSkipPreviewBlockRender(
prevProps: NodeProps<WorkflowPreviewBlockData>,
nextProps: NodeProps<WorkflowPreviewBlockData>
): boolean {
// Check primitive props first (fast path)
if (
prevProps.id !== nextProps.id ||
prevProps.data.type !== nextProps.data.type ||
@@ -508,12 +724,16 @@ function shouldSkipPreviewBlockRender(
prevProps.data.horizontalHandles !== nextProps.data.horizontalHandles ||
prevProps.data.enabled !== nextProps.data.enabled ||
prevProps.data.isPreviewSelected !== nextProps.data.isPreviewSelected ||
prevProps.data.executionStatus !== nextProps.data.executionStatus
prevProps.data.executionStatus !== nextProps.data.executionStatus ||
prevProps.data.lightweight !== nextProps.data.lightweight ||
prevProps.data.isSubflow !== nextProps.data.isSubflow ||
prevProps.data.subflowKind !== nextProps.data.subflowKind ||
prevProps.data.width !== nextProps.data.width ||
prevProps.data.height !== nextProps.data.height
) {
return false
}
// Compare subBlockValues by reference first
const prevValues = prevProps.data.subBlockValues
const nextValues = nextProps.data.subBlockValues
@@ -525,7 +745,6 @@ function shouldSkipPreviewBlockRender(
return false
}
// Shallow compare keys and values
const prevKeys = Object.keys(prevValues)
const nextKeys = Object.keys(nextValues)

View File

@@ -1,113 +0,0 @@
'use client'
import { memo } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
interface WorkflowPreviewSubflowData {
name: string
width?: number
height?: number
kind: 'loop' | 'parallel'
/** Whether this subflow is selected in preview mode */
isPreviewSelected?: boolean
}
/**
* Preview subflow component for workflow visualization.
* Renders loop/parallel containers without hooks, store subscriptions,
* or interactive features.
*/
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data
const isLoop = kind === 'loop'
const BlockIcon = isLoop ? RepeatIcon : SplitIcon
const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
const blockName = name || (isLoop ? 'Loop' : 'Parallel')
const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
const leftHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
const rightHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none'
return (
<div
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
style={{
width,
height,
}}
>
{/* Selection ring overlay */}
{isPreviewSelected && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-[8px] ring-[1.75px] ring-[var(--brand-secondary)]' />
)}
{/* Target handle on left (input to the subflow) */}
<Handle
type='target'
position={Position.Left}
id='target'
className={leftHandleClass}
style={{
left: '-8px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
{/* Header - matches actual subflow header structure */}
<div className='flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ backgroundColor: blockIconBg }}
>
<BlockIcon className='h-[16px] w-[16px] text-white' />
</div>
<span className='font-medium text-[16px]' title={blockName}>
{blockName}
</span>
</div>
</div>
{/* Content area - matches workflow structure */}
<div
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
style={{ position: 'relative' }}
>
{/* Subflow Start - connects to first block in subflow */}
<div className='absolute top-[16px] left-[16px] flex items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] px-[12px] py-[6px]'>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Start</span>
<Handle
type='source'
position={Position.Right}
id={startHandleId}
className={rightHandleClass}
style={{ right: '-8px', top: '50%', transform: 'translateY(-50%)' }}
/>
</div>
</div>
{/* End source handle on right (output from the subflow) */}
<Handle
type='source'
position={Position.Right}
id={endHandleId}
className={rightHandleClass}
style={{
right: '-8px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
</div>
)
}
export const WorkflowPreviewSubflow = memo(WorkflowPreviewSubflowInner)

View File

@@ -18,7 +18,6 @@ import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/b
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowPreview')
@@ -53,13 +52,12 @@ function getPreviewBlockDimensions(block: BlockState): { width: number; height:
/**
* Calculates container dimensions based on child block positions and sizes.
* Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
* Accepts pre-filtered childBlocks for O(1) lookup instead of filtering all blocks.
*/
function calculateContainerDimensions(
containerId: string,
blocks: Record<string, BlockState>
): { width: number; height: number } {
const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId)
function calculateContainerDimensions(childBlocks: BlockState[]): {
width: number
height: number
} {
if (childBlocks.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
@@ -113,8 +111,34 @@ export function getLeftmostBlockId(workflowState: WorkflowState | null | undefin
return leftmostId
}
/**
* Recursively calculates the absolute position of a block,
* accounting for parent container offsets.
*/
function calculateAbsolutePosition(
block: BlockState,
blocks: Record<string, BlockState>
): { x: number; y: number } {
if (!block.data?.parentId) {
return block.position
}
const parentBlock = blocks[block.data.parentId]
if (!parentBlock) {
logger.warn(`Parent block not found for child block: ${block.id}`)
return block.position
}
const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
return {
x: parentAbsolutePosition.x + block.position.x,
y: parentAbsolutePosition.y + block.position.y,
}
}
/** Execution status for edges/nodes in the preview */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
export type ExecutionStatus = 'success' | 'error' | 'not-executed'
interface WorkflowPreviewProps {
workflowState: WorkflowState
@@ -136,6 +160,8 @@ interface WorkflowPreviewProps {
executedBlocks?: Record<string, { status: string }>
/** Currently selected block ID for highlighting */
selectedBlockId?: string | null
/** Skips expensive computations for thumbnails/template previews */
lightweight?: boolean
}
/**
@@ -144,8 +170,6 @@ interface WorkflowPreviewProps {
*/
const previewNodeTypes: NodeTypes = {
workflowBlock: WorkflowPreviewBlock,
noteBlock: WorkflowPreviewBlock,
subflowNode: WorkflowPreviewSubflow,
}
// Define edge types
@@ -221,64 +245,30 @@ export function WorkflowPreview({
cursorStyle = 'grab',
executedBlocks,
selectedBlockId,
lightweight = false,
}: WorkflowPreviewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const nodeTypes = previewNodeTypes
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
const blocksStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.blocks || {}).length,
ids: Object.keys(workflowState.blocks || {}).join(','),
}
}, [workflowState.blocks, isValidWorkflowState])
const workflowStructureIds = useMemo(() => {
if (!isValidWorkflowState) return ''
const blockIds = Object.keys(workflowState.blocks || {})
const edgeIds = (workflowState.edges || []).map((e) => e.id)
return [...blockIds, ...edgeIds].join(',')
}, [workflowState.blocks, workflowState.edges, isValidWorkflowState])
const loopsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.loops || {}).length,
ids: Object.keys(workflowState.loops || {}).join(','),
// Pre-compute parent-child relationships for O(1) lookups in container dimension calculations
const containerChildIndex = useMemo(() => {
const index: Record<string, BlockState[]> = {}
for (const block of Object.values(workflowState.blocks || {})) {
if (block?.data?.parentId) {
const parentId = block.data.parentId
if (!index[parentId]) index[parentId] = []
index[parentId].push(block)
}
}
}, [workflowState.loops, isValidWorkflowState])
const parallelsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.parallels || {}).length,
ids: Object.keys(workflowState.parallels || {}).join(','),
}
}, [workflowState.parallels, isValidWorkflowState])
const edgesStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: workflowState.edges?.length || 0,
ids: workflowState.edges?.map((e) => e.id).join(',') || '',
}
}, [workflowState.edges, isValidWorkflowState])
const calculateAbsolutePosition = (
block: any,
blocks: Record<string, any>
): { x: number; y: number } => {
if (!block.data?.parentId) {
return block.position
}
const parentBlock = blocks[block.data.parentId]
if (!parentBlock) {
logger.warn(`Parent block not found for child block: ${block.id}`)
return block.position
}
const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
return {
x: parentAbsolutePosition.x + block.position.x,
y: parentAbsolutePosition.y + block.position.y,
}
}
return index
}, [workflowState.blocks])
const nodes: Node[] = useMemo(() => {
if (!isValidWorkflowState) return []
@@ -293,21 +283,24 @@ export function WorkflowPreview({
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
// Handle loop/parallel containers
// Handle loop/parallel containers - use unified workflowBlock with isSubflow flag
if (block.type === 'loop' || block.type === 'parallel') {
const isSelected = selectedBlockId === blockId
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
const childBlocks = containerChildIndex[blockId] || []
const dimensions = calculateContainerDimensions(childBlocks)
nodeArray.push({
id: blockId,
type: 'subflowNode',
type: 'workflowBlock',
position: absolutePosition,
draggable: false,
data: {
type: block.type,
name: block.name,
isPreviewSelected: isSelected,
isSubflow: true,
subflowKind: block.type as 'loop' | 'parallel',
width: dimensions.width,
height: dimensions.height,
kind: block.type as 'loop' | 'parallel',
isPreviewSelected: isSelected,
},
})
return
@@ -348,19 +341,20 @@ export function WorkflowPreview({
isPreviewSelected: isSelected,
executionStatus,
subBlockValues: block.subBlocks,
lightweight,
},
})
})
return nodeArray
}, [
blocksStructure,
loopsStructure,
parallelsStructure,
workflowStructureIds,
workflowState.blocks,
containerChildIndex,
isValidWorkflowState,
executedBlocks,
selectedBlockId,
lightweight,
])
const edges: Edge[] = useMemo(() => {
@@ -395,7 +389,7 @@ export function WorkflowPreview({
zIndex: executionStatus === 'success' ? 10 : 0,
}
})
}, [edgesStructure, workflowState.edges, isValidWorkflowState, executedBlocks])
}, [workflowStructureIds, workflowState.edges, isValidWorkflowState, executedBlocks])
if (!isValidWorkflowState) {
return (
@@ -449,7 +443,7 @@ export function WorkflowPreview({
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
nodeTypes={previewNodeTypes}
edgeTypes={edgeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
@@ -489,7 +483,7 @@ export function WorkflowPreview({
onPaneClick={onPaneClick}
/>
<FitViewOnChange
nodeIds={blocksStructure.ids}
nodeIds={workflowStructureIds}
fitPadding={fitPadding}
containerRef={containerRef}
/>