mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
6 Commits
fix/resize
...
fix/copilo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
077a902325 | ||
|
|
fd2c4b6a7c | ||
|
|
47209aee32 | ||
|
|
0350321d1b | ||
|
|
e3b849ad74 | ||
|
|
07433ccbb1 |
@@ -148,7 +148,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
ref={blockRef}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
className={cn(
|
||||
'relative cursor-pointer select-none rounded-[8px] border border-[var(--border-1)]',
|
||||
'relative cursor-pointer select-none rounded-[8px] border border-[var(--border)]',
|
||||
'transition-block-bg transition-ring',
|
||||
'z-[20]'
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,6 @@ export {
|
||||
computeParentUpdateEntries,
|
||||
getClampedPositionForNode,
|
||||
isInEditableElement,
|
||||
resolveParentChildSelectionConflicts,
|
||||
selectNodesDeferred,
|
||||
validateTriggerPaste,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
|
||||
@@ -13,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 { calculateContainerDimensions, useNodeUtilities } from './use-node-utilities'
|
||||
export { useNodeUtilities } from './use-node-utilities'
|
||||
export { usePreventZoom } from './use-prevent-zoom'
|
||||
export { useScrollManagement } from './use-scroll-management'
|
||||
export { useWorkflowExecution } from './use-workflow-execution'
|
||||
|
||||
@@ -62,47 +62,6 @@ 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
|
||||
*/
|
||||
@@ -347,16 +306,36 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
(id) => currentBlocks[id]?.data?.parentId === nodeId
|
||||
)
|
||||
|
||||
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)
|
||||
if (childBlockIds.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
return calculateContainerDimensions(childPositions)
|
||||
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 }
|
||||
},
|
||||
[getBlockDimensions]
|
||||
)
|
||||
|
||||
@@ -70,22 +70,19 @@ 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,
|
||||
blocks: Record<string, { data?: { parentId?: string } }>
|
||||
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void
|
||||
): void {
|
||||
const idsSet = new Set(nodeIds)
|
||||
requestAnimationFrame(() => {
|
||||
setDisplayNodes((nodes) => {
|
||||
const withSelection = nodes.map((node) => ({
|
||||
setDisplayNodes((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
...node,
|
||||
selected: idsSet.has(node.id),
|
||||
}))
|
||||
return resolveParentChildSelectionConflicts(withSelection, blocks)
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -189,26 +186,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
computeClampedPositionUpdates,
|
||||
getClampedPositionForNode,
|
||||
isInEditableElement,
|
||||
resolveParentChildSelectionConflicts,
|
||||
selectNodesDeferred,
|
||||
useAutoLayout,
|
||||
useCurrentWorkflow,
|
||||
@@ -56,7 +55,6 @@ 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'
|
||||
@@ -699,8 +697,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
selectNodesDeferred(
|
||||
pastedBlocksArray.map((b) => b.id),
|
||||
setDisplayNodes,
|
||||
blocks
|
||||
setDisplayNodes
|
||||
)
|
||||
}, [
|
||||
hasClipboard,
|
||||
@@ -748,8 +745,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
selectNodesDeferred(
|
||||
pastedBlocksArray.map((b) => b.id),
|
||||
setDisplayNodes,
|
||||
blocks
|
||||
setDisplayNodes
|
||||
)
|
||||
}, [
|
||||
contextMenuBlocks,
|
||||
@@ -894,8 +890,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
selectNodesDeferred(
|
||||
pastedBlocks.map((b) => b.id),
|
||||
setDisplayNodes,
|
||||
blocks
|
||||
setDisplayNodes
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2042,19 +2037,10 @@ const WorkflowContent = React.memo(() => {
|
||||
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
|
||||
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
|
||||
|
||||
/** 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]
|
||||
)
|
||||
/** 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))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Updates container dimensions in displayNodes during drag.
|
||||
@@ -2069,13 +2055,28 @@ const WorkflowContent = React.memo(() => {
|
||||
const childNodes = currentNodes.filter((n) => n.parentId === parentId)
|
||||
if (childNodes.length === 0) return currentNodes
|
||||
|
||||
const childPositions = childNodes.map((node) => {
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
childNodes.forEach((node) => {
|
||||
const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position
|
||||
const { width, height } = getBlockDimensions(node.id)
|
||||
return { x: nodePosition.x, y: nodePosition.y, width, height }
|
||||
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
|
||||
|
||||
maxRight = Math.max(maxRight, nodePosition.x + nodeWidth)
|
||||
maxBottom = Math.max(maxBottom, nodePosition.y + nodeHeight)
|
||||
})
|
||||
|
||||
const { width: newWidth, height: newHeight } = calculateContainerDimensions(childPositions)
|
||||
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
|
||||
)
|
||||
|
||||
return currentNodes.map((node) => {
|
||||
if (node.id === parentId) {
|
||||
@@ -2843,38 +2844,27 @@ const WorkflowContent = React.memo(() => {
|
||||
}, [isShiftPressed])
|
||||
|
||||
const onSelectionEnd = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsSelectionDragActive(false)
|
||||
setDisplayNodes((nodes) => resolveParentChildSelectionConflicts(nodes, blocks))
|
||||
})
|
||||
}, [blocks])
|
||||
requestAnimationFrame(() => setIsSelectionDragActive(false))
|
||||
}, [])
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
// Capture all selected nodes' positions for undo/redo
|
||||
multiNodeDragStartRef.current.clear()
|
||||
effectiveNodes.forEach((n) => {
|
||||
const blk = blocks[n.id]
|
||||
if (blk) {
|
||||
nodes.forEach((n) => {
|
||||
const block = blocks[n.id]
|
||||
if (block) {
|
||||
multiNodeDragStartRef.current.set(n.id, {
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
parentId: blk.data?.parentId,
|
||||
parentId: block.data?.parentId,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -2913,6 +2903,7 @@ 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,
|
||||
@@ -3138,11 +3129,13 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
/**
|
||||
* Handles node click to select the node in ReactFlow.
|
||||
* Parent-child conflict resolution happens automatically in onNodesChange.
|
||||
* This ensures clicking anywhere on a block (not just the drag handle)
|
||||
* selects it for delete/backspace and multi-select operations.
|
||||
*/
|
||||
const handleNodeClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node) => {
|
||||
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
|
||||
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => ({
|
||||
...n,
|
||||
|
||||
@@ -38,7 +38,7 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
|
||||
className='relative select-none rounded-[8px] border border-[var(--border)]'
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
|
||||
@@ -657,7 +657,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
items={emailItems}
|
||||
onAdd={(value) => addEmail(value)}
|
||||
onRemove={removeEmailItem}
|
||||
onInputChange={() => setErrorMessage(null)}
|
||||
placeholder={
|
||||
!userPerms.canAdmin
|
||||
? 'Only administrators can invite new members'
|
||||
|
||||
@@ -166,8 +166,6 @@ export interface TagInputProps extends VariantProps<typeof tagInputVariants> {
|
||||
onAdd: (value: string) => boolean
|
||||
/** Callback when a tag is removed (receives value, index, and isValid) */
|
||||
onRemove: (value: string, index: number, isValid: boolean) => void
|
||||
/** Callback when the input value changes (useful for clearing errors) */
|
||||
onInputChange?: (value: string) => void
|
||||
/** Placeholder text for the input */
|
||||
placeholder?: string
|
||||
/** Placeholder text when there are existing tags */
|
||||
@@ -209,7 +207,6 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
items,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onInputChange,
|
||||
placeholder = 'Enter values',
|
||||
placeholderWithTags = 'Add another',
|
||||
disabled = false,
|
||||
@@ -347,12 +344,10 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
})
|
||||
|
||||
if (addedCount === 0 && pastedValues.length === 1) {
|
||||
const newValue = inputValue + pastedValues[0]
|
||||
setInputValue(newValue)
|
||||
onInputChange?.(newValue)
|
||||
setInputValue(inputValue + pastedValues[0])
|
||||
}
|
||||
},
|
||||
[onAdd, inputValue, onInputChange]
|
||||
[onAdd, inputValue]
|
||||
)
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
@@ -427,10 +422,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
name={name}
|
||||
type='text'
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value)
|
||||
onInputChange?.(e.target.value)
|
||||
}}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onBlur={handleBlur}
|
||||
|
||||
Reference in New Issue
Block a user