Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export {
computeParentUpdateEntries,
getClampedPositionForNode,
isInEditableElement,
resolveParentChildSelectionConflicts,
selectNodesDeferred,
validateTriggerPaste,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
Expand All @@ -12,7 +13,7 @@ export { useAutoLayout } from './use-auto-layout'
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
export { useBlockVisual } from './use-block-visual'
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
export { useNodeUtilities } from './use-node-utilities'
export { calculateContainerDimensions, useNodeUtilities } from './use-node-utilities'
export { usePreventZoom } from './use-prevent-zoom'
export { useScrollManagement } from './use-scroll-management'
export { useWorkflowExecution } from './use-workflow-execution'
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,47 @@ export function clampPositionToContainer(
}
}

/**
* Calculates container dimensions based on child block positions.
* Single source of truth for container sizing - ensures consistency between
* live drag updates and final dimension calculations.
*
* @param childPositions - Array of child positions with their dimensions
* @returns Calculated width and height for the container
*/
export function calculateContainerDimensions(
childPositions: Array<{ x: number; y: number; width: number; height: number }>
): { width: number; height: number } {
if (childPositions.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}

let maxRight = 0
let maxBottom = 0

for (const child of childPositions) {
maxRight = Math.max(maxRight, child.x + child.width)
maxBottom = Math.max(maxBottom, child.y + child.height)
}

const width = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const height = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
CONTAINER_DIMENSIONS.TOP_PADDING +
maxBottom +
CONTAINER_DIMENSIONS.BOTTOM_PADDING
)

return { width, height }
}

/**
* Hook providing utilities for node position, hierarchy, and dimension calculations
*/
Expand Down Expand Up @@ -306,36 +347,16 @@ export function useNodeUtilities(blocks: Record<string, any>) {
(id) => currentBlocks[id]?.data?.parentId === nodeId
)

if (childBlockIds.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}

let maxRight = 0
let maxBottom = 0

for (const childId of childBlockIds) {
const child = currentBlocks[childId]
if (!child?.position) continue

const { width: childWidth, height: childHeight } = getBlockDimensions(childId)

maxRight = Math.max(maxRight, child.position.x + childWidth)
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
}

const width = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const height = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
const childPositions = childBlockIds
.map((childId) => {
const child = currentBlocks[childId]
if (!child?.position) return null
const { width, height } = getBlockDimensions(childId)
return { x: child.position.x, y: child.position.y, width, height }
})
.filter((p): p is NonNullable<typeof p> => p !== null)

return { width, height }
return calculateContainerDimensions(childPositions)
},
[getBlockDimensions]
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,22 @@ export function clearDragHighlights(): void {
* Defers selection to next animation frame to allow displayNodes to sync from store first.
* This is necessary because the component uses controlled state (nodes={displayNodes})
* and newly added blocks need time to propagate through the store → derivedNodes → displayNodes cycle.
* Automatically resolves parent-child selection conflicts to prevent wiggle during drag.
*/
export function selectNodesDeferred(
nodeIds: string[],
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void,
blocks: Record<string, { data?: { parentId?: string } }>
): void {
const idsSet = new Set(nodeIds)
requestAnimationFrame(() => {
setDisplayNodes((nodes) =>
nodes.map((node) => ({
setDisplayNodes((nodes) => {
const withSelection = nodes.map((node) => ({
...node,
selected: idsSet.has(node.id),
}))
)
return resolveParentChildSelectionConflicts(withSelection, blocks)
})
})
}

Expand Down Expand Up @@ -186,3 +189,26 @@ export function computeParentUpdateEntries(
}
})
}

/**
* Resolves parent-child selection conflicts by deselecting children whose parent is also selected.
*/
export function resolveParentChildSelectionConflicts(
nodes: Node[],
blocks: Record<string, { data?: { parentId?: string } }>
): Node[] {
const selectedIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id))

let hasConflict = false
const resolved = nodes.map((n) => {
if (!n.selected) return n
const parentId = n.parentId || n.data?.parentId || blocks[n.id]?.data?.parentId
if (parentId && selectedIds.has(parentId)) {
hasConflict = true
return { ...n, selected: false }
}
return n
})

