fix(autolayout): align by handle (#2277)

* fix(autolayout): align by handle

* use shared constants everywhere

* cleanup
This commit is contained in:
Vikhyath Mondreti
2025-12-09 16:28:25 -08:00
committed by GitHub
parent 306043eedb
commit dd7db6e144
12 changed files with 192 additions and 83 deletions

View File

@@ -3,6 +3,7 @@ import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
import { Button, Trash } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
@@ -119,7 +120,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
}
const getHandleStyle = () => {
return { top: '20px', transform: 'translateY(-50%)' }
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
}
/**

View File

@@ -28,6 +28,7 @@ import {
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
BLOCK_DIMENSIONS,
HANDLE_POSITIONS,
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
@@ -716,7 +717,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const getHandleStyle = (position: 'horizontal' | 'vertical') => {
if (position === 'horizontal') {
return { top: '20px', transform: 'translateY(-50%)' }
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
}
return { left: '50%', transform: 'translateX(-50%)' }
}
@@ -1030,7 +1031,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
{type === 'condition' && (
<>
{conditionRows.map((cond, condIndex) => {
const topOffset = 60 + condIndex * 29
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y +
condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${cond.id}`}
@@ -1052,7 +1055,12 @@ export const WorkflowBlock = memo(function WorkflowBlock({
position={Position.Right}
id='error'
className={getHandleClasses('right', true)}
style={{ right: '-7px', top: 'auto', bottom: '17px', transform: 'translateY(50%)' }}
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
data-nodeid={id}
data-handleid='error'
isConnectableStart={true}
@@ -1083,7 +1091,12 @@ export const WorkflowBlock = memo(function WorkflowBlock({
position={Position.Right}
id='error'
className={getHandleClasses('right', true)}
style={{ right: '-7px', top: 'auto', bottom: '17px', transform: 'translateY(50%)' }}
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
data-nodeid={id}
data-handleid='error'
isConnectableStart={true}

View File

@@ -3,7 +3,7 @@ import { useUpdateNodeInternals } from 'reactflow'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
// Re-export for backwards compatibility
export { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
export { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
interface BlockDimensions {
width: number

View File

@@ -2,6 +2,7 @@
import { memo, useMemo } from 'react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { getBlock } from '@/blocks/registry'
interface WorkflowPreviewBlockData {
@@ -62,7 +63,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
style={
horizontalHandles
? { left: '-7px', top: '24px' }
? { left: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
: { top: '-7px', left: '50%', transform: 'translateX(-50%)' }
}
/>
@@ -122,7 +123,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
style={
horizontalHandles
? { right: '-7px', top: '24px' }
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
: { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' }
}
/>

View File

@@ -3,6 +3,7 @@
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
@@ -47,7 +48,11 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
position={Position.Left}
id='target'
className={handleClass}
style={{ left: '-7px', top: '20px', transform: 'translateY(-50%)' }}
style={{
left: '-7px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
{/* Header - matches actual subflow header */}
@@ -81,7 +86,11 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
position={Position.Right}
id={endHandleId}
className={handleClass}
style={{ right: '-7px', top: '20px', transform: 'translateY(-50%)' }}
style={{
right: '-7px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
</div>
)

View File

@@ -75,7 +75,6 @@ export const DEFAULT_LAYOUT_OPTIONS = {
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,
padding: DEFAULT_LAYOUT_PADDING,
alignment: 'center' as const,
}
/**
@@ -90,5 +89,4 @@ export const CONTAINER_LAYOUT_OPTIONS = {
horizontalSpacing: DEFAULT_CONTAINER_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,
padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y },
alignment: 'center' as const,
}

View File

@@ -28,16 +28,12 @@ export function layoutContainers(
): void {
const { children } = getBlocksByParent(blocks)
// Build container-specific layout options
// If horizontalSpacing provided, reduce by 15% for tighter container layout
// Otherwise use the default container spacing (400)
const containerOptions: LayoutOptions = {
horizontalSpacing: options.horizontalSpacing
? options.horizontalSpacing * 0.85
: DEFAULT_CONTAINER_HORIZONTAL_SPACING,
verticalSpacing: options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING,
padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y },
alignment: options.alignment,
}
for (const [parentId, childIds] of children.entries()) {

View File

@@ -10,12 +10,55 @@ import {
normalizePositions,
prepareBlockMetrics,
} from '@/lib/workflows/autolayout/utils'
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AutoLayout:Core')
/** Handle names that indicate edges from subflow end */
const SUBFLOW_END_HANDLES = new Set(['loop-end-source', 'parallel-end-source'])
const SUBFLOW_START_HANDLES = new Set(['loop-start-source', 'parallel-start-source'])
/**
* Calculates the Y offset for a source handle based on block type and handle ID.
*/
function getSourceHandleYOffset(block: BlockState, sourceHandle?: string | null): number {
if (sourceHandle === 'error') {
const blockHeight = block.height || BLOCK_DIMENSIONS.MIN_HEIGHT
return blockHeight - HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET
}
if (sourceHandle && SUBFLOW_START_HANDLES.has(sourceHandle)) {
return HANDLE_POSITIONS.SUBFLOW_START_Y_OFFSET
}
if (block.type === 'condition' && sourceHandle?.startsWith('condition-')) {
const conditionId = sourceHandle.replace('condition-', '')
try {
const conditionsValue = block.subBlocks?.conditions?.value
if (typeof conditionsValue === 'string' && conditionsValue) {
const conditions = JSON.parse(conditionsValue) as Array<{ id?: string }>
const conditionIndex = conditions.findIndex((c) => c.id === conditionId)
if (conditionIndex >= 0) {
return (
HANDLE_POSITIONS.CONDITION_START_Y +
conditionIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
)
}
}
} catch {
// Fall back to default offset
}
}
return HANDLE_POSITIONS.DEFAULT_Y_OFFSET
}
/**
* Calculates the Y offset for a target handle based on block type and handle ID.
*/
function getTargetHandleYOffset(_block: BlockState, _targetHandle?: string | null): number {
return HANDLE_POSITIONS.DEFAULT_Y_OFFSET
}
/**
* Checks if an edge comes from a subflow end handle
@@ -225,18 +268,36 @@ function resolveVerticalOverlaps(nodes: GraphNode[], verticalSpacing: number): v
}
}
/**
* Checks if a block is a container type (loop or parallel)
*/
function isContainerBlock(node: GraphNode): boolean {
return node.block.type === 'loop' || node.block.type === 'parallel'
}
/**
* Extra vertical spacing after containers to prevent edge crossings with sibling blocks.
* This creates clearance for edges from container ends to route cleanly.
*/
const CONTAINER_VERTICAL_CLEARANCE = 120
/**
* Calculates positions for nodes organized by layer.
* Uses cumulative width-based X positioning to properly handle containers of varying widths.
* Aligns blocks based on their connected predecessors to achieve handle-to-handle alignment.
*
* Handle alignment: Calculates actual source handle Y positions based on block type
* (condition blocks have handles at different heights for each branch).
* Target handles are also calculated per-block to ensure precise alignment.
*/
export function calculatePositions(
layers: Map<number, GraphNode[]>,
edges: Edge[],
options: LayoutOptions = {}
): void {
const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_LAYOUT_OPTIONS.horizontalSpacing
const verticalSpacing = options.verticalSpacing ?? DEFAULT_LAYOUT_OPTIONS.verticalSpacing
const padding = options.padding ?? DEFAULT_LAYOUT_OPTIONS.padding
const alignment = options.alignment ?? DEFAULT_LAYOUT_OPTIONS.alignment
const layerNumbers = Array.from(layers.keys()).sort((a, b) => a - b)
@@ -257,41 +318,89 @@ export function calculatePositions(
cumulativeX += layerWidths.get(layerNum)! + horizontalSpacing
}
// Position nodes using cumulative X
// Build a flat map of all nodes for quick lookups
const allNodes = new Map<string, GraphNode>()
for (const nodesInLayer of layers.values()) {
for (const node of nodesInLayer) {
allNodes.set(node.id, node)
}
}
// Build incoming edges map for handle lookups
const incomingEdgesMap = new Map<string, Edge[]>()
for (const edge of edges) {
if (!incomingEdgesMap.has(edge.target)) {
incomingEdgesMap.set(edge.target, [])
}
incomingEdgesMap.get(edge.target)!.push(edge)
}
// Position nodes layer by layer, aligning with connected predecessors
for (const layerNum of layerNumbers) {
const nodesInLayer = layers.get(layerNum)!
const xPosition = layerXPositions.get(layerNum)!
// Calculate total height for this layer
const totalHeight = nodesInLayer.reduce(
(sum, node, idx) => sum + node.metrics.height + (idx > 0 ? verticalSpacing : 0),
0
)
// Separate containers and non-containers
const containersInLayer = nodesInLayer.filter(isContainerBlock)
const nonContainersInLayer = nodesInLayer.filter((n) => !isContainerBlock(n))
// Start Y based on alignment
let yOffset: number
switch (alignment) {
case 'start':
yOffset = padding.y
break
case 'center':
yOffset = Math.max(padding.y, 300 - totalHeight / 2)
break
case 'end':
yOffset = 600 - totalHeight - padding.y
break
default:
yOffset = padding.y
break
// For the first layer (layer 0), position sequentially from padding.y
if (layerNum === 0) {
let yOffset = padding.y
// Sort containers by height for visual balance
containersInLayer.sort((a, b) => b.metrics.height - a.metrics.height)
for (const node of containersInLayer) {
node.position = { x: xPosition, y: yOffset }
yOffset += node.metrics.height + verticalSpacing
}
if (containersInLayer.length > 0 && nonContainersInLayer.length > 0) {
yOffset += CONTAINER_VERTICAL_CLEARANCE
}
// Sort non-containers by outgoing connections
nonContainersInLayer.sort((a, b) => b.outgoing.size - a.outgoing.size)
for (const node of nonContainersInLayer) {
node.position = { x: xPosition, y: yOffset }
yOffset += node.metrics.height + verticalSpacing
}
continue
}
// Position each node
for (const node of nodesInLayer) {
node.position = {
x: xPosition,
y: yOffset,
// For subsequent layers, align with connected predecessors (handle-to-handle)
for (const node of [...containersInLayer, ...nonContainersInLayer]) {
// Find the bottommost predecessor handle Y (highest value) and align to it
let bestSourceHandleY = -1
let bestEdge: Edge | null = null
const incomingEdges = incomingEdgesMap.get(node.id) || []
for (const edge of incomingEdges) {
const predecessor = allNodes.get(edge.source)
if (predecessor) {
// Calculate actual source handle Y position based on block type and handle
const sourceHandleOffset = getSourceHandleYOffset(predecessor.block, edge.sourceHandle)
const sourceHandleY = predecessor.position.y + sourceHandleOffset
if (sourceHandleY > bestSourceHandleY) {
bestSourceHandleY = sourceHandleY
bestEdge = edge
}
}
}
yOffset += node.metrics.height + verticalSpacing
// If no predecessors found (shouldn't happen for layer > 0), use padding
if (bestSourceHandleY < 0) {
bestSourceHandleY = padding.y + HANDLE_POSITIONS.DEFAULT_Y_OFFSET
}
// Calculate the target handle Y offset for this node
const targetHandleOffset = getTargetHandleYOffset(node.block, bestEdge?.targetHandle)
// Position node so its target handle aligns with the source handle Y
node.position = { x: xPosition, y: bestSourceHandleY - targetHandleOffset }
}
}
@@ -338,8 +447,8 @@ export function layoutBlocksCore(
// 3. Group by layer
const layers = groupByLayer(nodes)
// 4. Calculate positions
calculatePositions(layers, layoutOptions)
// 4. Calculate positions (pass edges for handle offset calculations)
calculatePositions(layers, edges, layoutOptions)
// 5. Normalize positions
const dimensions = normalizePositions(nodes, { isContainer: options.isContainer })

View File

@@ -228,7 +228,6 @@ function computeLayoutPositions(
layoutOptions: {
horizontalSpacing: isContainer ? horizontalSpacing * 0.85 : horizontalSpacing,
verticalSpacing,
alignment: 'center',
},
subflowDepths,
})

View File

@@ -4,7 +4,6 @@ export interface LayoutOptions {
horizontalSpacing?: number
verticalSpacing?: number
padding?: { x: number; y: number }
alignment?: 'start' | 'center' | 'end'
}
export interface LayoutResult {

View File

@@ -329,7 +329,6 @@ export type LayoutFunction = (
horizontalSpacing?: number
verticalSpacing?: number
padding?: { x: number; y: number }
alignment?: 'start' | 'center' | 'end'
}
subflowDepths?: Map<string, number>
}
@@ -418,7 +417,6 @@ export function prepareContainerDimensions(
layoutOptions: {
horizontalSpacing: horizontalSpacing * 0.85,
verticalSpacing,
alignment: 'center',
},
})

View File

@@ -2,56 +2,42 @@
* Shared Block Dimension Constants
*
* Single source of truth for block dimensions used by:
* - UI components (workflow-block, note-block)
* - UI components (workflow-block, note-block, subflow-node)
* - Autolayout system
* - Node utilities
*
* IMPORTANT: These values must match the actual CSS dimensions in the UI.
* Changing these values will affect both rendering and layout calculations.
*/
/**
* Block dimension constants for workflow blocks
*/
export const BLOCK_DIMENSIONS = {
/** Fixed width for all workflow blocks (matches w-[250px] in workflow-block.tsx) */
FIXED_WIDTH: 250,
/** Header height for blocks */
HEADER_HEIGHT: 40,
/** Minimum height for blocks */
MIN_HEIGHT: 100,
/** Padding around workflow block content (p-[8px] top + bottom = 16px) */
WORKFLOW_CONTENT_PADDING: 16,
/** Height of each subblock row (14px text + 8px gap + padding) */
WORKFLOW_ROW_HEIGHT: 29,
/** Padding around note block content */
NOTE_CONTENT_PADDING: 14,
/** Minimum content height for note blocks */
NOTE_MIN_CONTENT_HEIGHT: 20,
/** Base content height for note blocks */
NOTE_BASE_CONTENT_HEIGHT: 60,
} as const
/**
* Container block dimension constants (loop, parallel, subflow)
*/
export const CONTAINER_DIMENSIONS = {
/** Default width for container blocks */
DEFAULT_WIDTH: 500,
/** Default height for container blocks */
DEFAULT_HEIGHT: 300,
/** Minimum width for container blocks */
MIN_WIDTH: 400,
/** Minimum height for container blocks */
MIN_HEIGHT: 200,
HEADER_HEIGHT: 50,
} as const
/**
* Handle position constants - must match CSS in workflow-block.tsx and subflow-node.tsx
*/
export const HANDLE_POSITIONS = {
/** Default Y offset from block top for source/target handles */
DEFAULT_Y_OFFSET: 20,
/** Error handle offset from block bottom */
ERROR_BOTTOM_OFFSET: 17,
/** Condition handle starting Y offset */
CONDITION_START_Y: 60,
/** Height per condition row */
CONDITION_ROW_HEIGHT: 29,
/** Subflow start handle Y offset (header 50px + pill offset 16px + pill center 14px) */
SUBFLOW_START_Y_OFFSET: 80,
} as const