mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(autolayout): align by handle (#2277)
* fix(autolayout): align by handle * use shared constants everywhere * cleanup
This commit is contained in:
committed by
GitHub
parent
306043eedb
commit
dd7db6e144
@@ -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%)' }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%)' }
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -228,7 +228,6 @@ function computeLayoutPositions(
|
||||
layoutOptions: {
|
||||
horizontalSpacing: isContainer ? horizontalSpacing * 0.85 : horizontalSpacing,
|
||||
verticalSpacing,
|
||||
alignment: 'center',
|
||||
},
|
||||
subflowDepths,
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ export interface LayoutOptions {
|
||||
horizontalSpacing?: number
|
||||
verticalSpacing?: number
|
||||
padding?: { x: number; y: number }
|
||||
alignment?: 'start' | 'center' | 'end'
|
||||
}
|
||||
|
||||
export interface LayoutResult {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user