adding clamps for subflow drag and drops of blocks (#2460)

Co-authored-by: priyanshu.solanki <priyanshu.solanki@saviynt.com>
This commit is contained in:
Priyanshu Solanki
2025-12-18 16:57:58 -07:00
committed by GitHub
parent 90c3c43607
commit 2a7f51a2f6
2 changed files with 143 additions and 54 deletions

View File

@@ -6,6 +6,61 @@ import { getBlock } from '@/blocks/registry'
const logger = createLogger('NodeUtilities') const logger = createLogger('NodeUtilities')
/**
* Estimates block dimensions based on block type.
* Uses subblock count to estimate height for blocks that haven't been measured yet.
*
* @param blockType - The type of block (e.g., 'condition', 'agent')
* @returns Estimated width and height for the block
*/
export function estimateBlockDimensions(blockType: string): { width: number; height: number } {
const blockConfig = getBlock(blockType)
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
// Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.)
// Use roughly half the config count as a reasonable estimate, capped between 3-7 rows
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
const hasErrorRow = blockType !== 'starter' && blockType !== 'response' ? 1 : 0
const height =
BLOCK_DIMENSIONS.HEADER_HEIGHT +
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}
/**
* Clamps a position to keep a block fully inside a container's content area.
* Content area starts after the header and padding, and ends before the right/bottom padding.
*
* @param position - Raw position relative to container origin
* @param containerDimensions - Container width and height
* @param blockDimensions - Block width and height
* @returns Clamped position that keeps block inside content area
*/
export function clampPositionToContainer(
position: { x: number; y: number },
containerDimensions: { width: number; height: number },
blockDimensions: { width: number; height: number }
): { x: number; y: number } {
const { width: containerWidth, height: containerHeight } = containerDimensions
const { width: blockWidth, height: blockHeight } = blockDimensions
// Content area bounds (where blocks can be placed)
const minX = CONTAINER_DIMENSIONS.LEFT_PADDING
const minY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
const maxX = containerWidth - CONTAINER_DIMENSIONS.RIGHT_PADDING - blockWidth
const maxY = containerHeight - CONTAINER_DIMENSIONS.BOTTOM_PADDING - blockHeight
return {
x: Math.max(minX, Math.min(position.x, Math.max(minX, maxX))),
y: Math.max(minY, Math.min(position.y, Math.max(minY, maxY))),
}
}
/** /**
* Hook providing utilities for node position, hierarchy, and dimension calculations * Hook providing utilities for node position, hierarchy, and dimension calculations
*/ */
@@ -21,7 +76,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
/** /**
* Get the dimensions of a block. * Get the dimensions of a block.
* For regular blocks, estimates height based on block config if not yet measured. * For regular blocks, uses stored height or estimates based on block config.
*/ */
const getBlockDimensions = useCallback( const getBlockDimensions = useCallback(
(blockId: string): { width: number; height: number } => { (blockId: string): { width: number; height: number } => {
@@ -41,32 +96,16 @@ export function useNodeUtilities(blocks: Record<string, any>) {
} }
} }
// Workflow block nodes have fixed visual width
const width = BLOCK_DIMENSIONS.FIXED_WIDTH
// Prefer deterministic height published by the block component; fallback to estimate // Prefer deterministic height published by the block component; fallback to estimate
let height = block.height if (block.height) {
return {
if (!height) { width: BLOCK_DIMENSIONS.FIXED_WIDTH,
// Estimate height based on block config's subblock count for more accurate initial sizing height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
// This is critical for subflow containers to size correctly before child blocks are measured }
const blockConfig = getBlock(block.type)
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
// Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.)
// Use roughly half the config count as a reasonable estimate, capped between 3-7 rows
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
const hasErrorRow = block.type !== 'starter' && block.type !== 'response' ? 1 : 0
height =
BLOCK_DIMENSIONS.HEADER_HEIGHT +
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
} }
return { // Use shared estimation utility for blocks without measured height
width, return estimateBlockDimensions(block.type)
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}, },
[blocks, isContainerType] [blocks, isContainerType]
) )
@@ -164,29 +203,36 @@ export function useNodeUtilities(blocks: Record<string, any>) {
) )
/** /**
* Calculates the relative position of a node to a new parent's content area. * Calculates the relative position of a node to a new parent's origin.
* Accounts for header height and padding offsets in container nodes. * React Flow positions children relative to parent origin, so we clamp
* to the content area bounds (after header and padding).
* @param nodeId ID of the node being repositioned * @param nodeId ID of the node being repositioned
* @param newParentId ID of the new parent * @param newParentId ID of the new parent
* @returns Relative position coordinates {x, y} within the parent's content area * @returns Relative position coordinates {x, y} within the parent
*/ */
const calculateRelativePosition = useCallback( const calculateRelativePosition = useCallback(
(nodeId: string, newParentId: string): { x: number; y: number } => { (nodeId: string, newParentId: string): { x: number; y: number } => {
const nodeAbsPos = getNodeAbsolutePosition(nodeId) const nodeAbsPos = getNodeAbsolutePosition(nodeId)
const parentAbsPos = getNodeAbsolutePosition(newParentId) const parentAbsPos = getNodeAbsolutePosition(newParentId)
const parentNode = getNodes().find((n) => n.id === newParentId)
// Account for container's header and padding // Calculate raw relative position (relative to parent origin)
// Children are positioned relative to content area, not container origin const rawPosition = {
const headerHeight = 50 x: nodeAbsPos.x - parentAbsPos.x,
const leftPadding = 16 y: nodeAbsPos.y - parentAbsPos.y,
const topPadding = 16
return {
x: nodeAbsPos.x - parentAbsPos.x - leftPadding,
y: nodeAbsPos.y - parentAbsPos.y - headerHeight - topPadding,
} }
// Get container and block dimensions
const containerDimensions = {
width: parentNode?.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: parentNode?.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
const blockDimensions = getBlockDimensions(nodeId)
// Clamp position to keep block inside content area
return clampPositionToContainer(rawPosition, containerDimensions, blockDimensions)
}, },
[getNodeAbsolutePosition] [getNodeAbsolutePosition, getNodes, getBlockDimensions]
) )
/** /**
@@ -252,7 +298,11 @@ export function useNodeUtilities(blocks: Record<string, any>) {
*/ */
const calculateLoopDimensions = useCallback( const calculateLoopDimensions = useCallback(
(nodeId: string): { width: number; height: number } => { (nodeId: string): { width: number; height: number } => {
const childNodes = getNodes().filter((node) => node.parentId === nodeId) // Check both React Flow's node.parentId AND blocks store's data.parentId
// This ensures we catch children even if React Flow hasn't re-rendered yet
const childNodes = getNodes().filter(
(node) => node.parentId === nodeId || blocks[node.id]?.data?.parentId === nodeId
)
if (childNodes.length === 0) { if (childNodes.length === 0) {
return { return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
@@ -265,8 +315,11 @@ export function useNodeUtilities(blocks: Record<string, any>) {
childNodes.forEach((node) => { childNodes.forEach((node) => {
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id) const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
maxRight = Math.max(maxRight, node.position.x + nodeWidth) // Use block position from store if available (more up-to-date)
maxBottom = Math.max(maxBottom, node.position.y + nodeHeight) const block = blocks[node.id]
const position = block?.position || node.position
maxRight = Math.max(maxRight, position.x + nodeWidth)
maxBottom = Math.max(maxBottom, position.y + nodeHeight)
}) })
const width = Math.max( const width = Math.max(
@@ -283,7 +336,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
return { width, height } return { width, height }
}, },
[getNodes, getBlockDimensions] [getNodes, getBlockDimensions, blocks]
) )
/** /**

View File

@@ -18,7 +18,7 @@ import { useShallow } from 'zustand/react/shallow'
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access' import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import type { OAuthProvider } from '@/lib/oauth' import type { OAuthProvider } from '@/lib/oauth'
import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { import {
@@ -40,6 +40,10 @@ import {
useCurrentWorkflow, useCurrentWorkflow,
useNodeUtilities, useNodeUtilities,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
clampPositionToContainer,
estimateBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
import { useSocket } from '@/app/workspace/providers/socket-provider' import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock } from '@/executor/constants' import { isAnnotationOnlyBlock } from '@/executor/constants'
@@ -694,17 +698,19 @@ const WorkflowContent = React.memo(() => {
return return
} }
// Calculate position relative to the container's content area // Calculate raw position relative to container origin
// Account for header (50px), left padding (16px), and top padding (16px) const rawPosition = {
const headerHeight = 50 x: position.x - containerInfo.loopPosition.x,
const leftPadding = 16 y: position.y - containerInfo.loopPosition.y,
const topPadding = 16
const relativePosition = {
x: position.x - containerInfo.loopPosition.x - leftPadding,
y: position.y - containerInfo.loopPosition.y - headerHeight - topPadding,
} }
// Clamp position to keep block inside container's content area
const relativePosition = clampPositionToContainer(
rawPosition,
containerInfo.dimensions,
estimateBlockDimensions(data.type)
)
// Capture existing child blocks before adding the new one // Capture existing child blocks before adding the new one
const existingChildBlocks = Object.values(blocks).filter( const existingChildBlocks = Object.values(blocks).filter(
(b) => b.data?.parentId === containerInfo.loopId (b) => b.data?.parentId === containerInfo.loopId
@@ -1910,17 +1916,47 @@ const WorkflowContent = React.memo(() => {
}) })
document.body.style.cursor = '' document.body.style.cursor = ''
// Get the block's current parent (if any)
const currentBlock = blocks[node.id]
const currentParentId = currentBlock?.data?.parentId
// Calculate position - clamp if inside a container
let finalPosition = node.position
if (currentParentId) {
// Block is inside a container - clamp position to keep it fully inside
const parentNode = getNodes().find((n) => n.id === currentParentId)
if (parentNode) {
const containerDimensions = {
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
const blockDimensions = {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
BLOCK_DIMENSIONS.MIN_HEIGHT
),
}
finalPosition = clampPositionToContainer(
node.position,
containerDimensions,
blockDimensions
)
}
}
// Emit collaborative position update for the final position // Emit collaborative position update for the final position
// This ensures other users see the smooth final position // This ensures other users see the smooth final position
collaborativeUpdateBlockPosition(node.id, node.position, true) collaborativeUpdateBlockPosition(node.id, finalPosition, true)
// Record single move entry on drag end to avoid micro-moves // Record single move entry on drag end to avoid micro-moves
const start = getDragStartPosition() const start = getDragStartPosition()
if (start && start.id === node.id) { if (start && start.id === node.id) {
const before = { x: start.x, y: start.y, parentId: start.parentId } const before = { x: start.x, y: start.y, parentId: start.parentId }
const after = { const after = {
x: node.position.x, x: finalPosition.x,
y: node.position.y, y: finalPosition.y,
parentId: node.parentId || blocks[node.id]?.data?.parentId, parentId: node.parentId || blocks[node.id]?.data?.parentId,
} }
const moved = const moved =