Skip to content

Commit 2a7f51a

Browse files
Pbonmars-20031006priyanshu.solanki
andauthored
adding clamps for subflow drag and drops of blocks (#2460)
Co-authored-by: priyanshu.solanki <priyanshu.solanki@saviynt.com>
1 parent 90c3c43 commit 2a7f51a

File tree

2 files changed

+142
-53
lines changed

2 files changed

+142
-53
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts

Lines changed: 93 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,61 @@ import { getBlock } from '@/blocks/registry'
66

77
const logger = createLogger('NodeUtilities')
88

9+
/**
10+
* Estimates block dimensions based on block type.
11+
* Uses subblock count to estimate height for blocks that haven't been measured yet.
12+
*
13+
* @param blockType - The type of block (e.g., 'condition', 'agent')
14+
* @returns Estimated width and height for the block
15+
*/
16+
export function estimateBlockDimensions(blockType: string): { width: number; height: number } {
17+
const blockConfig = getBlock(blockType)
18+
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
19+
// Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.)
20+
// Use roughly half the config count as a reasonable estimate, capped between 3-7 rows
21+
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
22+
const hasErrorRow = blockType !== 'starter' && blockType !== 'response' ? 1 : 0
23+
24+
const height =
25+
BLOCK_DIMENSIONS.HEADER_HEIGHT +
26+
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
27+
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
28+
29+
return {
30+
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
31+
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
32+
}
33+
}
34+
35+
/**
36+
* Clamps a position to keep a block fully inside a container's content area.
37+
* Content area starts after the header and padding, and ends before the right/bottom padding.
38+
*
39+
* @param position - Raw position relative to container origin
40+
* @param containerDimensions - Container width and height
41+
* @param blockDimensions - Block width and height
42+
* @returns Clamped position that keeps block inside content area
43+
*/
44+
export function clampPositionToContainer(
45+
position: { x: number; y: number },
46+
containerDimensions: { width: number; height: number },
47+
blockDimensions: { width: number; height: number }
48+
): { x: number; y: number } {
49+
const { width: containerWidth, height: containerHeight } = containerDimensions
50+
const { width: blockWidth, height: blockHeight } = blockDimensions
51+
52+
// Content area bounds (where blocks can be placed)
53+
const minX = CONTAINER_DIMENSIONS.LEFT_PADDING
54+
const minY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
55+
const maxX = containerWidth - CONTAINER_DIMENSIONS.RIGHT_PADDING - blockWidth
56+
const maxY = containerHeight - CONTAINER_DIMENSIONS.BOTTOM_PADDING - blockHeight
57+
58+
return {
59+
x: Math.max(minX, Math.min(position.x, Math.max(minX, maxX))),
60+
y: Math.max(minY, Math.min(position.y, Math.max(minY, maxY))),
61+
}
62+
}
63+
964
/**
1065
* Hook providing utilities for node position, hierarchy, and dimension calculations
1166
*/
@@ -21,7 +76,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
2176

2277
/**
2378
* Get the dimensions of a block.
24-
* For regular blocks, estimates height based on block config if not yet measured.
79+
* For regular blocks, uses stored height or estimates based on block config.
2580
*/
2681
const getBlockDimensions = useCallback(
2782
(blockId: string): { width: number; height: number } => {
@@ -41,32 +96,16 @@ export function useNodeUtilities(blocks: Record<string, any>) {
4196
}
4297
}
4398

44-
// Workflow block nodes have fixed visual width
45-
const width = BLOCK_DIMENSIONS.FIXED_WIDTH
46-
4799
// Prefer deterministic height published by the block component; fallback to estimate
48-
let height = block.height
49-
50-
if (!height) {
51-
// Estimate height based on block config's subblock count for more accurate initial sizing
52-
// This is critical for subflow containers to size correctly before child blocks are measured
53-
const blockConfig = getBlock(block.type)
54-
const subBlockCount = blockConfig?.subBlocks?.length ?? 3
55-
// Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.)
56-
// Use roughly half the config count as a reasonable estimate, capped between 3-7 rows
57-
const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7))
58-
const hasErrorRow = block.type !== 'starter' && block.type !== 'response' ? 1 : 0
59-
60-
height =
61-
BLOCK_DIMENSIONS.HEADER_HEIGHT +
62-
BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
63-
(estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
100+
if (block.height) {
101+
return {
102+
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
103+
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
104+
}
64105
}
65106

66-
return {
67-
width,
68-
height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT),
69-
}
107+
// Use shared estimation utility for blocks without measured height
108+
return estimateBlockDimensions(block.type)
70109
},
71110
[blocks, isContainerType]
72111
)
@@ -164,29 +203,36 @@ export function useNodeUtilities(blocks: Record<string, any>) {
164203
)
165204

