Skip to content

Commit 5e1c838

Browse files
committed
fix(resize): fix subflow resize on drag, children deselected in subflow on drag
1 parent e347486 commit 5e1c838

File tree

4 files changed

+127
-72
lines changed

4 files changed

+127
-72
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {
44
computeParentUpdateEntries,
55
getClampedPositionForNode,
66
isInEditableElement,
7+
resolveParentChildSelectionConflicts,
78
selectNodesDeferred,
89
validateTriggerPaste,
910
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
@@ -12,7 +13,7 @@ export { useAutoLayout } from './use-auto-layout'
1213
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
1314
export { useBlockVisual } from './use-block-visual'
1415
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
15-
export { useNodeUtilities } from './use-node-utilities'
16+
export { calculateContainerDimensions, useNodeUtilities } from './use-node-utilities'
1617
export { usePreventZoom } from './use-prevent-zoom'
1718
export { useScrollManagement } from './use-scroll-management'
1819
export { useWorkflowExecution } from './use-workflow-execution'

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

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,47 @@ export function clampPositionToContainer(
6262
}
6363
}
6464

65+
/**
66+
* Calculates container dimensions based on child block positions.
67+
* Single source of truth for container sizing - ensures consistency between
68+
* live drag updates and final dimension calculations.
69+
*
70+
* @param childPositions - Array of child positions with their dimensions
71+
* @returns Calculated width and height for the container
72+
*/
73+
export function calculateContainerDimensions(
74+
childPositions: Array<{ x: number; y: number; width: number; height: number }>
75+
): { width: number; height: number } {
76+
if (childPositions.length === 0) {
77+
return {
78+
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
79+
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
80+
}
81+
}
82+
83+
let maxRight = 0
84+
let maxBottom = 0
85+
86+
for (const child of childPositions) {
87+
maxRight = Math.max(maxRight, child.x + child.width)
88+
maxBottom = Math.max(maxBottom, child.y + child.height)
89+
}
90+
91+
const width = Math.max(
92+
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
93+
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
94+
)
95+
const height = Math.max(
96+
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
97+
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
98+
CONTAINER_DIMENSIONS.TOP_PADDING +
99+
maxBottom +
100+
CONTAINER_DIMENSIONS.BOTTOM_PADDING
101+
)
102+
103+
return { width, height }
104+
}
105+
65106
/**
66107
* Hook providing utilities for node position, hierarchy, and dimension calculations
67108
*/
@@ -306,36 +347,16 @@ export function useNodeUtilities(blocks: Record<string, any>) {
306347
(id) => currentBlocks[id]?.data?.parentId === nodeId
307348
)
308349

309-
if (childBlockIds.length === 0) {
310-
return {
311-
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
312-
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
313-
}
314-
}
315-
316-
let maxRight = 0
317-
let maxBottom = 0
318-
319-
for (const childId of childBlockIds) {
320-
const child = currentBlocks[childId]
321-
if (!child?.position) continue
322-
323-
const { width: childWidth, height: childHeight } = getBlockDimensions(childId)
324-
325-
maxRight = Math.max(maxRight, child.position.x + childWidth)
326-
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
327-
}
328-
329-
const width = Math.max(
330-
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
331-
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
332-
)
333-
const height = Math.max(
334-
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
335-
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
336-
)
350+
const childPositions = childBlockIds
351+
.map((childId) => {
352+
const child = currentBlocks[childId]
353+
if (!child?.position) return null
354+
const { width, height } = getBlockDimensions(childId)
355+
return { x: child.position.x, y: child.position.y, width, height }
356+
})
357+
.filter((p): p is NonNullable<typeof p> => p !== null)
337358