return hasConflict ? resolved : nodes
}
83 changes: 45 additions & 38 deletions apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
computeClampedPositionUpdates,
getClampedPositionForNode,
isInEditableElement,
resolveParentChildSelectionConflicts,
selectNodesDeferred,
useAutoLayout,
useCurrentWorkflow,
Expand All @@ -55,6 +56,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu'
import {
calculateContainerDimensions,
clampPositionToContainer,
estimateBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
Expand Down Expand Up @@ -697,7 +699,8 @@ const WorkflowContent = React.memo(() => {

selectNodesDeferred(
pastedBlocksArray.map((b) => b.id),
setDisplayNodes
setDisplayNodes,
blocks
)
}, [
hasClipboard,
Expand Down Expand Up @@ -745,7 +748,8 @@ const WorkflowContent = React.memo(() => {

selectNodesDeferred(
pastedBlocksArray.map((b) => b.id),
setDisplayNodes
setDisplayNodes,
blocks
)
}, [
contextMenuBlocks,
Expand Down Expand Up @@ -890,7 +894,8 @@ const WorkflowContent = React.memo(() => {

selectNodesDeferred(
pastedBlocks.map((b) => b.id),
setDisplayNodes
setDisplayNodes,
blocks
)
}
}
Expand Down Expand Up @@ -2037,10 +2042,19 @@ const WorkflowContent = React.memo(() => {
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])

/** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */
const onNodesChange = useCallback((changes: NodeChange[]) => {
setDisplayNodes((nds) => applyNodeChanges(changes, nds))
}, [])
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
setDisplayNodes((nds) => {
const updated = applyNodeChanges(changes, nds)
const hasSelectionChange = changes.some(
(c) => c.type === 'select' && (c as { selected?: boolean }).selected
)
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
})
},
[blocks]
)

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

let maxRight = 0
let maxBottom = 0

childNodes.forEach((node) => {
const childPositions = childNodes.map((node) => {
const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)

maxRight = Math.max(maxRight, nodePosition.x + nodeWidth)
maxBottom = Math.max(maxBottom, nodePosition.y + nodeHeight)
const { width, height } = getBlockDimensions(node.id)
return { x: nodePosition.x, y: nodePosition.y, width, height }
})

const newWidth = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const newHeight = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
CONTAINER_DIMENSIONS.TOP_PADDING +
maxBottom +
CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
const { width: newWidth, height: newHeight } = calculateContainerDimensions(childPositions)

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

const onSelectionEnd = useCallback(() => {
requestAnimationFrame(() => setIsSelectionDragActive(false))
}, [])
requestAnimationFrame(() => {
setIsSelectionDragActive(false)
setDisplayNodes((nodes) => resolveParentChildSelectionConflicts(nodes, blocks))
})
}, [blocks])

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

// Capture all selected nodes' positions for undo/redo
// Resolve parent-child conflicts and capture positions for undo/redo
setDisplayNodes((allNodes) => resolveParentChildSelectionConflicts(allNodes, blocks))

// Filter to nodes that won't be deselected (exclude children whose parent is selected)
const nodeIds = new Set(nodes.map((n) => n.id))
const effectiveNodes = nodes.filter((n) => {
const parentId = blocks[n.id]?.data?.parentId
return !parentId || !nodeIds.has(parentId)
})

multiNodeDragStartRef.current.clear()
nodes.forEach((n) => {
const block = blocks[n.id]
if (block) {
effectiveNodes.forEach((n) => {
const blk = blocks[n.id]
if (blk) {
multiNodeDragStartRef.current.set(n.id, {
x: n.position.x,
y: n.position.y,
parentId: block.data?.parentId,
parentId: blk.data?.parentId,
})
}
})
Expand Down Expand Up @@ -2903,7 +2913,6 @@ const WorkflowContent = React.memo(() => {

eligibleNodes.forEach((node) => {
const absolutePos = getNodeAbsolutePosition(node.id)
const block = blocks[node.id]
const width = BLOCK_DIMENSIONS.FIXED_WIDTH
const height = Math.max(
node.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
Expand Down Expand Up @@ -3129,13 +3138,11 @@ const WorkflowContent = React.memo(() => {

/**
* Handles node click to select the node in ReactFlow.
* This ensures clicking anywhere on a block (not just the drag handle)
* selects it for delete/backspace and multi-select operations.
* Parent-child conflict resolution happens automatically in onNodesChange.
*/
const handleNodeClick = useCallback(
(event: React.MouseEvent, node: Node) => {
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey

setNodes((nodes) =>
nodes.map((n) => ({
...n,
Expand Down