mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 16:08:04 -05:00
Compare commits
4 Commits
staging
...
fix/resize
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c738a71b74 | ||
|
|
80ede82ee1 | ||
|
|
28943eb11d | ||
|
|
5e1c83883b |
@@ -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'
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user