338-
return { width, height }
359+
return calculateContainerDimensions(childPositions)
339360
},
340361
[getBlockDimensions]
341362
)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,19 +70,22 @@ export function clearDragHighlights(): void {
7070
* Defers selection to next animation frame to allow displayNodes to sync from store first.
7171
* This is necessary because the component uses controlled state (nodes={displayNodes})
7272
* and newly added blocks need time to propagate through the store → derivedNodes → displayNodes cycle.
73+
* Automatically resolves parent-child selection conflicts to prevent wiggle during drag.
7374
*/
7475
export function selectNodesDeferred(
7576
nodeIds: string[],
76-
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void
77+
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void,
78+
blocks: Record<string, { data?: { parentId?: string } }>
7779
): void {
7880
const idsSet = new Set(nodeIds)
7981
requestAnimationFrame(() => {
80-
setDisplayNodes((nodes) =>
81-
nodes.map((node) => ({
82+
setDisplayNodes((nodes) => {
83+
const withSelection = nodes.map((node) => ({
8284
...node,
8385
selected: idsSet.has(node.id),
8486
}))
85-
)
87+
return resolveParentChildSelectionConflicts(withSelection, blocks)
88+
})
8689
})
8790
}
8891

@@ -186,3 +189,26 @@ export function computeParentUpdateEntries(
186189
}
187190
})
188191
}
192+
193+
/**
194+
* Resolves parent-child selection conflicts by deselecting children whose parent is also selected.
195+
*/
196+
export function resolveParentChildSelectionConflicts(
197+
nodes: Node[],
198+
blocks: Record<string, { data?: { parentId?: string } }>
199+
): Node[] {
200+
const selectedIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id))
201+
202+
let hasConflict = false
203+
const resolved = nodes.map((n) => {
204+
if (!n.selected) return n
205+
const parentId = n.parentId || n.data?.parentId || blocks[n.id]?.data?.parentId
206+
if (parentId && selectedIds.has(parentId)) {
207+
hasConflict = true
208+
return { ...n, selected: false }
209+
}
210+
return n
211+
})
212+
213+
return hasConflict ? resolved : nodes
214+
}

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