166205
/**
167-
* Calculates the relative position of a node to a new parent's content area.
168-
* Accounts for header height and padding offsets in container nodes.
206+
* Calculates the relative position of a node to a new parent's origin.
207+
* React Flow positions children relative to parent origin, so we clamp
208+
* to the content area bounds (after header and padding).
169209
* @param nodeId ID of the node being repositioned
170210
* @param newParentId ID of the new parent
171-
* @returns Relative position coordinates {x, y} within the parent's content area
211+
* @returns Relative position coordinates {x, y} within the parent
172212
*/
173213
const calculateRelativePosition = useCallback(
174214
(nodeId: string, newParentId: string): { x: number; y: number } => {
175215
const nodeAbsPos = getNodeAbsolutePosition(nodeId)
176216
const parentAbsPos = getNodeAbsolutePosition(newParentId)
217+
const parentNode = getNodes().find((n) => n.id === newParentId)
177218

178-
// Account for container's header and padding
179-
// Children are positioned relative to content area, not container origin
180-
const headerHeight = 50
181-
const leftPadding = 16
182-
const topPadding = 16
219+
// Calculate raw relative position (relative to parent origin)
220+
const rawPosition = {
221+
x: nodeAbsPos.x - parentAbsPos.x,
222+
y: nodeAbsPos.y - parentAbsPos.y,
223+
}
183224

184-
return {
185-
x: nodeAbsPos.x - parentAbsPos.x - leftPadding,
186-
y: nodeAbsPos.y - parentAbsPos.y - headerHeight - topPadding,
225+
// Get container and block dimensions
226+
const containerDimensions = {
227+
width: parentNode?.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
228+
height: parentNode?.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
187229
}
230+
const blockDimensions = getBlockDimensions(nodeId)
231+
232+
// Clamp position to keep block inside content area
233+
return clampPositionToContainer(rawPosition, containerDimensions, blockDimensions)
188234
},
189-
[getNodeAbsolutePosition]
235+
[getNodeAbsolutePosition, getNodes, getBlockDimensions]
190236
)
191237

192238
/**
@@ -252,7 +298,11 @@ export function useNodeUtilities(blocks: Record<string, any>) {
252298
*/
253299
const calculateLoopDimensions = useCallback(
254300
(nodeId: string): { width: number; height: number } => {
255-
const childNodes = getNodes().filter((node) => node.parentId === nodeId)
301+
// Check both React Flow's node.parentId AND blocks store's data.parentId
302+
// This ensures we catch children even if React Flow hasn't re-rendered yet
303+
const childNodes = getNodes().filter(
304+
(node) => node.parentId === nodeId || blocks[node.id]?.data?.parentId === nodeId
305+
)
256306
if (childNodes.length === 0) {
257307
return {
258308
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
@@ -265,8 +315,11 @@ export function useNodeUtilities(blocks: Record<string, any>) {
265315

266316
childNodes.forEach((node) => {
267317
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
268-
maxRight = Math.max(maxRight, node.position.x + nodeWidth)
269-
maxBottom = Math.max(maxBottom, node.position.y + nodeHeight)
318+
// Use block position from store if available (more up-to-date)
319+
const block = blocks[node.id]
320+
const position = block?.position || node.position
321+
maxRight = Math.max(maxRight, position.x + nodeWidth)
322+
maxBottom = Math.max(maxBottom, position.y + nodeHeight)
270323
})
271324

272325
const width = Math.max(
@@ -283,7 +336,7 @@ export function useNodeUtilities(blocks: Record<string, any>) {
283336

284337
return { width, height }
285338
},
286-
[getNodes, getBlockDimensions]
339+
[getNodes, getBlockDimensions, blocks]
287340
)
288341

289342
/**

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { useShallow } from 'zustand/react/shallow'
1818
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
1919
import { createLogger } from '@/lib/logs/console/logger'
2020
import type { OAuthProvider } from '@/lib/oauth'
21-
import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
21+
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
2222
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
2323
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2424
import {
@@ -40,6 +40,10 @@ import {
4040
useCurrentWorkflow,
4141
useNodeUtilities,
4242
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
43+
import {
44+
clampPositionToContainer,
45+
estimateBlockDimensions,
46+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
4347
import { useSocket } from '@/app/workspace/providers/socket-provider'
4448
import { getBlock } from '@/blocks'
4549
import { isAnnotationOnlyBlock } from '@/executor/constants'
@@ -694,17 +698,19 @@ const WorkflowContent = React.memo(() => {
694698
return
695699
}
696700

697-
// Calculate position relative to the container's content area
698-
// Account for header (50px), left padding (16px), and top padding (16px)
699-
const headerHeight = 50
700-
const leftPadding = 16
701-
const topPadding = 16
702-
703-
const relativePosition = {
704-
x: position.x - containerInfo.loopPosition.x - leftPadding,
705-
y: position.y - containerInfo.loopPosition.y - headerHeight - topPadding,
701+
// Calculate raw position relative to container origin
702+
const rawPosition = {
703+
x: position.x - containerInfo.loopPosition.x,
704+
y: position.y - containerInfo.loopPosition.y,
706705
}
707706

707+
// Clamp position to keep block inside container's content area
708+
const relativePosition = clampPositionToContainer(
709+
rawPosition,
710+
containerInfo.dimensions,
711+
estimateBlockDimensions(data.type)
712+
)
713+
708714
// Capture existing child blocks before adding the new one
709715
const existingChildBlocks = Object.values(blocks).filter(
710716
(b) => b.data?.parentId === containerInfo.loopId
@@ -1910,17 +1916,47 @@ const WorkflowContent = React.memo(() => {
19101916
})
19111917
document.body.style.cursor = ''
19121918

1919+
// Get the block's current parent (if any)
1920+
const currentBlock = blocks[node.id]
1921+
const currentParentId = currentBlock?.data?.parentId
1922+
1923+
// Calculate position - clamp if inside a container
1924+
let finalPosition = node.position
1925+
if (currentParentId) {
1926+
// Block is inside a container - clamp position to keep it fully inside
1927+
const parentNode = getNodes().find((n) => n.id === currentParentId)
1928+
if (parentNode) {
1929+
const containerDimensions = {
1930+
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
1931+
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
1932+
}
1933+
const blockDimensions = {
1934+
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
1935+
height: Math.max(
1936+
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
1937+
BLOCK_DIMENSIONS.MIN_HEIGHT
1938+
),
1939+
}
1940+
1941+
finalPosition = clampPositionToContainer(
1942+
node.position,
1943+
containerDimensions,
1944+
blockDimensions
1945+
)
1946+
}
1947+
}
1948+
19131949
// Emit collaborative position update for the final position
19141950
// This ensures other users see the smooth final position
1915-
collaborativeUpdateBlockPosition(node.id, node.position, true)
1951+
collaborativeUpdateBlockPosition(node.id, finalPosition, true)
19161952

19171953
// Record single move entry on drag end to avoid micro-moves
19181954
const start = getDragStartPosition()
19191955
if (start && start.id === node.id) {
19201956
const before = { x: start.x, y: start.y, parentId: start.parentId }
19211957
const after = {
1922-
x: node.position.x,
1923-
y: node.position.y,
1958+
x: finalPosition.x,
1959+
y: finalPosition.y,
19241960
parentId: node.parentId || blocks[node.id]?.data?.parentId,
19251961
}
19261962
const moved =

0 commit comments

Comments
 (0)