mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
adding clamps for subflow drag and drops of blocks (#2460)
Co-authored-by: priyanshu.solanki <priyanshu.solanki@saviynt.com>
This commit is contained in:
committed by
GitHub
parent
90c3c43607
commit
2a7f51a2f6
@@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user