Lines changed: 45 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
computeClampedPositionUpdates,
4848
getClampedPositionForNode,
4949
isInEditableElement,
50+
resolveParentChildSelectionConflicts,
5051
selectNodesDeferred,
5152
useAutoLayout,
5253
useCurrentWorkflow,
@@ -55,6 +56,7 @@ import {
5556
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
5657
import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu'
5758
import {
59+
calculateContainerDimensions,
5860
clampPositionToContainer,
5961
estimateBlockDimensions,
6062
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
@@ -697,7 +699,8 @@ const WorkflowContent = React.memo(() => {
697699

698700
selectNodesDeferred(
699701
pastedBlocksArray.map((b) => b.id),
700-
setDisplayNodes
702+
setDisplayNodes,
703+
blocks
701704
)
702705
}, [
703706
hasClipboard,
@@ -745,7 +748,8 @@ const WorkflowContent = React.memo(() => {
745748

746749
selectNodesDeferred(
747750
pastedBlocksArray.map((b) => b.id),
748-
setDisplayNodes
751+
setDisplayNodes,
752+
blocks
749753
)
750754
}, [
751755
contextMenuBlocks,
@@ -890,7 +894,8 @@ const WorkflowContent = React.memo(() => {
890894

891895
selectNodesDeferred(
892896
pastedBlocks.map((b) => b.id),
893-
setDisplayNodes
897+
setDisplayNodes,
898+
blocks
894899
)
895900
}
896901
}
@@ -2037,10 +2042,19 @@ const WorkflowContent = React.memo(() => {
20372042
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
20382043
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
20392044

2040-
/** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */
2041-
const onNodesChange = useCallback((changes: NodeChange[]) => {
2042-
setDisplayNodes((nds) => applyNodeChanges(changes, nds))
2043-
}, [])
2045+
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
2046+
const onNodesChange = useCallback(
2047+
(changes: NodeChange[]) => {
2048+
setDisplayNodes((nds) => {
2049+
const updated = applyNodeChanges(changes, nds)
2050+
const hasSelectionChange = changes.some(
2051+
(c) => c.type === 'select' && (c as { selected?: boolean }).selected
2052+
)
2053+
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
2054+
})
2055+
},
2056+
[blocks]
2057+
)
20442058

20452059
/**
20462060
* Updates container dimensions in displayNodes during drag.
@@ -2055,28 +2069,13 @@ const WorkflowContent = React.memo(() => {
20552069
const childNodes = currentNodes.filter((n) => n.parentId === parentId)
20562070
if (childNodes.length === 0) return currentNodes
20572071

2058-
let maxRight = 0
2059-
let maxBottom = 0
2060-
2061-
childNodes.forEach((node) => {
2072+
const childPositions = childNodes.map((node) => {
20622073
const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position
2063-
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
2064-
2065-
maxRight = Math.max(maxRight, nodePosition.x + nodeWidth)
2066-
maxBottom = Math.max(maxBottom, nodePosition.y + nodeHeight)
2074+
const { width, height } = getBlockDimensions(node.id)
2075+
return { x: nodePosition.x, y: nodePosition.y, width, height }
20672076
})
20682077

2069-
const newWidth = Math.max(
2070-
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
2071-
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
2072-
)
2073-
const newHeight = Math.max(
2074-
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
2075-
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
2076-
CONTAINER_DIMENSIONS.TOP_PADDING +
2077-
maxBottom +
2078-
CONTAINER_DIMENSIONS.BOTTOM_PADDING
2079-
)
2078+
const { width: newWidth, height: newHeight } = calculateContainerDimensions(childPositions)
20802079

20812080
return currentNodes.map((node) => {
20822081
if (node.id === parentId) {
@@ -2844,27 +2843,38 @@ const WorkflowContent = React.memo(() => {
28442843
}, [isShiftPressed])
28452844

28462845
const onSelectionEnd = useCallback(() => {
2847-
requestAnimationFrame(() => setIsSelectionDragActive(false))
2848-
}, [])
2846+
requestAnimationFrame(() => {
2847+
setIsSelectionDragActive(false)
2848+
setDisplayNodes((nodes) => resolveParentChildSelectionConflicts(nodes, blocks))
2849+
})
2850+
}, [blocks])
28492851

28502852
/** Captures initial positions when selection drag starts (for marquee-selected nodes). */
28512853
const onSelectionDragStart = useCallback(
28522854
(_event: React.MouseEvent, nodes: Node[]) => {
2853-
// Capture the parent ID of the first node as reference (they should all be in the same context)
28542855
if (nodes.length > 0) {
28552856
const firstNodeParentId = blocks[nodes[0].id]?.data?.parentId || null
28562857
setDragStartParentId(firstNodeParentId)
28572858
}
28582859

2859-
// Capture all selected nodes' positions for undo/redo
2860+
// Resolve parent-child conflicts and capture positions for undo/redo
2861+
setDisplayNodes((allNodes) => resolveParentChildSelectionConflicts(allNodes, blocks))
2862+
2863+
// Filter to nodes that won't be deselected (exclude children whose parent is selected)
2864+
const nodeIds = new Set(nodes.map((n) => n.id))
2865+
const effectiveNodes = nodes.filter((n) => {
2866+
const parentId = blocks[n.id]?.data?.parentId
2867+
return !parentId || !nodeIds.has(parentId)
2868+
})
2869+
28602870
multiNodeDragStartRef.current.clear()
2861-
nodes.forEach((n) => {
2862-
const block = blocks[n.id]
2863-
if (block) {
2871+
effectiveNodes.forEach((n) => {
2872+
const blk = blocks[n.id]
2873+
if (blk) {
28642874
multiNodeDragStartRef.current.set(n.id, {
28652875
x: n.position.x,
28662876
y: n.position.y,
2867-
parentId: block.data?.parentId,
2877+
parentId: blk.data?.parentId,
28682878
})
28692879
}
28702880
})
@@ -2903,7 +2913,6 @@ const WorkflowContent = React.memo(() => {
29032913

29042914
eligibleNodes.forEach((node) => {
29052915
const absolutePos = getNodeAbsolutePosition(node.id)
2906-
const block = blocks[node.id]
29072916
const width = BLOCK_DIMENSIONS.FIXED_WIDTH
29082917
const height = Math.max(
29092918
node.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
@@ -3129,13 +3138,11 @@ const WorkflowContent = React.memo(() => {
31293138

31303139
/**
31313140
* Handles node click to select the node in ReactFlow.
3132-
* This ensures clicking anywhere on a block (not just the drag handle)
3133-
* selects it for delete/backspace and multi-select operations.
3141+
* Parent-child conflict resolution happens automatically in onNodesChange.
31343142
*/
31353143
const handleNodeClick = useCallback(
31363144
(event: React.MouseEvent, node: Node) => {
31373145
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
3138-
31393146
setNodes((nodes) =>
31403147
nodes.map((n) => ({
31413148
...n,

0 commit comments

Comments
 (0)