mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Fix drag
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { NodeProps } from 'reactflow'
|
||||
import { type NodeProps, useReactFlow } from 'reactflow'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { ActionBar } from '../workflow-block/components'
|
||||
import type { WorkflowBlockProps } from '../workflow-block/types'
|
||||
|
||||
@@ -198,6 +199,57 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
// Get React Flow methods for group selection expansion
|
||||
const { getNodes, setNodes } = useReactFlow()
|
||||
const { getGroups } = useWorkflowStore()
|
||||
|
||||
/**
|
||||
* Expands selection to include all group members on mouse down.
|
||||
* This ensures that when a user starts dragging a note in a group,
|
||||
* all other blocks in the group are also selected and will move together.
|
||||
*/
|
||||
const handleGroupMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only process left mouse button clicks
|
||||
if (e.button !== 0) return
|
||||
|
||||
const groupId = data.groupId
|
||||
if (!groupId) return
|
||||
|
||||
const groups = getGroups()
|
||||
const group = groups[groupId]
|
||||
if (!group || group.blockIds.length <= 1) return
|
||||
|
||||
const groupBlockIds = new Set(group.blockIds)
|
||||
const allNodes = getNodes()
|
||||
|
||||
// Check if all group members are already selected
|
||||
const allSelected = [...groupBlockIds].every((blockId) =>
|
||||
allNodes.find((n) => n.id === blockId && n.selected)
|
||||
)
|
||||
|
||||
if (allSelected) return
|
||||
|
||||
// Expand selection to include all group members
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => {
|
||||
const isInGroup = groupBlockIds.has(n.id)
|
||||
const isThisBlock = n.id === id
|
||||
return {
|
||||
...n,
|
||||
selected: isInGroup ? true : n.selected,
|
||||
data: {
|
||||
...n.data,
|
||||
// Mark as grouped selection if in group but not the directly clicked block
|
||||
isGroupedSelection: isInGroup && !isThisBlock && !n.selected,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
[id, data.groupId, getNodes, setNodes, getGroups]
|
||||
)
|
||||
|
||||
/**
|
||||
* Calculate deterministic dimensions based on content structure.
|
||||
* Uses fixed width and computed height to avoid ResizeObserver jitter.
|
||||
@@ -217,7 +269,7 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='group relative'>
|
||||
<div className='group relative' onMouseDown={handleGroupMouseDown}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)]'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import { memo, useCallback, useMemo, useRef } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
||||
import { Button, Trash } from '@/components/emcn'
|
||||
@@ -8,6 +8,7 @@ import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/**
|
||||
* Global styles for subflow nodes (loop and parallel containers).
|
||||
@@ -51,6 +52,8 @@ export interface SubflowNodeData {
|
||||
isPreviewSelected?: boolean
|
||||
kind: 'loop' | 'parallel'
|
||||
name?: string
|
||||
/** The ID of the group this subflow belongs to */
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,8 +65,9 @@ export interface SubflowNodeData {
|
||||
* @returns Rendered subflow node component
|
||||
*/
|
||||
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const { getNodes, setNodes } = useReactFlow()
|
||||
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
|
||||
const { getGroups } = useWorkflowStore()
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
@@ -140,10 +144,57 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
diffStatus === 'edited' && 'ring-[var(--warning)]'
|
||||
)
|
||||
|
||||
/**
|
||||
* Expands selection to include all group members on mouse down.
|
||||
* This ensures that when a user starts dragging a subflow in a group,
|
||||
* all other blocks in the group are also selected and will move together.
|
||||
*/
|
||||
const handleGroupMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only process left mouse button clicks
|
||||
if (e.button !== 0) return
|
||||
|
||||
const groupId = data.groupId
|
||||
if (!groupId) return
|
||||
|
||||
const groups = getGroups()
|
||||
const group = groups[groupId]
|
||||
if (!group || group.blockIds.length <= 1) return
|
||||
|
||||
const groupBlockIds = new Set(group.blockIds)
|
||||
const allNodes = getNodes()
|
||||
|
||||
// Check if all group members are already selected
|
||||
const allSelected = [...groupBlockIds].every((blockId) =>
|
||||
allNodes.find((n) => n.id === blockId && n.selected)
|
||||
)
|
||||
|
||||
if (allSelected) return
|
||||
|
||||
// Expand selection to include all group members
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => {
|
||||
const isInGroup = groupBlockIds.has(n.id)
|
||||
const isThisBlock = n.id === id
|
||||
return {
|
||||
...n,
|
||||
selected: isInGroup ? true : n.selected,
|
||||
data: {
|
||||
...n.data,
|
||||
// Mark as grouped selection if in group but not the directly clicked block
|
||||
isGroupedSelection: isInGroup && !isThisBlock && !n.selected,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
[id, data.groupId, getNodes, setNodes, getGroups]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubflowNodeStyles />
|
||||
<div className='group relative'>
|
||||
<div className='group relative' onMouseDown={handleGroupMouseDown}>
|
||||
<div
|
||||
ref={blockRef}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface WorkflowBlockProps {
|
||||
isPreviewSelected?: boolean
|
||||
/** Whether this block is selected as part of a group (not directly clicked) */
|
||||
isGroupedSelection?: boolean
|
||||
/** The ID of the group this block belongs to */
|
||||
groupId?: string
|
||||
subBlockValues?: Record<string, any>
|
||||
blockState?: any
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||
import { Handle, type NodeProps, Position, useReactFlow, useUpdateNodeInternals } from 'reactflow'
|
||||
import { Badge, Tooltip } from '@/components/emcn'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -917,8 +917,63 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
|
||||
const isGroupedSelection = data.isGroupedSelection ?? false
|
||||
|
||||
// Get React Flow methods for group selection expansion
|
||||
const { getNodes, setNodes } = useReactFlow()
|
||||
const { getGroups } = useWorkflowStore()
|
||||
|
||||
/**
|
||||
* Expands selection to include all group members on mouse down.
|
||||
* This ensures that when a user starts dragging a block in a group,
|
||||
* all other blocks in the group are also selected and will move together.
|
||||
*/
|
||||
const handleGroupMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only process left mouse button clicks
|
||||
if (e.button !== 0) return
|
||||
|
||||
const groupId = data.groupId
|
||||
if (!groupId) return
|
||||
|
||||
const groups = getGroups()
|
||||
const group = groups[groupId]
|
||||
if (!group || group.blockIds.length <= 1) return
|
||||
|
||||
const groupBlockIds = new Set(group.blockIds)
|
||||
const allNodes = getNodes()
|
||||
|
||||
// Check if all group members are already selected
|
||||
const allSelected = [...groupBlockIds].every((blockId) =>
|
||||
allNodes.find((n) => n.id === blockId && n.selected)
|
||||
)
|
||||
|
||||
if (allSelected) return
|
||||
|
||||
// Expand selection to include all group members
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => {
|
||||
const isInGroup = groupBlockIds.has(n.id)
|
||||
const isThisBlock = n.id === id
|
||||
return {
|
||||
...n,
|
||||
selected: isInGroup ? true : n.selected,
|
||||
data: {
|
||||
...n.data,
|
||||
// Mark as grouped selection if in group but not the directly clicked block
|
||||
isGroupedSelection: isInGroup && !isThisBlock && !n.selected,
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
[id, data.groupId, getNodes, setNodes, getGroups]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='group relative' data-grouped-selection={isGroupedSelection ? 'true' : undefined}>
|
||||
<div
|
||||
className='group relative'
|
||||
data-grouped-selection={isGroupedSelection ? 'true' : undefined}
|
||||
onMouseDown={handleGroupMouseDown}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -1928,6 +1928,7 @@ const WorkflowContent = React.memo(() => {
|
||||
name: block.name,
|
||||
isActive,
|
||||
isPending,
|
||||
groupId: block.data?.groupId,
|
||||
},
|
||||
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||
@@ -2436,6 +2437,50 @@ const WorkflowContent = React.memo(() => {
|
||||
// Note: We don't emit position updates during drag to avoid flooding socket events.
|
||||
// The final position is sent in onNodeDragStop for collaborative updates.
|
||||
|
||||
// Move all group members together if the dragged node is in a group
|
||||
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
|
||||
if (draggedBlockGroupId) {
|
||||
const groups = getGroups()
|
||||
const group = groups[draggedBlockGroupId]
|
||||
if (group && group.blockIds.length > 1) {
|
||||
// Get the starting position of the dragged node
|
||||
const startPos = multiNodeDragStartRef.current.get(node.id)
|
||||
if (startPos) {
|
||||
// Calculate delta from start position
|
||||
const deltaX = node.position.x - startPos.x
|
||||
const deltaY = node.position.y - startPos.y
|
||||
|
||||
// Update positions of all nodes in the group (including dragged node to preserve React Flow's position)
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => {
|
||||
// For the dragged node, use the position from React Flow's node parameter
|
||||
if (n.id === node.id) {
|
||||
return {
|
||||
...n,
|
||||
position: node.position,
|
||||
}
|
||||
}
|
||||
|
||||
// Only update nodes in the same group
|
||||
if (group.blockIds.includes(n.id)) {
|
||||
const memberStartPos = multiNodeDragStartRef.current.get(n.id)
|
||||
if (memberStartPos) {
|
||||
return {
|
||||
...n,
|
||||
position: {
|
||||
x: memberStartPos.x + deltaX,
|
||||
y: memberStartPos.y + deltaY,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current parent ID of the node being dragged
|
||||
const currentParentId = blocks[node.id]?.data?.parentId || null
|
||||
|
||||
@@ -2568,11 +2613,13 @@ const WorkflowContent = React.memo(() => {
|
||||
},
|
||||
[
|
||||
getNodes,
|
||||
setNodes,
|
||||
potentialParentId,
|
||||
blocks,
|
||||
getNodeAbsolutePosition,
|
||||
getNodeDepth,
|
||||
updateContainerDimensionsDuringDrag,
|
||||
getGroups,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -2631,6 +2678,8 @@ const WorkflowContent = React.memo(() => {
|
||||
// Re-get nodes after potential selection expansion
|
||||
const updatedNodes = getNodes()
|
||||
const selectedNodes = updatedNodes.filter((n) => {
|
||||
// Always include the dragged node
|
||||
if (n.id === node.id) return true
|
||||
// Include node if it's selected OR if it's in the same group as the dragged node
|
||||
if (n.selected) return true
|
||||
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
|
||||
@@ -2661,9 +2710,31 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
// Get all selected nodes to update their positions too
|
||||
const allNodes = getNodes()
|
||||
const selectedNodes = allNodes.filter((n) => n.selected)
|
||||
let selectedNodes = allNodes.filter((n) => n.selected)
|
||||
|
||||
// If multiple nodes are selected, update all their positions
|
||||
// If the dragged node is in a group, include all group members
|
||||
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
|
||||
if (draggedBlockGroupId) {
|
||||
const groups = getGroups()
|
||||
const group = groups[draggedBlockGroupId]
|
||||
if (group && group.blockIds.length > 1) {
|
||||
const groupBlockIds = new Set(group.blockIds)
|
||||
// Include the dragged node and all group members that aren't already selected
|
||||
const groupNodes = allNodes.filter(
|
||||
(n) => groupBlockIds.has(n.id) && !selectedNodes.some((sn) => sn.id === n.id)
|
||||
)
|
||||
selectedNodes = [...selectedNodes, ...groupNodes]
|
||||
// Also ensure the dragged node is included
|
||||
if (!selectedNodes.some((n) => n.id === node.id)) {
|
||||
const draggedNode = allNodes.find((n) => n.id === node.id)
|
||||
if (draggedNode) {
|
||||
selectedNodes = [...selectedNodes, draggedNode]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If multiple nodes are selected (or in a group), update all their positions
|
||||
if (selectedNodes.length > 1) {
|
||||
const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
|
||||
collaborativeBatchUpdatePositions(positionUpdates, {
|
||||
|
||||
@@ -1666,7 +1666,6 @@ export function useCollaborativeWorkflow() {
|
||||
}
|
||||
|
||||
const blockIds = [...group.blockIds]
|
||||
const parentGroupId = group.parentGroupId
|
||||
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
@@ -1675,7 +1674,7 @@ export function useCollaborativeWorkflow() {
|
||||
operation: {
|
||||
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
|
||||
target: OPERATION_TARGETS.BLOCKS,
|
||||
payload: { groupId, blockIds, parentGroupId },
|
||||
payload: { groupId, blockIds },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
@@ -1683,7 +1682,7 @@ export function useCollaborativeWorkflow() {
|
||||
|
||||
workflowStore.ungroupBlocks(groupId)
|
||||
|
||||
undoRedo.recordUngroupBlocks(groupId, blockIds, parentGroupId)
|
||||
undoRedo.recordUngroupBlocks(groupId, blockIds)
|
||||
|
||||
logger.info('Ungrouped blocks collaboratively', { groupId, blockCount: blockIds.length })
|
||||
return blockIds
|
||||
|
||||
@@ -16,9 +16,61 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('AutoLayout')
|
||||
|
||||
/** Default block dimensions for layout calculations */
|
||||
const DEFAULT_BLOCK_WIDTH = 250
|
||||
const DEFAULT_BLOCK_HEIGHT = 100
|
||||
|
||||
/**
|
||||
* Identifies groups from blocks and calculates their bounding boxes.
|
||||
* Returns a map of groupId to group info including bounding box and member block IDs.
|
||||
*/
|
||||
function identifyGroups(blocks: Record<string, BlockState>): Map<
|
||||
string,
|
||||
{
|
||||
blockIds: string[]
|
||||
bounds: { minX: number; minY: number; maxX: number; maxY: number }
|
||||
}
|
||||
> {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
blockIds: string[]
|
||||
bounds: { minX: number; minY: number; maxX: number; maxY: number }
|
||||
}
|
||||
>()
|
||||
|
||||
// Group blocks by their groupId
|
||||
for (const [blockId, block] of Object.entries(blocks)) {
|
||||
const groupId = block.data?.groupId
|
||||
if (!groupId) continue
|
||||
|
||||
if (!groups.has(groupId)) {
|
||||
groups.set(groupId, {
|
||||
blockIds: [],
|
||||
bounds: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||
})
|
||||
}
|
||||
|
||||
const group = groups.get(groupId)!
|
||||
group.blockIds.push(blockId)
|
||||
|
||||
// Update bounding box
|
||||
const blockWidth = block.data?.width ?? DEFAULT_BLOCK_WIDTH
|
||||
const blockHeight = block.data?.height ?? block.height ?? DEFAULT_BLOCK_HEIGHT
|
||||
|
||||
group.bounds.minX = Math.min(group.bounds.minX, block.position.x)
|
||||
group.bounds.minY = Math.min(group.bounds.minY, block.position.y)
|
||||
group.bounds.maxX = Math.max(group.bounds.maxX, block.position.x + blockWidth)
|
||||
group.bounds.maxY = Math.max(group.bounds.maxY, block.position.y + blockHeight)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies automatic layout to all blocks in a workflow.
|
||||
* Positions blocks in layers based on their connections (edges).
|
||||
* Groups are treated as single units and laid out together.
|
||||
*/
|
||||
export function applyAutoLayout(
|
||||
blocks: Record<string, BlockState>,
|
||||
@@ -36,6 +88,11 @@ export function applyAutoLayout(
|
||||
const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING
|
||||
const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_SPACING
|
||||
|
||||
// Identify groups and their bounding boxes
|
||||
const groups = identifyGroups(blocksCopy)
|
||||
|
||||
logger.info('Identified block groups for layout', { groupCount: groups.size })
|
||||
|
||||
// Pre-calculate container dimensions by laying out their children (bottom-up)
|
||||
// This ensures accurate widths/heights before root-level layout
|
||||
prepareContainerDimensions(
|
||||
@@ -49,19 +106,112 @@ export function applyAutoLayout(
|
||||
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
|
||||
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
|
||||
|
||||
const rootBlocks: Record<string, BlockState> = {}
|
||||
for (const id of layoutRootIds) {
|
||||
rootBlocks[id] = blocksCopy[id]
|
||||
// For groups, we need to:
|
||||
// 1. Create virtual blocks representing each group
|
||||
// 2. Replace grouped blocks with their group's virtual block
|
||||
// 3. Layout the virtual blocks + ungrouped blocks
|
||||
// 4. Apply position deltas to grouped blocks
|
||||
|
||||
// Track which blocks are in groups at root level
|
||||
const groupedRootBlockIds = new Set<string>()
|
||||
const groupRepresentatives = new Map<string, string>() // groupId -> representative blockId
|
||||
|
||||
// Store ORIGINAL positions of all grouped blocks before any modifications
|
||||
const originalBlockPositions = new Map<string, { x: number; y: number }>()
|
||||
for (const [_groupId, group] of groups) {
|
||||
for (const blockId of group.blockIds) {
|
||||
if (blocksCopy[blockId]) {
|
||||
originalBlockPositions.set(blockId, { ...blocksCopy[blockId].position })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootEdges = edges.filter(
|
||||
(edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target)
|
||||
)
|
||||
for (const [groupId, group] of groups) {
|
||||
// Find if any blocks in this group are at root level
|
||||
const rootGroupBlocks = group.blockIds.filter((id) => layoutRootIds.includes(id))
|
||||
if (rootGroupBlocks.length > 0) {
|
||||
// Mark all blocks in this group as grouped
|
||||
for (const blockId of rootGroupBlocks) {
|
||||
groupedRootBlockIds.add(blockId)
|
||||
}
|
||||
// Use the first block as the group's representative for layout
|
||||
const representativeId = rootGroupBlocks[0]
|
||||
groupRepresentatives.set(groupId, representativeId)
|
||||
|
||||
// Update the representative block's dimensions to match the group's bounding box
|
||||
const bounds = group.bounds
|
||||
const groupWidth = bounds.maxX - bounds.minX
|
||||
const groupHeight = bounds.maxY - bounds.minY
|
||||
|
||||
blocksCopy[representativeId] = {
|
||||
...blocksCopy[representativeId],
|
||||
data: {
|
||||
...blocksCopy[representativeId].data,
|
||||
width: groupWidth,
|
||||
height: groupHeight,
|
||||
},
|
||||
// Position at the group's top-left corner
|
||||
position: { x: bounds.minX, y: bounds.minY },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the blocks to layout: ungrouped blocks + group representatives
|
||||
const rootBlocks: Record<string, BlockState> = {}
|
||||
for (const id of layoutRootIds) {
|
||||
// Skip grouped blocks that aren't representatives
|
||||
if (groupedRootBlockIds.has(id)) {
|
||||
// Only include if this is a group representative
|
||||
for (const [groupId, repId] of groupRepresentatives) {
|
||||
if (repId === id) {
|
||||
rootBlocks[id] = blocksCopy[id]
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rootBlocks[id] = blocksCopy[id]
|
||||
}
|
||||
}
|
||||
|
||||
// Remap edges: edges involving grouped blocks should connect to the representative
|
||||
const blockToGroup = new Map<string, string>() // blockId -> groupId
|
||||
for (const [groupId, group] of groups) {
|
||||
for (const blockId of group.blockIds) {
|
||||
blockToGroup.set(blockId, groupId)
|
||||
}
|
||||
}
|
||||
|
||||
const layoutBlockIds = new Set(Object.keys(rootBlocks))
|
||||
const rootEdges = edges
|
||||
.map((edge) => {
|
||||
let source = edge.source
|
||||
let target = edge.target
|
||||
|
||||
// Remap source if it's in a group
|
||||
const sourceGroupId = blockToGroup.get(source)
|
||||
if (sourceGroupId && groupRepresentatives.has(sourceGroupId)) {
|
||||
source = groupRepresentatives.get(sourceGroupId)!
|
||||
}
|
||||
|
||||
// Remap target if it's in a group
|
||||
const targetGroupId = blockToGroup.get(target)
|
||||
if (targetGroupId && groupRepresentatives.has(targetGroupId)) {
|
||||
target = groupRepresentatives.get(targetGroupId)!
|
||||
}
|
||||
|
||||
return { ...edge, source, target }
|
||||
})
|
||||
.filter((edge) => layoutBlockIds.has(edge.source) && layoutBlockIds.has(edge.target))
|
||||
|
||||
// Calculate subflow depths before laying out root blocks
|
||||
// This ensures blocks connected to subflow ends are positioned correctly
|
||||
const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers)
|
||||
|
||||
// Store old positions for groups to calculate deltas
|
||||
const oldGroupPositions = new Map<string, { x: number; y: number }>()
|
||||
for (const [groupId, repId] of groupRepresentatives) {
|
||||
oldGroupPositions.set(groupId, { ...blocksCopy[repId].position })
|
||||
}
|
||||
|
||||
if (Object.keys(rootBlocks).length > 0) {
|
||||
const { nodes } = layoutBlocksCore(rootBlocks, rootEdges, {
|
||||
isContainer: false,
|
||||
@@ -69,15 +219,49 @@ export function applyAutoLayout(
|
||||
subflowDepths,
|
||||
})
|
||||
|
||||
// Apply positions to ungrouped blocks and group representatives
|
||||
for (const node of nodes.values()) {
|
||||
blocksCopy[node.id].position = node.position
|
||||
}
|
||||
|
||||
// For each group, calculate the delta and apply to ALL blocks in the group
|
||||
for (const [groupId, repId] of groupRepresentatives) {
|
||||
const oldGroupTopLeft = oldGroupPositions.get(groupId)!
|
||||
const newGroupTopLeft = blocksCopy[repId].position
|
||||
const deltaX = newGroupTopLeft.x - oldGroupTopLeft.x
|
||||
const deltaY = newGroupTopLeft.y - oldGroupTopLeft.y
|
||||
|
||||
const group = groups.get(groupId)!
|
||||
// Apply delta to ALL blocks in the group using their ORIGINAL positions
|
||||
for (const blockId of group.blockIds) {
|
||||
if (layoutRootIds.includes(blockId)) {
|
||||
const originalPos = originalBlockPositions.get(blockId)
|
||||
if (originalPos) {
|
||||
blocksCopy[blockId].position = {
|
||||
x: originalPos.x + deltaX,
|
||||
y: originalPos.y + deltaY,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the representative's original dimensions
|
||||
const originalBlock = blocks[repId]
|
||||
if (originalBlock) {
|
||||
blocksCopy[repId].data = {
|
||||
...blocksCopy[repId].data,
|
||||
width: originalBlock.data?.width,
|
||||
height: originalBlock.data?.height,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layoutContainers(blocksCopy, edges, options)
|
||||
|
||||
logger.info('Auto layout completed successfully', {
|
||||
blockCount: Object.keys(blocksCopy).length,
|
||||
groupCount: groups.size,
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -26,9 +26,53 @@ export interface TargetedLayoutOptions extends LayoutOptions {
|
||||
horizontalSpacing?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies block groups from the blocks' groupId data.
|
||||
* Returns a map of groupId to array of block IDs in that group.
|
||||
*/
|
||||
function identifyBlockGroups(blocks: Record<string, BlockState>): Map<string, string[]> {
|
||||
const groups = new Map<string, string[]>()
|
||||
|
||||
for (const [blockId, block] of Object.entries(blocks)) {
|
||||
const groupId = block.data?.groupId
|
||||
if (!groupId) continue
|
||||
|
||||
if (!groups.has(groupId)) {
|
||||
groups.set(groupId, [])
|
||||
}
|
||||
groups.get(groupId)!.push(blockId)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands changed block IDs to include all blocks in the same group.
|
||||
* If any block in a group changed, all blocks in that group should be treated as changed.
|
||||
*/
|
||||
function expandChangedToGroups(
|
||||
changedBlockIds: string[],
|
||||
blockGroups: Map<string, string[]>,
|
||||
blocks: Record<string, BlockState>
|
||||
): Set<string> {
|
||||
const expandedSet = new Set(changedBlockIds)
|
||||
|
||||
for (const blockId of changedBlockIds) {
|
||||
const groupId = blocks[blockId]?.data?.groupId
|
||||
if (groupId && blockGroups.has(groupId)) {
|
||||
for (const groupBlockId of blockGroups.get(groupId)!) {
|
||||
expandedSet.add(groupBlockId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return expandedSet
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies targeted layout to only reposition changed blocks.
|
||||
* Unchanged blocks act as anchors to preserve existing layout.
|
||||
* Blocks in groups are moved together as a unit.
|
||||
*/
|
||||
export function applyTargetedLayout(
|
||||
blocks: Record<string, BlockState>,
|
||||
@@ -45,9 +89,14 @@ export function applyTargetedLayout(
|
||||
return blocks
|
||||
}
|
||||
|
||||
const changedSet = new Set(changedBlockIds)
|
||||
const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks))
|
||||
|
||||
// Identify block groups
|
||||
const blockGroups = identifyBlockGroups(blocksCopy)
|
||||
|
||||
// Expand changed set to include all blocks in affected groups
|
||||
const changedSet = expandChangedToGroups(changedBlockIds, blockGroups, blocksCopy)
|
||||
|
||||
// Pre-calculate container dimensions by laying out their children (bottom-up)
|
||||
// This ensures accurate widths/heights before root-level layout
|
||||
prepareContainerDimensions(
|
||||
@@ -71,7 +120,8 @@ export function applyTargetedLayout(
|
||||
changedSet,
|
||||
verticalSpacing,
|
||||
horizontalSpacing,
|
||||
subflowDepths
|
||||
subflowDepths,
|
||||
blockGroups
|
||||
)
|
||||
|
||||
for (const [parentId, childIds] of groups.children.entries()) {
|
||||
@@ -83,7 +133,8 @@ export function applyTargetedLayout(
|
||||
changedSet,
|
||||
verticalSpacing,
|
||||
horizontalSpacing,
|
||||
subflowDepths
|
||||
subflowDepths,
|
||||
blockGroups
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,6 +143,7 @@ export function applyTargetedLayout(
|
||||
|
||||
/**
|
||||
* Layouts a group of blocks (either root level or within a container)
|
||||
* Blocks in block groups are moved together as a unit.
|
||||
*/
|
||||
function layoutGroup(
|
||||
parentId: string | null,
|
||||
@@ -101,7 +153,8 @@ function layoutGroup(
|
||||
changedSet: Set<string>,
|
||||
verticalSpacing: number,
|
||||
horizontalSpacing: number,
|
||||
subflowDepths: Map<string, number>
|
||||
subflowDepths: Map<string, number>,
|
||||
blockGroups: Map<string, string[]>
|
||||
): void {
|
||||
if (childIds.length === 0) return
|
||||
|
||||
@@ -141,7 +194,7 @@ function layoutGroup(
|
||||
return
|
||||
}
|
||||
|
||||
// Store old positions for anchor calculation
|
||||
// Store old positions for anchor calculation and group delta tracking
|
||||
const oldPositions = new Map<string, { x: number; y: number }>()
|
||||
for (const id of layoutEligibleChildIds) {
|
||||
const block = blocks[id]
|
||||
@@ -185,14 +238,47 @@ function layoutGroup(
|
||||
}
|
||||
}
|
||||
|
||||
// Track which groups have already had their deltas applied
|
||||
const processedGroups = new Set<string>()
|
||||
|
||||
// Apply new positions only to blocks that need layout
|
||||
for (const id of needsLayout) {
|
||||
const block = blocks[id]
|
||||
const newPos = layoutPositions.get(id)
|
||||
if (!block || !newPos) continue
|
||||
block.position = {
|
||||
x: newPos.x + offsetX,
|
||||
y: newPos.y + offsetY,
|
||||
|
||||
const groupId = block.data?.groupId
|
||||
|
||||
// If this block is in a group, move all blocks in the group together
|
||||
if (groupId && blockGroups.has(groupId) && !processedGroups.has(groupId)) {
|
||||
processedGroups.add(groupId)
|
||||
|
||||
// Calculate the delta for this block (the one that needs layout)
|
||||
const oldPos = oldPositions.get(id)
|
||||
if (oldPos) {
|
||||
const deltaX = newPos.x + offsetX - oldPos.x
|
||||
const deltaY = newPos.y + offsetY - oldPos.y
|
||||
|
||||
// Apply delta to ALL blocks in the group using their original positions
|
||||
for (const groupBlockId of blockGroups.get(groupId)!) {
|
||||
const groupBlock = blocks[groupBlockId]
|
||||
if (groupBlock && layoutEligibleChildIds.includes(groupBlockId)) {
|
||||
const groupOriginalPos = oldPositions.get(groupBlockId)
|
||||
if (groupOriginalPos) {
|
||||
groupBlock.position = {
|
||||
x: groupOriginalPos.x + deltaX,
|
||||
y: groupOriginalPos.y + deltaY,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!groupId) {
|
||||
// Non-grouped block - apply position normally
|
||||
block.position = {
|
||||
x: newPos.x + offsetX,
|
||||
y: newPos.y + offsetY,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,18 @@ export function isContainerType(blockType: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block should be excluded from autolayout
|
||||
* Checks if a block should be excluded from autolayout.
|
||||
* Note blocks are excluded unless they are part of a group.
|
||||
*/
|
||||
export function shouldSkipAutoLayout(block?: BlockState): boolean {
|
||||
export function shouldSkipAutoLayout(block?: BlockState, isInGroup?: boolean): boolean {
|
||||
if (!block) return true
|
||||
return AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)
|
||||
// If the block type is normally excluded (e.g., note), but it's in a group, include it
|
||||
if (AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)) {
|
||||
// Check if block is in a group - if so, include it in layout
|
||||
const blockIsInGroup = isInGroup ?? !!block.data?.groupId
|
||||
return !blockIsInGroup
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user