Compare commits

...

4 Commits

Author SHA1 Message Date
waleed
c738a71b74 ack comments 2026-01-11 11:27:47 -08:00
waleed
80ede82ee1 fix copy-paste subflows deselecting children 2026-01-11 11:18:40 -08:00
waleed
28943eb11d ack PR comments 2026-01-10 22:04:41 -08:00
waleed
5e1c83883b fix(resize): fix subflow resize on drag, children deselected in subflow on drag 2026-01-10 21:54:59 -08:00
4 changed files with 148 additions and 105 deletions

View File

@@ -4,7 +4,7 @@ export {
computeParentUpdateEntries,
getClampedPositionForNode,
isInEditableElement,
selectNodesDeferred,
resolveParentChildSelectionConflicts,
validateTriggerPaste,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './float'
@@ -12,7 +12,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'

View File

@@ -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
*/
@@ -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,
}
}
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)
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
)
return { width, height }
return calculateContainerDimensions(childPositions)
},
[getBlockDimensions]
)

View File

@@ -65,27 +65,6 @@ export function clearDragHighlights(): void {
document.body.style.cursor = ''
}
/**
* Selects nodes by their IDs after paste/duplicate operations.
* 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.
*/
export function selectNodesDeferred(
nodeIds: string[],
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void
): void {
const idsSet = new Set(nodeIds)
requestAnimationFrame(() => {
setDisplayNodes((nodes) =>
nodes.map((node) => ({
...node,
selected: idsSet.has(node.id),
}))
)
})
}
interface BlockData {
height?: number
data?: {
@@ -186,3 +165,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 || blocks[n.id]?.data?.parentId
if (parentId && selectedIds.has(parentId)) {
hasConflict = true
return { ...n, selected: false }
}
return n
})
return hasConflict ? resolved : nodes
}

View File

@@ -47,7 +47,7 @@ import {
computeClampedPositionUpdates,
getClampedPositionForNode,
isInEditableElement,
selectNodesDeferred,
resolveParentChildSelectionConflicts,
useAutoLayout,
useCurrentWorkflow,
useNodeUtilities,
@@ -55,6 +55,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'
@@ -356,6 +357,9 @@ const WorkflowContent = React.memo(() => {
new Map()
)
/** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */
const pendingSelectionRef = useRef<Set<string> | null>(null)
/** Re-applies diff markers when blocks change after socket rehydration. */
const blocksRef = useRef(blocks)
useEffect(() => {
@@ -687,6 +691,12 @@ const WorkflowContent = React.memo(() => {
return
}
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
pendingSelectionRef.current = new Set([
...(pendingSelectionRef.current ?? []),
...pastedBlocksArray.map((b) => b.id),
])
collaborativeBatchAddBlocks(
pastedBlocksArray,
pastedEdges,
@@ -694,11 +704,6 @@ const WorkflowContent = React.memo(() => {
pastedParallels,
pastedSubBlockValues
)
selectNodesDeferred(
pastedBlocksArray.map((b) => b.id),
setDisplayNodes
)
}, [
hasClipboard,
clipboard,
@@ -735,6 +740,12 @@ const WorkflowContent = React.memo(() => {
return
}
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
pendingSelectionRef.current = new Set([
...(pendingSelectionRef.current ?? []),
...pastedBlocksArray.map((b) => b.id),
])
collaborativeBatchAddBlocks(
pastedBlocksArray,
pastedEdges,
@@ -742,11 +753,6 @@ const WorkflowContent = React.memo(() => {
pastedParallels,
pastedSubBlockValues
)
selectNodesDeferred(
pastedBlocksArray.map((b) => b.id),
setDisplayNodes
)
}, [
contextMenuBlocks,
copyBlocks,
@@ -880,6 +886,12 @@ const WorkflowContent = React.memo(() => {
return
}
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
pendingSelectionRef.current = new Set([
...(pendingSelectionRef.current ?? []),
...pastedBlocks.map((b) => b.id),
])
collaborativeBatchAddBlocks(
pastedBlocks,
pasteData.edges,
@@ -887,11 +899,6 @@ const WorkflowContent = React.memo(() => {
pasteData.parallels,
pasteData.subBlockValues
)
selectNodesDeferred(
pastedBlocks.map((b) => b.id),
setDisplayNodes
)
}
}
}
@@ -1954,15 +1961,27 @@ const WorkflowContent = React.memo(() => {
}, [isShiftPressed])
useEffect(() => {
// Preserve selection state when syncing from derivedNodes
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection
const pendingSelection = pendingSelectionRef.current
pendingSelectionRef.current = null
setDisplayNodes((currentNodes) => {
if (pendingSelection) {
// Apply pending selection and resolve parent-child conflicts
const withSelection = derivedNodes.map((node) => ({
...node,
selected: pendingSelection.has(node.id),
}))
return resolveParentChildSelectionConflicts(withSelection, blocks)
}
// Preserve existing selection state
const selectedIds = new Set(currentNodes.filter((n) => n.selected).map((n) => n.id))
return derivedNodes.map((node) => ({
...node,
selected: selectedIds.has(node.id),
}))
})
}, [derivedNodes])
}, [derivedNodes, blocks])
/** Handles ActionBar remove-from-subflow events. */
useEffect(() => {
@@ -2037,10 +2056,17 @@ 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')
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
})
},
[blocks]
)
/**
* Updates container dimensions in displayNodes during drag.
@@ -2055,28 +2081,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) {
@@ -2844,30 +2855,42 @@ 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
// 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)
})
// Capture positions for undo/redo before applying display changes
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,
})
}
})
// Apply visual deselection of children
setDisplayNodes((allNodes) => resolveParentChildSelectionConflicts(allNodes, blocks))
},
[blocks]
)
@@ -2903,7 +2926,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,
@@ -3129,13 +3151,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,