mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-15 01:47:59 -05:00
Compare commits
5 Commits
fix/copilo
...
feat/group
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66d19c00db | ||
|
|
a45426bb6b | ||
|
|
a3007d8980 | ||
|
|
8ec067d280 | ||
|
|
f04cd7c355 |
@@ -76,6 +76,14 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppress the default selection ring for grouped selections
|
||||||
|
* These blocks show a more transparent ring via the component's ring overlay
|
||||||
|
*/
|
||||||
|
.react-flow__node.selected > div[data-grouped-selection="true"] > div::after {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Color tokens - single source of truth for all colors
|
* Color tokens - single source of truth for all colors
|
||||||
* Light mode: Warm theme
|
* Light mode: Warm theme
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export function BlockContextMenu({
|
|||||||
onRemoveFromSubflow,
|
onRemoveFromSubflow,
|
||||||
onOpenEditor,
|
onOpenEditor,
|
||||||
onRename,
|
onRename,
|
||||||
|
onGroupBlocks,
|
||||||
|
onUngroupBlocks,
|
||||||
hasClipboard = false,
|
hasClipboard = false,
|
||||||
showRemoveFromSubflow = false,
|
showRemoveFromSubflow = false,
|
||||||
disableEdit = false,
|
disableEdit = false,
|
||||||
@@ -47,6 +49,14 @@ export function BlockContextMenu({
|
|||||||
|
|
||||||
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
|
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
|
||||||
|
|
||||||
|
// Check if we can group: need at least 2 blocks selected
|
||||||
|
const canGroup = selectedBlocks.length >= 2
|
||||||
|
|
||||||
|
// Check if we can ungroup: at least one selected block must be in a group
|
||||||
|
// Ungrouping will ungroup all blocks in that group (the entire group, not just selected blocks)
|
||||||
|
const hasGroupedBlock = selectedBlocks.some((b) => !!b.groupId)
|
||||||
|
const canUngroup = hasGroupedBlock
|
||||||
|
|
||||||
const getToggleEnabledLabel = () => {
|
const getToggleEnabledLabel = () => {
|
||||||
if (allEnabled) return 'Disable'
|
if (allEnabled) return 'Disable'
|
||||||
if (allDisabled) return 'Enable'
|
if (allDisabled) return 'Enable'
|
||||||
@@ -141,6 +151,31 @@ export function BlockContextMenu({
|
|||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Block group actions */}
|
||||||
|
{(canGroup || canUngroup) && <PopoverDivider />}
|
||||||
|
{canGroup && (
|
||||||
|
<PopoverItem
|
||||||
|
disabled={disableEdit}
|
||||||
|
onClick={() => {
|
||||||
|
onGroupBlocks()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Group Blocks
|
||||||
|
</PopoverItem>
|
||||||
|
)}
|
||||||
|
{canUngroup && (
|
||||||
|
<PopoverItem
|
||||||
|
disabled={disableEdit}
|
||||||
|
onClick={() => {
|
||||||
|
onUngroupBlocks()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ungroup
|
||||||
|
</PopoverItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Single block actions */}
|
{/* Single block actions */}
|
||||||
{isSingleBlock && <PopoverDivider />}
|
{isSingleBlock && <PopoverDivider />}
|
||||||
{isSingleBlock && !isSubflow && (
|
{isSingleBlock && !isSubflow && (
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface ContextMenuBlockInfo {
|
|||||||
parentId?: string
|
parentId?: string
|
||||||
/** Parent type ('loop' | 'parallel') if nested */
|
/** Parent type ('loop' | 'parallel') if nested */
|
||||||
parentType?: string
|
parentType?: string
|
||||||
|
/** Group ID if block is in a group */
|
||||||
|
groupId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +52,8 @@ export interface BlockContextMenuProps {
|
|||||||
onRemoveFromSubflow: () => void
|
onRemoveFromSubflow: () => void
|
||||||
onOpenEditor: () => void
|
onOpenEditor: () => void
|
||||||
onRename: () => void
|
onRename: () => void
|
||||||
|
onGroupBlocks: () => void
|
||||||
|
onUngroupBlocks: () => void
|
||||||
/** Whether clipboard has content for pasting */
|
/** Whether clipboard has content for pasting */
|
||||||
hasClipboard?: boolean
|
hasClipboard?: boolean
|
||||||
/** Whether remove from subflow option should be shown */
|
/** Whether remove from subflow option should be shown */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo, useCallback, useMemo } from 'react'
|
import { memo, useCallback, useMemo } from 'react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import type { NodeProps } from 'reactflow'
|
import { type NodeProps, useReactFlow } from 'reactflow'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
useBlockDimensions,
|
useBlockDimensions,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||||
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
import { ActionBar } from '../workflow-block/components'
|
import { ActionBar } from '../workflow-block/components'
|
||||||
import type { WorkflowBlockProps } from '../workflow-block/types'
|
import type { WorkflowBlockProps } from '../workflow-block/types'
|
||||||
|
|
||||||
@@ -198,6 +199,57 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
|
|||||||
|
|
||||||
const userPermissions = useUserPermissionsContext()
|
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.
|
* Calculate deterministic dimensions based on content structure.
|
||||||
* Uses fixed width and computed height to avoid ResizeObserver jitter.
|
* Uses fixed width and computed height to avoid ResizeObserver jitter.
|
||||||
@@ -216,8 +268,14 @@ export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlo
|
|||||||
dependencies: [isEmpty],
|
dependencies: [isEmpty],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isGroupedSelection = data.isGroupedSelection ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='group relative'>
|
<div
|
||||||
|
className='group relative'
|
||||||
|
data-grouped-selection={isGroupedSelection ? 'true' : undefined}
|
||||||
|
onMouseDown={handleGroupMouseDown}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)]'
|
'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 { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||||
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
||||||
import { Button, Trash } from '@/components/emcn'
|
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 { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||||
import { usePanelEditorStore } from '@/stores/panel'
|
import { usePanelEditorStore } from '@/stores/panel'
|
||||||
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global styles for subflow nodes (loop and parallel containers).
|
* Global styles for subflow nodes (loop and parallel containers).
|
||||||
@@ -51,6 +52,8 @@ export interface SubflowNodeData {
|
|||||||
isPreviewSelected?: boolean
|
isPreviewSelected?: boolean
|
||||||
kind: 'loop' | 'parallel'
|
kind: 'loop' | 'parallel'
|
||||||
name?: string
|
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
|
* @returns Rendered subflow node component
|
||||||
*/
|
*/
|
||||||
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
||||||
const { getNodes } = useReactFlow()
|
const { getNodes, setNodes } = useReactFlow()
|
||||||
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
|
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
|
||||||
|
const { getGroups } = useWorkflowStore()
|
||||||
const blockRef = useRef<HTMLDivElement>(null)
|
const blockRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const currentWorkflow = useCurrentWorkflow()
|
const currentWorkflow = useCurrentWorkflow()
|
||||||
@@ -140,10 +144,57 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
|||||||
diffStatus === 'edited' && 'ring-[var(--warning)]'
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SubflowNodeStyles />
|
<SubflowNodeStyles />
|
||||||
<div className='group relative'>
|
<div className='group relative' onMouseDown={handleGroupMouseDown}>
|
||||||
<div
|
<div
|
||||||
ref={blockRef}
|
ref={blockRef}
|
||||||
onClick={() => setCurrentBlockId(id)}
|
onClick={() => setCurrentBlockId(id)}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const ActionBar = memo(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'-top-[46px] absolute right-0',
|
'-top-[46px] absolute right-0 z-[100]',
|
||||||
'flex flex-row items-center',
|
'flex flex-row items-center',
|
||||||
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
|
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
|
||||||
'gap-[5px] rounded-[10px] bg-[var(--surface-4)] p-[5px]'
|
'gap-[5px] rounded-[10px] bg-[var(--surface-4)] p-[5px]'
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export interface WorkflowBlockProps {
|
|||||||
isPreview?: boolean
|
isPreview?: boolean
|
||||||
/** Whether this block is selected in preview mode */
|
/** Whether this block is selected in preview mode */
|
||||||
isPreviewSelected?: boolean
|
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>
|
subBlockValues?: Record<string, any>
|
||||||
blockState?: any
|
blockState?: any
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { useParams } from 'next/navigation'
|
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 { Badge, Tooltip } from '@/components/emcn'
|
||||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
@@ -915,8 +915,65 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
|||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
|
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className='group relative'>
|
<div
|
||||||
|
className='group relative'
|
||||||
|
data-grouped-selection={isGroupedSelection ? 'true' : undefined}
|
||||||
|
onMouseDown={handleGroupMouseDown}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface UseBlockVisualProps {
|
|||||||
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
|
export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) {
|
||||||
const isPreview = data.isPreview ?? false
|
const isPreview = data.isPreview ?? false
|
||||||
const isPreviewSelected = data.isPreviewSelected ?? false
|
const isPreviewSelected = data.isPreviewSelected ?? false
|
||||||
|
const isGroupedSelection = data.isGroupedSelection ?? false
|
||||||
|
|
||||||
const currentWorkflow = useCurrentWorkflow()
|
const currentWorkflow = useCurrentWorkflow()
|
||||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||||
@@ -64,8 +65,18 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
|||||||
diffStatus: isPreview ? undefined : diffStatus,
|
diffStatus: isPreview ? undefined : diffStatus,
|
||||||
runPathStatus,
|
runPathStatus,
|
||||||
isPreviewSelection: isPreview && isPreviewSelected,
|
isPreviewSelection: isPreview && isPreviewSelected,
|
||||||
|
isGroupedSelection: !isPreview && isGroupedSelection,
|
||||||
}),
|
}),
|
||||||
[isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected]
|
[
|
||||||
|
isActive,
|
||||||
|
isPending,
|
||||||
|
isDeletedBlock,
|
||||||
|
diffStatus,
|
||||||
|
runPathStatus,
|
||||||
|
isPreview,
|
||||||
|
isPreviewSelected,
|
||||||
|
isGroupedSelection,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
|
|||||||
const block = blocks[n.id]
|
const block = blocks[n.id]
|
||||||
const parentId = block?.data?.parentId
|
const parentId = block?.data?.parentId
|
||||||
const parentType = parentId ? blocks[parentId]?.type : undefined
|
const parentType = parentId ? blocks[parentId]?.type : undefined
|
||||||
|
const groupId = block?.data?.groupId
|
||||||
return {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: block?.type || '',
|
type: block?.type || '',
|
||||||
@@ -42,6 +43,7 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
|
|||||||
horizontalHandles: block?.horizontalHandles ?? false,
|
horizontalHandles: block?.horizontalHandles ?? false,
|
||||||
parentId,
|
parentId,
|
||||||
parentType,
|
parentType,
|
||||||
|
groupId,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[blocks]
|
[blocks]
|
||||||
@@ -49,14 +51,22 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle right-click on a node (block)
|
* Handle right-click on a node (block)
|
||||||
|
* If the node is part of a multiselection, include all selected nodes.
|
||||||
|
* If the node is not selected, just use that node.
|
||||||
*/
|
*/
|
||||||
const handleNodeContextMenu = useCallback(
|
const handleNodeContextMenu = useCallback(
|
||||||
(event: React.MouseEvent, node: Node) => {
|
(event: React.MouseEvent, node: Node) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
const selectedNodes = getNodes().filter((n) => n.selected)
|
// Get all currently selected nodes
|
||||||
const nodesToUse = selectedNodes.some((n) => n.id === node.id) ? selectedNodes : [node]
|
const allNodes = getNodes()
|
||||||
|
const selectedNodes = allNodes.filter((n) => n.selected)
|
||||||
|
|
||||||
|
// If the right-clicked node is already selected, use all selected nodes
|
||||||
|
// Otherwise, just use the right-clicked node
|
||||||
|
const isNodeSelected = selectedNodes.some((n) => n.id === node.id)
|
||||||
|
const nodesToUse = isNodeSelected && selectedNodes.length > 0 ? selectedNodes : [node]
|
||||||
|
|
||||||
setPosition({ x: event.clientX, y: event.clientY })
|
setPosition({ x: event.clientX, y: event.clientY })
|
||||||
setSelectedBlocks(nodesToBlockInfos(nodesToUse))
|
setSelectedBlocks(nodesToBlockInfos(nodesToUse))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface BlockRingOptions {
|
|||||||
diffStatus: BlockDiffStatus
|
diffStatus: BlockDiffStatus
|
||||||
runPathStatus: BlockRunPathStatus
|
runPathStatus: BlockRunPathStatus
|
||||||
isPreviewSelection?: boolean
|
isPreviewSelection?: boolean
|
||||||
|
isGroupedSelection?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,8 +22,15 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
|||||||
hasRing: boolean
|
hasRing: boolean
|
||||||
ringClassName: string
|
ringClassName: string
|
||||||
} {
|
} {
|
||||||
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } =
|
const {
|
||||||
options
|
isActive,
|
||||||
|
isPending,
|
||||||
|
isDeletedBlock,
|
||||||
|
diffStatus,
|
||||||
|
runPathStatus,
|
||||||
|
isPreviewSelection,
|
||||||
|
isGroupedSelection,
|
||||||
|
} = options
|
||||||
|
|
||||||
const hasRing =
|
const hasRing =
|
||||||
isActive ||
|
isActive ||
|
||||||
@@ -30,17 +38,24 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
|||||||
diffStatus === 'new' ||
|
diffStatus === 'new' ||
|
||||||
diffStatus === 'edited' ||
|
diffStatus === 'edited' ||
|
||||||
isDeletedBlock ||
|
isDeletedBlock ||
|
||||||
!!runPathStatus
|
!!runPathStatus ||
|
||||||
|
!!isGroupedSelection
|
||||||
|
|
||||||
const ringClassName = cn(
|
const ringClassName = cn(
|
||||||
|
// Grouped selection: more transparent ring for blocks selected as part of a group
|
||||||
|
// Using rgba with the brand-secondary color (#33b4ff) at 40% opacity
|
||||||
|
isGroupedSelection &&
|
||||||
|
!isActive &&
|
||||||
|
'ring-[2px] ring-[rgba(51,180,255,0.4)]',
|
||||||
// Preview selection: static blue ring (standard thickness, no animation)
|
// Preview selection: static blue ring (standard thickness, no animation)
|
||||||
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
|
isActive && isPreviewSelection && 'ring-[1.75px] ring-[var(--brand-secondary)]',
|
||||||
// Executing block: pulsing success ring with prominent thickness
|
// Executing block: pulsing success ring with prominent thickness
|
||||||
isActive &&
|
isActive &&
|
||||||
!isPreviewSelection &&
|
!isPreviewSelection &&
|
||||||
|
!isGroupedSelection &&
|
||||||
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse',
|
||||||
// Non-active states use standard ring utilities
|
// Non-active states use standard ring utilities (except grouped selection which has its own)
|
||||||
!isActive && hasRing && 'ring-[1.75px]',
|
!isActive && hasRing && !isGroupedSelection && 'ring-[1.75px]',
|
||||||
// Pending state: warning ring
|
// Pending state: warning ring
|
||||||
!isActive && isPending && 'ring-[var(--warning)]',
|
!isActive && isPending && 'ring-[var(--warning)]',
|
||||||
// Deleted state (highest priority after active/pending)
|
// Deleted state (highest priority after active/pending)
|
||||||
|
|||||||
@@ -264,12 +264,14 @@ const WorkflowContent = React.memo(() => {
|
|||||||
const canUndo = undoRedoStack.undo.length > 0
|
const canUndo = undoRedoStack.undo.length > 0
|
||||||
const canRedo = undoRedoStack.redo.length > 0
|
const canRedo = undoRedoStack.redo.length > 0
|
||||||
|
|
||||||
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition } = useWorkflowStore(
|
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition, getGroups } =
|
||||||
useShallow((state) => ({
|
useWorkflowStore(
|
||||||
updateNodeDimensions: state.updateNodeDimensions,
|
useShallow((state) => ({
|
||||||
setDragStartPosition: state.setDragStartPosition,
|
updateNodeDimensions: state.updateNodeDimensions,
|
||||||
getDragStartPosition: state.getDragStartPosition,
|
setDragStartPosition: state.setDragStartPosition,
|
||||||
}))
|
getDragStartPosition: state.getDragStartPosition,
|
||||||
|
getGroups: state.getGroups,
|
||||||
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
const copilotCleanup = useCopilotStore((state) => state.cleanup)
|
const copilotCleanup = useCopilotStore((state) => state.cleanup)
|
||||||
@@ -361,6 +363,14 @@ const WorkflowContent = React.memo(() => {
|
|||||||
new Map()
|
new Map()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores original positions and parentIds for nodes temporarily parented during group drag.
|
||||||
|
* Key: node ID, Value: { originalPosition, originalParentId }
|
||||||
|
*/
|
||||||
|
const groupDragTempParentsRef = useRef<
|
||||||
|
Map<string, { originalPosition: { x: number; y: number }; originalParentId?: string }>
|
||||||
|
>(new Map())
|
||||||
|
|
||||||
/** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */
|
/** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */
|
||||||
const pendingSelectionRef = useRef<Set<string> | null>(null)
|
const pendingSelectionRef = useRef<Set<string> | null>(null)
|
||||||
|
|
||||||
@@ -458,6 +468,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
collaborativeBatchRemoveBlocks,
|
collaborativeBatchRemoveBlocks,
|
||||||
collaborativeBatchToggleBlockEnabled,
|
collaborativeBatchToggleBlockEnabled,
|
||||||
collaborativeBatchToggleBlockHandles,
|
collaborativeBatchToggleBlockHandles,
|
||||||
|
collaborativeGroupBlocks,
|
||||||
|
collaborativeUngroupBlocks,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
} = useCollaborativeWorkflow()
|
} = useCollaborativeWorkflow()
|
||||||
@@ -782,6 +794,35 @@ const WorkflowContent = React.memo(() => {
|
|||||||
collaborativeBatchToggleBlockHandles(blockIds)
|
collaborativeBatchToggleBlockHandles(blockIds)
|
||||||
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
|
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
|
||||||
|
|
||||||
|
const handleContextGroupBlocks = useCallback(() => {
|
||||||
|
const blockIds = contextMenuBlocks.map((block) => block.id)
|
||||||
|
if (blockIds.length >= 2) {
|
||||||
|
// Validate that all blocks share the same parent (or all have no parent)
|
||||||
|
// Blocks inside a subflow cannot be grouped with blocks outside that subflow
|
||||||
|
const parentIds = contextMenuBlocks.map((block) => block.parentId || null)
|
||||||
|
const uniqueParentIds = new Set(parentIds)
|
||||||
|
if (uniqueParentIds.size > 1) {
|
||||||
|
addNotification({
|
||||||
|
level: 'error',
|
||||||
|
message: 'Cannot group blocks from different subflows',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collaborativeGroupBlocks(blockIds)
|
||||||
|
}
|
||||||
|
}, [contextMenuBlocks, collaborativeGroupBlocks, addNotification])
|
||||||
|
|
||||||
|
const handleContextUngroupBlocks = useCallback(() => {
|
||||||
|
// Find the first block with a groupId
|
||||||
|
const groupedBlock = contextMenuBlocks.find((block) => block.groupId)
|
||||||
|
if (!groupedBlock?.groupId) return
|
||||||
|
|
||||||
|
// The block's groupId is the group we want to ungroup
|
||||||
|
// This is the direct group the block belongs to, which is the "top level" from the user's perspective
|
||||||
|
// (the most recently created group that contains this block)
|
||||||
|
collaborativeUngroupBlocks(groupedBlock.groupId)
|
||||||
|
}, [contextMenuBlocks, collaborativeUngroupBlocks])
|
||||||
|
|
||||||
const handleContextRemoveFromSubflow = useCallback(() => {
|
const handleContextRemoveFromSubflow = useCallback(() => {
|
||||||
const blocksToRemove = contextMenuBlocks.filter(
|
const blocksToRemove = contextMenuBlocks.filter(
|
||||||
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
|
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
|
||||||
@@ -1906,6 +1947,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
name: block.name,
|
name: block.name,
|
||||||
isActive,
|
isActive,
|
||||||
isPending,
|
isPending,
|
||||||
|
groupId: block.data?.groupId,
|
||||||
},
|
},
|
||||||
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||||
@@ -2060,16 +2102,56 @@ const WorkflowContent = React.memo(() => {
|
|||||||
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
|
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
|
||||||
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
|
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
|
||||||
|
|
||||||
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
|
/** Handles node changes - applies changes and resolves parent-child selection conflicts.
|
||||||
|
* Also expands selection to include all group members when a grouped block is selected.
|
||||||
|
*/
|
||||||
const onNodesChange = useCallback(
|
const onNodesChange = useCallback(
|
||||||
(changes: NodeChange[]) => {
|
(changes: NodeChange[]) => {
|
||||||
setDisplayNodes((nds) => {
|
setDisplayNodes((nds) => {
|
||||||
const updated = applyNodeChanges(changes, nds)
|
let updated = applyNodeChanges(changes, nds)
|
||||||
const hasSelectionChange = changes.some((c) => c.type === 'select')
|
const hasSelectionChange = changes.some((c) => c.type === 'select')
|
||||||
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
|
|
||||||
|
if (hasSelectionChange) {
|
||||||
|
// Expand selection to include all group members
|
||||||
|
const groups = getGroups()
|
||||||
|
const selectedNodeIds = new Set(updated.filter((n) => n.selected).map((n) => n.id))
|
||||||
|
const groupsToInclude = new Set<string>()
|
||||||
|
|
||||||
|
// Find all groups that have at least one selected member
|
||||||
|
selectedNodeIds.forEach((nodeId) => {
|
||||||
|
const groupId = blocks[nodeId]?.data?.groupId
|
||||||
|
if (groupId && groups[groupId]) {
|
||||||
|
groupsToInclude.add(groupId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add all blocks from those groups to the selection
|
||||||
|
if (groupsToInclude.size > 0) {
|
||||||
|
const expandedNodeIds = new Set(selectedNodeIds)
|
||||||
|
groupsToInclude.forEach((groupId) => {
|
||||||
|
const group = groups[groupId]
|
||||||
|
if (group) {
|
||||||
|
group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update nodes to include expanded selection
|
||||||
|
if (expandedNodeIds.size > selectedNodeIds.size) {
|
||||||
|
updated = updated.map((n) => ({
|
||||||
|
...n,
|
||||||
|
selected: expandedNodeIds.has(n.id) ? true : n.selected,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve parent-child conflicts
|
||||||
|
updated = resolveParentChildSelectionConflicts(updated, blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[blocks]
|
[blocks, getGroups]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2530,9 +2612,55 @@ const WorkflowContent = React.memo(() => {
|
|||||||
parentId: currentParentId,
|
parentId: currentParentId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Capture all selected nodes' positions for multi-node undo/redo
|
// Expand selection to include all group members before capturing positions
|
||||||
|
const groups = getGroups()
|
||||||
const allNodes = getNodes()
|
const allNodes = getNodes()
|
||||||
const selectedNodes = allNodes.filter((n) => n.selected)
|
|
||||||
|
// Find the group of the dragged node
|
||||||
|
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
|
||||||
|
|
||||||
|
// If the dragged node is in a group, expand selection to include all group members
|
||||||
|
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
|
||||||
|
const group = groups[draggedBlockGroupId]
|
||||||
|
const groupBlockIds = new Set(group.blockIds)
|
||||||
|
|
||||||
|
// Check if we need to expand selection
|
||||||
|
const currentSelectedIds = new Set(allNodes.filter((n) => n.selected).map((n) => n.id))
|
||||||
|
const needsExpansion = [...groupBlockIds].some((id) => !currentSelectedIds.has(id))
|
||||||
|
|
||||||
|
if (needsExpansion) {
|
||||||
|
setNodes((nodes) =>
|
||||||
|
nodes.map((n) => {
|
||||||
|
const isInGroup = groupBlockIds.has(n.id)
|
||||||
|
const isDirectlyDragged = n.id === node.id
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
selected: isInGroup ? true : n.selected,
|
||||||
|
data: {
|
||||||
|
...n.data,
|
||||||
|
// Mark as grouped selection if in group but not the directly dragged node
|
||||||
|
isGroupedSelection: isInGroup && !isDirectlyDragged && !n.selected,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture all selected nodes' positions for multi-node undo/redo
|
||||||
|
// 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]) {
|
||||||
|
return groups[draggedBlockGroupId].blockIds.includes(n.id)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
multiNodeDragStartRef.current.clear()
|
multiNodeDragStartRef.current.clear()
|
||||||
selectedNodes.forEach((n) => {
|
selectedNodes.forEach((n) => {
|
||||||
const block = blocks[n.id]
|
const block = blocks[n.id]
|
||||||
@@ -2544,8 +2672,63 @@ const WorkflowContent = React.memo(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Set up temporary parent-child relationships for group members
|
||||||
|
// This leverages React Flow's built-in parent-child drag behavior
|
||||||
|
// BUT: Only do this if NOT all group members are already selected
|
||||||
|
// If all are selected, React Flow's native multiselect drag will handle it
|
||||||
|
groupDragTempParentsRef.current.clear()
|
||||||
|
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
|
||||||
|
const group = groups[draggedBlockGroupId]
|
||||||
|
if (group.blockIds.length > 1) {
|
||||||
|
// Check if all group members are already selected
|
||||||
|
const allGroupMembersSelected = group.blockIds.every((blockId) =>
|
||||||
|
updatedNodes.find((n) => n.id === blockId && n.selected)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only use temporary parent approach if NOT all members are selected
|
||||||
|
// (i.e., when click-and-dragging on an unselected grouped block)
|
||||||
|
if (!allGroupMembersSelected) {
|
||||||
|
// Get the dragged node's absolute position for calculating relative positions
|
||||||
|
const draggedNodeAbsPos = getNodeAbsolutePosition(node.id)
|
||||||
|
|
||||||
|
setNodes((nodes) =>
|
||||||
|
nodes.map((n) => {
|
||||||
|
// Skip the dragged node - it becomes the temporary parent
|
||||||
|
if (n.id === node.id) return n
|
||||||
|
|
||||||
|
// Only process nodes in the same group
|
||||||
|
if (group.blockIds.includes(n.id)) {
|
||||||
|
// Store original position and parentId for restoration later
|
||||||
|
groupDragTempParentsRef.current.set(n.id, {
|
||||||
|
originalPosition: { ...n.position },
|
||||||
|
originalParentId: n.parentId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get this node's absolute position
|
||||||
|
const nodeAbsPos = getNodeAbsolutePosition(n.id)
|
||||||
|
|
||||||
|
// Calculate position relative to the dragged node
|
||||||
|
const relativePosition = {
|
||||||
|
x: nodeAbsPos.x - draggedNodeAbsPos.x,
|
||||||
|
y: nodeAbsPos.y - draggedNodeAbsPos.y,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
parentId: node.id, // Temporarily make this a child of the dragged node
|
||||||
|
position: relativePosition,
|
||||||
|
extent: undefined, // Remove extent constraint during drag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId]
|
[blocks, setDragStartPosition, getNodes, setNodes, getGroups, potentialParentId, setPotentialParentId, getNodeAbsolutePosition]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Handles node drag stop to establish parent-child relationships. */
|
/** Handles node drag stop to establish parent-child relationships. */
|
||||||
@@ -2553,13 +2736,93 @@ const WorkflowContent = React.memo(() => {
|
|||||||
(_event: React.MouseEvent, node: any) => {
|
(_event: React.MouseEvent, node: any) => {
|
||||||
clearDragHighlights()
|
clearDragHighlights()
|
||||||
|
|
||||||
|
// Compute absolute positions for group members before restoring parentIds
|
||||||
|
// We need to do this first because getNodes() will return stale data after setNodes
|
||||||
|
const computedGroupPositions = new Map<string, { x: number; y: number }>()
|
||||||
|
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
|
||||||
|
|
||||||
|
if (groupDragTempParentsRef.current.size > 0) {
|
||||||
|
const draggedNodeAbsPos = getNodeAbsolutePosition(node.id)
|
||||||
|
const currentNodes = getNodes()
|
||||||
|
|
||||||
|
// Compute absolute positions for all temporarily parented nodes
|
||||||
|
for (const [nodeId, _tempData] of groupDragTempParentsRef.current) {
|
||||||
|
const nodeData = currentNodes.find((n) => n.id === nodeId)
|
||||||
|
if (nodeData) {
|
||||||
|
// The node's current position is relative to the dragged node
|
||||||
|
computedGroupPositions.set(nodeId, {
|
||||||
|
x: draggedNodeAbsPos.x + nodeData.position.x,
|
||||||
|
y: draggedNodeAbsPos.y + nodeData.position.y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also store the dragged node's absolute position
|
||||||
|
computedGroupPositions.set(node.id, draggedNodeAbsPos)
|
||||||
|
|
||||||
|
// Restore temporary parent-child relationships
|
||||||
|
setNodes((nodes) =>
|
||||||
|
nodes.map((n) => {
|
||||||
|
const tempData = groupDragTempParentsRef.current.get(n.id)
|
||||||
|
if (tempData) {
|
||||||
|
const absolutePosition = computedGroupPositions.get(n.id) || n.position
|
||||||
|
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
parentId: tempData.originalParentId,
|
||||||
|
position: absolutePosition,
|
||||||
|
extent: tempData.originalParentId ? ('parent' as const) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
)
|
||||||
|
groupDragTempParentsRef.current.clear()
|
||||||
|
}
|
||||||
|
|
||||||
// Get all selected nodes to update their positions too
|
// Get all selected nodes to update their positions too
|
||||||
const allNodes = getNodes()
|
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
|
||||||
|
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) {
|
if (selectedNodes.length > 1) {
|
||||||
const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
|
// Use pre-computed positions for group members, otherwise use computeClampedPositionUpdates
|
||||||
|
let positionUpdates: Array<{ id: string; position: { x: number; y: number } }>
|
||||||
|
|
||||||
|
if (computedGroupPositions.size > 0) {
|
||||||
|
// For group drags, use the pre-computed absolute positions
|
||||||
|
positionUpdates = selectedNodes.map((n) => {
|
||||||
|
const precomputedPos = computedGroupPositions.get(n.id)
|
||||||
|
if (precomputedPos) {
|
||||||
|
return { id: n.id, position: precomputedPos }
|
||||||
|
}
|
||||||
|
// For non-group members, use current position
|
||||||
|
return { id: n.id, position: n.position }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
|
||||||
|
}
|
||||||
collaborativeBatchUpdatePositions(positionUpdates, {
|
collaborativeBatchUpdatePositions(positionUpdates, {
|
||||||
previousPositions: multiNodeDragStartRef.current,
|
previousPositions: multiNodeDragStartRef.current,
|
||||||
})
|
})
|
||||||
@@ -2843,6 +3106,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
getNodes,
|
getNodes,
|
||||||
|
setNodes,
|
||||||
dragStartParentId,
|
dragStartParentId,
|
||||||
potentialParentId,
|
potentialParentId,
|
||||||
updateNodeParent,
|
updateNodeParent,
|
||||||
@@ -2861,6 +3125,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
collaborativeBatchUpdatePositions,
|
collaborativeBatchUpdatePositions,
|
||||||
collaborativeBatchUpdateParent,
|
collaborativeBatchUpdateParent,
|
||||||
|
getGroups,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3168,19 +3433,81 @@ const WorkflowContent = React.memo(() => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles node click to select the node in ReactFlow.
|
* Handles node click to select the node in ReactFlow.
|
||||||
|
* When clicking on a grouped block, also selects all other blocks in the group.
|
||||||
|
* Grouped blocks are marked with isGroupedSelection for different visual styling.
|
||||||
* Parent-child conflict resolution happens automatically in onNodesChange.
|
* Parent-child conflict resolution happens automatically in onNodesChange.
|
||||||
*/
|
*/
|
||||||
const handleNodeClick = useCallback(
|
const handleNodeClick = useCallback(
|
||||||
(event: React.MouseEvent, node: Node) => {
|
(event: React.MouseEvent, node: Node) => {
|
||||||
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
|
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
|
||||||
setNodes((nodes) =>
|
const groups = getGroups()
|
||||||
nodes.map((n) => ({
|
|
||||||
...n,
|
// Track which nodes are directly clicked vs. group-expanded
|
||||||
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
|
const directlySelectedIds = new Set<string>()
|
||||||
}))
|
|
||||||
)
|
setNodes((nodes) => {
|
||||||
|
// First, calculate the base selection
|
||||||
|
let updatedNodes = nodes.map((n) => {
|
||||||
|
const isDirectlySelected = isMultiSelect
|
||||||
|
? n.id === node.id
|
||||||
|
? true
|
||||||
|
: n.selected
|
||||||
|
: n.id === node.id
|
||||||
|
if (isDirectlySelected) {
|
||||||
|
directlySelectedIds.add(n.id)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
selected: isDirectlySelected,
|
||||||
|
data: {
|
||||||
|
...n.data,
|
||||||
|
isGroupedSelection: false, // Reset grouped selection flag
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Expand selection to include all group members
|
||||||
|
const selectedNodeIds = new Set(updatedNodes.filter((n) => n.selected).map((n) => n.id))
|
||||||
|
const groupsToInclude = new Set<string>()
|
||||||
|
|
||||||
|
// Find all groups that have at least one selected member
|
||||||
|
selectedNodeIds.forEach((nodeId) => {
|
||||||
|
const groupId = blocks[nodeId]?.data?.groupId
|
||||||
|
if (groupId && groups[groupId]) {
|
||||||
|
groupsToInclude.add(groupId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add all blocks from those groups to the selection
|
||||||
|
if (groupsToInclude.size > 0) {
|
||||||
|
const expandedNodeIds = new Set(selectedNodeIds)
|
||||||
|
groupsToInclude.forEach((groupId) => {
|
||||||
|
const group = groups[groupId]
|
||||||
|
if (group) {
|
||||||
|
group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update nodes with expanded selection, marking group-expanded nodes
|
||||||
|
if (expandedNodeIds.size > selectedNodeIds.size) {
|
||||||
|
updatedNodes = updatedNodes.map((n) => {
|
||||||
|
const isGroupExpanded = expandedNodeIds.has(n.id) && !directlySelectedIds.has(n.id)
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
selected: expandedNodeIds.has(n.id) ? true : n.selected,
|
||||||
|
data: {
|
||||||
|
...n.data,
|
||||||
|
isGroupedSelection: isGroupExpanded,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedNodes
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[setNodes]
|
[setNodes, blocks, getGroups]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
|
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
|
||||||
@@ -3415,6 +3742,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
onRemoveFromSubflow={handleContextRemoveFromSubflow}
|
onRemoveFromSubflow={handleContextRemoveFromSubflow}
|
||||||
onOpenEditor={handleContextOpenEditor}
|
onOpenEditor={handleContextOpenEditor}
|
||||||
onRename={handleContextRename}
|
onRename={handleContextRename}
|
||||||
|
onGroupBlocks={handleContextGroupBlocks}
|
||||||
|
onUngroupBlocks={handleContextUngroupBlocks}
|
||||||
hasClipboard={hasClipboard()}
|
hasClipboard={hasClipboard()}
|
||||||
showRemoveFromSubflow={contextMenuBlocks.some(
|
showRemoveFromSubflow={contextMenuBlocks.some(
|
||||||
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
|
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
|
||||||
|
|||||||
@@ -424,6 +424,35 @@ export function useCollaborativeWorkflow() {
|
|||||||
logger.info('Successfully applied batch-update-parent from remote user')
|
logger.info('Successfully applied batch-update-parent from remote user')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case BLOCKS_OPERATIONS.GROUP_BLOCKS: {
|
||||||
|
const { blockIds, groupId } = payload
|
||||||
|
logger.info('Received group-blocks from remote user', {
|
||||||
|
userId,
|
||||||
|
groupId,
|
||||||
|
blockCount: (blockIds || []).length,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (blockIds && blockIds.length > 0 && groupId) {
|
||||||
|
workflowStore.groupBlocks(blockIds, groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Successfully applied group-blocks from remote user')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: {
|
||||||
|
const { groupId } = payload
|
||||||
|
logger.info('Received ungroup-blocks from remote user', {
|
||||||
|
userId,
|
||||||
|
groupId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (groupId) {
|
||||||
|
workflowStore.ungroupBlocks(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Successfully applied ungroup-blocks from remote user')
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1584,6 +1613,83 @@ export function useCollaborativeWorkflow() {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const collaborativeGroupBlocks = useCallback(
|
||||||
|
(blockIds: string[]) => {
|
||||||
|
if (!isInActiveRoom()) {
|
||||||
|
logger.debug('Skipping group blocks - not in active workflow')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockIds.length < 2) {
|
||||||
|
logger.debug('Cannot group fewer than 2 blocks')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = crypto.randomUUID()
|
||||||
|
|
||||||
|
const operationId = crypto.randomUUID()
|
||||||
|
|
||||||
|
addToQueue({
|
||||||
|
id: operationId,
|
||||||
|
operation: {
|
||||||
|
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
|
||||||
|
target: OPERATION_TARGETS.BLOCKS,
|
||||||
|
payload: { blockIds, groupId },
|
||||||
|
},
|
||||||
|
workflowId: activeWorkflowId || '',
|
||||||
|
userId: session?.user?.id || 'unknown',
|
||||||
|
})
|
||||||
|
|
||||||
|
workflowStore.groupBlocks(blockIds, groupId)
|
||||||
|
|
||||||
|
undoRedo.recordGroupBlocks(blockIds, groupId)
|
||||||
|
|
||||||
|
logger.info('Grouped blocks collaboratively', { groupId, blockCount: blockIds.length })
|
||||||
|
return groupId
|
||||||
|
},
|
||||||
|
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
|
||||||
|
)
|
||||||
|
|
||||||
|
const collaborativeUngroupBlocks = useCallback(
|
||||||
|
(groupId: string) => {
|
||||||
|
if (!isInActiveRoom()) {
|
||||||
|
logger.debug('Skipping ungroup blocks - not in active workflow')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = workflowStore.getGroups()
|
||||||
|
const group = groups[groupId]
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
logger.warn('Cannot ungroup - group not found', { groupId })
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockIds = [...group.blockIds]
|
||||||
|
|
||||||
|
const operationId = crypto.randomUUID()
|
||||||
|
|
||||||
|
addToQueue({
|
||||||
|
id: operationId,
|
||||||
|
operation: {
|
||||||
|
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
|
||||||
|
target: OPERATION_TARGETS.BLOCKS,
|
||||||
|
payload: { groupId, blockIds },
|
||||||
|
},
|
||||||
|
workflowId: activeWorkflowId || '',
|
||||||
|
userId: session?.user?.id || 'unknown',
|
||||||
|
})
|
||||||
|
|
||||||
|
workflowStore.ungroupBlocks(groupId)
|
||||||
|
|
||||||
|
undoRedo.recordUngroupBlocks(groupId, blockIds)
|
||||||
|
|
||||||
|
logger.info('Ungrouped blocks collaboratively', { groupId, blockCount: blockIds.length })
|
||||||
|
return blockIds
|
||||||
|
},
|
||||||
|
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Connection status
|
// Connection status
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -1622,6 +1728,10 @@ export function useCollaborativeWorkflow() {
|
|||||||
collaborativeUpdateIterationCount,
|
collaborativeUpdateIterationCount,
|
||||||
collaborativeUpdateIterationCollection,
|
collaborativeUpdateIterationCollection,
|
||||||
|
|
||||||
|
// Collaborative block group operations
|
||||||
|
collaborativeGroupBlocks,
|
||||||
|
collaborativeUngroupBlocks,
|
||||||
|
|
||||||
// Direct access to stores for non-collaborative operations
|
// Direct access to stores for non-collaborative operations
|
||||||
workflowStore,
|
workflowStore,
|
||||||
subBlockStore,
|
subBlockStore,
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ import {
|
|||||||
type BatchToggleHandlesOperation,
|
type BatchToggleHandlesOperation,
|
||||||
type BatchUpdateParentOperation,
|
type BatchUpdateParentOperation,
|
||||||
createOperationEntry,
|
createOperationEntry,
|
||||||
|
type GroupBlocksOperation,
|
||||||
runWithUndoRedoRecordingSuspended,
|
runWithUndoRedoRecordingSuspended,
|
||||||
|
type UngroupBlocksOperation,
|
||||||
type UpdateParentOperation,
|
type UpdateParentOperation,
|
||||||
useUndoRedoStore,
|
useUndoRedoStore,
|
||||||
} from '@/stores/undo-redo'
|
} from '@/stores/undo-redo'
|
||||||
@@ -874,6 +876,46 @@ export function useUndoRedo() {
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
|
||||||
|
// Undoing group = ungroup (inverse is ungroup operation)
|
||||||
|
const inverseOp = entry.inverse as unknown as UngroupBlocksOperation
|
||||||
|
const { groupId } = inverseOp.data
|
||||||
|
|
||||||
|
addToQueue({
|
||||||
|
id: opId,
|
||||||
|
operation: {
|
||||||
|
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
|
||||||
|
target: OPERATION_TARGETS.BLOCKS,
|
||||||
|
payload: { groupId, blockIds: inverseOp.data.blockIds },
|
||||||
|
},
|
||||||
|
workflowId: activeWorkflowId,
|
||||||
|
userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
workflowStore.ungroupBlocks(groupId)
|
||||||
|
logger.debug('Undid group blocks', { groupId })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
|
||||||
|
// Undoing ungroup = re-group (inverse is group operation)
|
||||||
|
const inverseOp = entry.inverse as unknown as GroupBlocksOperation
|
||||||
|
const { groupId, blockIds } = inverseOp.data
|
||||||
|
|
||||||
|
addToQueue({
|
||||||
|
id: opId,
|
||||||
|
operation: {
|
||||||
|
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
|
||||||
|
target: OPERATION_TARGETS.BLOCKS,
|
||||||
|
payload: { groupId, blockIds },
|
||||||
|
},
|
||||||
|
workflowId: activeWorkflowId,
|
||||||
|
userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
workflowStore.groupBlocks(blockIds, groupId)
|
||||||
|
logger.debug('Undid ungroup blocks', { groupId })
|
||||||
|
break
|
||||||
|
}
|
||||||
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
||||||
const applyDiffInverse = entry.inverse as any
|
const applyDiffInverse = entry.inverse as any
|
||||||
const { baselineSnapshot } = applyDiffInverse.data
|
const { baselineSnapshot } = applyDiffInverse.data
|
||||||
@@ -1482,6 +1524,46 @@ export function useUndoRedo() {
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
|
||||||
|
// Redo group = group again
|
||||||
|
const groupOp = entry.operation as GroupBlocksOperation
|
||||||
|
const { groupId, blockIds } = groupOp.data
|
||||||
|
|
||||||
|
addToQueue({
|
||||||
|
id: opId,
|
||||||
|
operation: {
|
||||||
|
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
|
||||||
|
target: OPERATION_TARGETS.BLOCKS,
|
||||||
|
payload: { groupId, blockIds },
|
||||||
|
},
|
||||||
|
workflowId: activeWorkflowId,
|
||||||
|
userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
workflowStore.groupBlocks(blockIds, groupId)
|
||||||
|
logger.debug('Redid group blocks', { groupId })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
|
||||||
|
// Redo ungroup = ungroup again
|
||||||
|
const ungroupOp = entry.operation as UngroupBlocksOperation
|
||||||
|
const { groupId } = ungroupOp.data
|
||||||
|
|
||||||
|
addToQueue({
|
||||||
|
id: opId,
|
||||||
|
operation: {
|
||||||
|
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
|
||||||
|
target: OPERATION_TARGETS.BLOCKS,
|
||||||
|
payload: { groupId, blockIds: ungroupOp.data.blockIds },
|
||||||
|
},
|
||||||
|
workflowId: activeWorkflowId,
|
||||||
|
userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
workflowStore.ungroupBlocks(groupId)
|
||||||
|
logger.debug('Redid ungroup blocks', { groupId })
|
||||||
|
break
|
||||||
|
}
|
||||||
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
||||||
// Redo apply-diff means re-applying the proposed state with diff markers
|
// Redo apply-diff means re-applying the proposed state with diff markers
|
||||||
const applyDiffOp = entry.operation as any
|
const applyDiffOp = entry.operation as any
|
||||||
@@ -1793,6 +1875,66 @@ export function useUndoRedo() {
|
|||||||
[activeWorkflowId, userId, undoRedoStore]
|
[activeWorkflowId, userId, undoRedoStore]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const recordGroupBlocks = useCallback(
|
||||||
|
(blockIds: string[], groupId: string) => {
|
||||||
|
if (!activeWorkflowId || blockIds.length === 0) return
|
||||||
|
|
||||||
|
const operation: GroupBlocksOperation = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
workflowId: activeWorkflowId,
|
||||||
|
userId,
|
||||||
|
data: { groupId, blockIds },
|
||||||
|
}
|
||||||
|
|
||||||
|
const inverse: UngroupBlocksOperation = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
workflowId: activeWorkflowId,
|
||||||
|
userId,
|
||||||
|
data: { groupId, blockIds },
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createOperationEntry(operation, inverse)
|
||||||
|
undoRedoStore.push(activeWorkflowId, userId, entry)
|
||||||
|
|
||||||
|
logger.debug('Recorded group blocks', { groupId, blockCount: blockIds.length })
|
||||||
|
},
|
||||||
|
[activeWorkflowId, userId, undoRedoStore]
|
||||||
|
)
|
||||||
|
|
||||||
|
const recordUngroupBlocks = useCallback(
|
||||||
|
(groupId: string, blockIds: string[], parentGroupId?: string) => {
|
||||||
|
if (!activeWorkflowId || blockIds.length === 0) return
|
||||||
|
|
||||||
|
const operation: UngroupBlocksOperation = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
workflowId: activeWorkflowId,
|
||||||
|
userId,
|
||||||
|
data: { groupId, blockIds, parentGroupId },
|
||||||
|
}
|
||||||
|
|
||||||
|
const inverse: GroupBlocksOperation = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
workflowId: activeWorkflowId,
|
||||||
|
userId,
|
||||||
|
data: { groupId, blockIds },
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = createOperationEntry(operation, inverse)
|
||||||
|
undoRedoStore.push(activeWorkflowId, userId, entry)
|
||||||
|
|
||||||
|
logger.debug('Recorded ungroup blocks', { groupId, blockCount: blockIds.length })
|
||||||
|
},
|
||||||
|
[activeWorkflowId, userId, undoRedoStore]
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
recordBatchAddBlocks,
|
recordBatchAddBlocks,
|
||||||
recordBatchRemoveBlocks,
|
recordBatchRemoveBlocks,
|
||||||
@@ -1806,6 +1948,8 @@ export function useUndoRedo() {
|
|||||||
recordApplyDiff,
|
recordApplyDiff,
|
||||||
recordAcceptDiff,
|
recordAcceptDiff,
|
||||||
recordRejectDiff,
|
recordRejectDiff,
|
||||||
|
recordGroupBlocks,
|
||||||
|
recordUngroupBlocks,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
getStackSizes,
|
getStackSizes,
|
||||||
|
|||||||
@@ -16,9 +16,61 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
|
|||||||
|
|
||||||
const logger = createLogger('AutoLayout')
|
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.
|
* Applies automatic layout to all blocks in a workflow.
|
||||||
* Positions blocks in layers based on their connections (edges).
|
* Positions blocks in layers based on their connections (edges).
|
||||||
|
* Groups are treated as single units and laid out together.
|
||||||
*/
|
*/
|
||||||
export function applyAutoLayout(
|
export function applyAutoLayout(
|
||||||
blocks: Record<string, BlockState>,
|
blocks: Record<string, BlockState>,
|
||||||
@@ -36,6 +88,11 @@ export function applyAutoLayout(
|
|||||||
const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING
|
const horizontalSpacing = options.horizontalSpacing ?? DEFAULT_HORIZONTAL_SPACING
|
||||||
const verticalSpacing = options.verticalSpacing ?? DEFAULT_VERTICAL_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)
|
// Pre-calculate container dimensions by laying out their children (bottom-up)
|
||||||
// This ensures accurate widths/heights before root-level layout
|
// This ensures accurate widths/heights before root-level layout
|
||||||
prepareContainerDimensions(
|
prepareContainerDimensions(
|
||||||
@@ -49,19 +106,112 @@ export function applyAutoLayout(
|
|||||||
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
|
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
|
||||||
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
|
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
|
||||||
|
|
||||||
const rootBlocks: Record<string, BlockState> = {}
|
// For groups, we need to:
|
||||||
for (const id of layoutRootIds) {
|
// 1. Create virtual blocks representing each group
|
||||||
rootBlocks[id] = blocksCopy[id]
|
// 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(
|
for (const [groupId, group] of groups) {
|
||||||
(edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target)
|
// 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
|
// Calculate subflow depths before laying out root blocks
|
||||||
// This ensures blocks connected to subflow ends are positioned correctly
|
|
||||||
const subflowDepths = calculateSubflowDepths(blocksCopy, edges, assignLayers)
|
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) {
|
if (Object.keys(rootBlocks).length > 0) {
|
||||||
const { nodes } = layoutBlocksCore(rootBlocks, rootEdges, {
|
const { nodes } = layoutBlocksCore(rootBlocks, rootEdges, {
|
||||||
isContainer: false,
|
isContainer: false,
|
||||||
@@ -69,15 +219,49 @@ export function applyAutoLayout(
|
|||||||
subflowDepths,
|
subflowDepths,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Apply positions to ungrouped blocks and group representatives
|
||||||
for (const node of nodes.values()) {
|
for (const node of nodes.values()) {
|
||||||
blocksCopy[node.id].position = node.position
|
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)
|
layoutContainers(blocksCopy, edges, options)
|
||||||
|
|
||||||
logger.info('Auto layout completed successfully', {
|
logger.info('Auto layout completed successfully', {
|
||||||
blockCount: Object.keys(blocksCopy).length,
|
blockCount: Object.keys(blocksCopy).length,
|
||||||
|
groupCount: groups.size,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -26,9 +26,53 @@ export interface TargetedLayoutOptions extends LayoutOptions {
|
|||||||
horizontalSpacing?: number
|
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.
|
* Applies targeted layout to only reposition changed blocks.
|
||||||
* Unchanged blocks act as anchors to preserve existing layout.
|
* Unchanged blocks act as anchors to preserve existing layout.
|
||||||
|
* Blocks in groups are moved together as a unit.
|
||||||
*/
|
*/
|
||||||
export function applyTargetedLayout(
|
export function applyTargetedLayout(
|
||||||
blocks: Record<string, BlockState>,
|
blocks: Record<string, BlockState>,
|
||||||
@@ -45,9 +89,14 @@ export function applyTargetedLayout(
|
|||||||
return blocks
|
return blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
const changedSet = new Set(changedBlockIds)
|
|
||||||
const blocksCopy: Record<string, BlockState> = JSON.parse(JSON.stringify(blocks))
|
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)
|
// Pre-calculate container dimensions by laying out their children (bottom-up)
|
||||||
// This ensures accurate widths/heights before root-level layout
|
// This ensures accurate widths/heights before root-level layout
|
||||||
prepareContainerDimensions(
|
prepareContainerDimensions(
|
||||||
@@ -71,7 +120,8 @@ export function applyTargetedLayout(
|
|||||||
changedSet,
|
changedSet,
|
||||||
verticalSpacing,
|
verticalSpacing,
|
||||||
horizontalSpacing,
|
horizontalSpacing,
|
||||||
subflowDepths
|
subflowDepths,
|
||||||
|
blockGroups
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const [parentId, childIds] of groups.children.entries()) {
|
for (const [parentId, childIds] of groups.children.entries()) {
|
||||||
@@ -83,7 +133,8 @@ export function applyTargetedLayout(
|
|||||||
changedSet,
|
changedSet,
|
||||||
verticalSpacing,
|
verticalSpacing,
|
||||||
horizontalSpacing,
|
horizontalSpacing,
|
||||||
subflowDepths
|
subflowDepths,
|
||||||
|
blockGroups
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +143,7 @@ export function applyTargetedLayout(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Layouts a group of blocks (either root level or within a container)
|
* Layouts a group of blocks (either root level or within a container)
|
||||||
|
* Blocks in block groups are moved together as a unit.
|
||||||
*/
|
*/
|
||||||
function layoutGroup(
|
function layoutGroup(
|
||||||
parentId: string | null,
|
parentId: string | null,
|
||||||
@@ -101,7 +153,8 @@ function layoutGroup(
|
|||||||
changedSet: Set<string>,
|
changedSet: Set<string>,
|
||||||
verticalSpacing: number,
|
verticalSpacing: number,
|
||||||
horizontalSpacing: number,
|
horizontalSpacing: number,
|
||||||
subflowDepths: Map<string, number>
|
subflowDepths: Map<string, number>,
|
||||||
|
blockGroups: Map<string, string[]>
|
||||||
): void {
|
): void {
|
||||||
if (childIds.length === 0) return
|
if (childIds.length === 0) return
|
||||||
|
|
||||||
@@ -141,7 +194,7 @@ function layoutGroup(
|
|||||||
return
|
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 }>()
|
const oldPositions = new Map<string, { x: number; y: number }>()
|
||||||
for (const id of layoutEligibleChildIds) {
|
for (const id of layoutEligibleChildIds) {
|
||||||
const block = blocks[id]
|
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
|
// Apply new positions only to blocks that need layout
|
||||||
for (const id of needsLayout) {
|
for (const id of needsLayout) {
|
||||||
const block = blocks[id]
|
const block = blocks[id]
|
||||||
const newPos = layoutPositions.get(id)
|
const newPos = layoutPositions.get(id)
|
||||||
if (!block || !newPos) continue
|
if (!block || !newPos) continue
|
||||||
block.position = {
|
|
||||||
x: newPos.x + offsetX,
|
const groupId = block.data?.groupId
|
||||||
y: newPos.y + offsetY,
|
|
||||||
|
// 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
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1174,5 +1174,6 @@ export function stripWorkflowDiffMarkers(state: WorkflowState): WorkflowState {
|
|||||||
edges: structuredClone(state.edges || []),
|
edges: structuredClone(state.edges || []),
|
||||||
loops: structuredClone(state.loops || {}),
|
loops: structuredClone(state.loops || {}),
|
||||||
parallels: structuredClone(state.parallels || {}),
|
parallels: structuredClone(state.parallels || {}),
|
||||||
|
groups: structuredClone(state.groups || {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const BLOCKS_OPERATIONS = {
|
|||||||
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
|
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
|
||||||
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
|
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
|
||||||
BATCH_UPDATE_PARENT: 'batch-update-parent',
|
BATCH_UPDATE_PARENT: 'batch-update-parent',
|
||||||
|
GROUP_BLOCKS: 'group-blocks',
|
||||||
|
UNGROUP_BLOCKS: 'ungroup-blocks',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
|
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
|
||||||
@@ -87,6 +89,8 @@ export const UNDO_REDO_OPERATIONS = {
|
|||||||
APPLY_DIFF: 'apply-diff',
|
APPLY_DIFF: 'apply-diff',
|
||||||
ACCEPT_DIFF: 'accept-diff',
|
ACCEPT_DIFF: 'accept-diff',
|
||||||
REJECT_DIFF: 'reject-diff',
|
REJECT_DIFF: 'reject-diff',
|
||||||
|
GROUP_BLOCKS: 'group-blocks',
|
||||||
|
UNGROUP_BLOCKS: 'ungroup-blocks',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS]
|
export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS]
|
||||||
|
|||||||
@@ -810,6 +810,104 @@ async function handleBlocksOperationTx(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case BLOCKS_OPERATIONS.GROUP_BLOCKS: {
|
||||||
|
const { blockIds, groupId } = payload
|
||||||
|
if (!Array.isArray(blockIds) || blockIds.length === 0 || !groupId) {
|
||||||
|
logger.debug('Invalid payload for group blocks operation')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Grouping ${blockIds.length} blocks into group ${groupId} in workflow ${workflowId}`)
|
||||||
|
|
||||||
|
// Update blocks: set groupId and push to groupStack
|
||||||
|
for (const blockId of blockIds) {
|
||||||
|
const [currentBlock] = await tx
|
||||||
|
.select({ data: workflowBlocks.data })
|
||||||
|
.from(workflowBlocks)
|
||||||
|
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!currentBlock) {
|
||||||
|
logger.warn(`Block ${blockId} not found for grouping`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentData = (currentBlock?.data || {}) as Record<string, any>
|
||||||
|
const currentStack = Array.isArray(currentData.groupStack) ? currentData.groupStack : []
|
||||||
|
const updatedData = {
|
||||||
|
...currentData,
|
||||||
|
groupId,
|
||||||
|
groupStack: [...currentStack, groupId],
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(workflowBlocks)
|
||||||
|
.set({
|
||||||
|
data: updatedData,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Grouped ${blockIds.length} blocks into group ${groupId}`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: {
|
||||||
|
const { groupId, blockIds } = payload
|
||||||
|
if (!groupId || !Array.isArray(blockIds)) {
|
||||||
|
logger.debug('Invalid payload for ungroup blocks operation')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Ungrouping ${blockIds.length} blocks from group ${groupId} in workflow ${workflowId}`)
|
||||||
|
|
||||||
|
// Update blocks: pop from groupStack and set groupId to the previous level
|
||||||
|
for (const blockId of blockIds) {
|
||||||
|
const [currentBlock] = await tx
|
||||||
|
.select({ data: workflowBlocks.data })
|
||||||
|
.from(workflowBlocks)
|
||||||
|
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!currentBlock) {
|
||||||
|
logger.warn(`Block ${blockId} not found for ungrouping`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentData = (currentBlock?.data || {}) as Record<string, any>
|
||||||
|
const currentStack = Array.isArray(currentData.groupStack) ? [...currentData.groupStack] : []
|
||||||
|
|
||||||
|
// Pop the current groupId from the stack
|
||||||
|
if (currentStack.length > 0 && currentStack[currentStack.length - 1] === groupId) {
|
||||||
|
currentStack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The new groupId is the top of the remaining stack, or undefined if empty
|
||||||
|
const newGroupId = currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined
|
||||||
|
|
||||||
|
let updatedData: Record<string, any>
|
||||||
|
if (newGroupId) {
|
||||||
|
updatedData = { ...currentData, groupId: newGroupId, groupStack: currentStack }
|
||||||
|
} else {
|
||||||
|
// Remove groupId and groupStack if stack is empty
|
||||||
|
const { groupId: _removed, groupStack: _removedStack, ...restData } = currentData
|
||||||
|
updatedData = restData
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(workflowBlocks)
|
||||||
|
.set({
|
||||||
|
data: updatedData,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Ungrouped ${blockIds.length} blocks from group ${groupId}`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported blocks operation: ${operation}`)
|
throw new Error(`Unsupported blocks operation: ${operation}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -465,6 +465,70 @@ export function setupOperationsHandlers(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
target === OPERATION_TARGETS.BLOCKS &&
|
||||||
|
operation === BLOCKS_OPERATIONS.GROUP_BLOCKS
|
||||||
|
) {
|
||||||
|
await persistWorkflowOperation(workflowId, {
|
||||||
|
operation,
|
||||||
|
target,
|
||||||
|
payload,
|
||||||
|
timestamp: operationTimestamp,
|
||||||
|
userId: session.userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
room.lastModified = Date.now()
|
||||||
|
|
||||||
|
socket.to(workflowId).emit('workflow-operation', {
|
||||||
|
operation,
|
||||||
|
target,
|
||||||
|
payload,
|
||||||
|
timestamp: operationTimestamp,
|
||||||
|
senderId: socket.id,
|
||||||
|
userId: session.userId,
|
||||||
|
userName: session.userName,
|
||||||
|
metadata: { workflowId, operationId: crypto.randomUUID() },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (operationId) {
|
||||||
|
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
target === OPERATION_TARGETS.BLOCKS &&
|
||||||
|
operation === BLOCKS_OPERATIONS.UNGROUP_BLOCKS
|
||||||
|
) {
|
||||||
|
await persistWorkflowOperation(workflowId, {
|
||||||
|
operation,
|
||||||
|
target,
|
||||||
|
payload,
|
||||||
|
timestamp: operationTimestamp,
|
||||||
|
userId: session.userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
room.lastModified = Date.now()
|
||||||
|
|
||||||
|
socket.to(workflowId).emit('workflow-operation', {
|
||||||
|
operation,
|
||||||
|
target,
|
||||||
|
payload,
|
||||||
|
timestamp: operationTimestamp,
|
||||||
|
senderId: socket.id,
|
||||||
|
userId: session.userId,
|
||||||
|
userName: session.userName,
|
||||||
|
metadata: { workflowId, operationId: crypto.randomUUID() },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (operationId) {
|
||||||
|
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_ADD_EDGES) {
|
if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_ADD_EDGES) {
|
||||||
await persistWorkflowOperation(workflowId, {
|
await persistWorkflowOperation(workflowId, {
|
||||||
operation,
|
operation,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const WRITE_OPERATIONS: string[] = [
|
|||||||
BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
||||||
BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES,
|
BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES,
|
||||||
BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT,
|
BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT,
|
||||||
|
BLOCKS_OPERATIONS.GROUP_BLOCKS,
|
||||||
|
BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
|
||||||
// Edge operations
|
// Edge operations
|
||||||
EDGE_OPERATIONS.ADD,
|
EDGE_OPERATIONS.ADD,
|
||||||
EDGE_OPERATIONS.REMOVE,
|
EDGE_OPERATIONS.REMOVE,
|
||||||
|
|||||||
@@ -221,6 +221,30 @@ export const BatchUpdateParentSchema = z.object({
|
|||||||
operationId: z.string().optional(),
|
operationId: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const GroupBlocksSchema = z.object({
|
||||||
|
operation: z.literal(BLOCKS_OPERATIONS.GROUP_BLOCKS),
|
||||||
|
target: z.literal(OPERATION_TARGETS.BLOCKS),
|
||||||
|
payload: z.object({
|
||||||
|
blockIds: z.array(z.string()),
|
||||||
|
groupId: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
}),
|
||||||
|
timestamp: z.number(),
|
||||||
|
operationId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const UngroupBlocksSchema = z.object({
|
||||||
|
operation: z.literal(BLOCKS_OPERATIONS.UNGROUP_BLOCKS),
|
||||||
|
target: z.literal(OPERATION_TARGETS.BLOCKS),
|
||||||
|
payload: z.object({
|
||||||
|
groupId: z.string(),
|
||||||
|
blockIds: z.array(z.string()),
|
||||||
|
parentGroupId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
timestamp: z.number(),
|
||||||
|
operationId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const WorkflowOperationSchema = z.union([
|
export const WorkflowOperationSchema = z.union([
|
||||||
BlockOperationSchema,
|
BlockOperationSchema,
|
||||||
BatchPositionUpdateSchema,
|
BatchPositionUpdateSchema,
|
||||||
@@ -229,6 +253,8 @@ export const WorkflowOperationSchema = z.union([
|
|||||||
BatchToggleEnabledSchema,
|
BatchToggleEnabledSchema,
|
||||||
BatchToggleHandlesSchema,
|
BatchToggleHandlesSchema,
|
||||||
BatchUpdateParentSchema,
|
BatchUpdateParentSchema,
|
||||||
|
GroupBlocksSchema,
|
||||||
|
UngroupBlocksSchema,
|
||||||
EdgeOperationSchema,
|
EdgeOperationSchema,
|
||||||
BatchAddEdgesSchema,
|
BatchAddEdgesSchema,
|
||||||
BatchRemoveEdgesSchema,
|
BatchRemoveEdgesSchema,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function captureWorkflowSnapshot(): WorkflowState {
|
|||||||
edges: rawState.edges || [],
|
edges: rawState.edges || [],
|
||||||
loops: rawState.loops || {},
|
loops: rawState.loops || {},
|
||||||
parallels: rawState.parallels || {},
|
parallels: rawState.parallels || {},
|
||||||
|
groups: rawState.groups || {},
|
||||||
lastSaved: Date.now(),
|
lastSaved: Date.now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,23 @@ export interface RejectDiffOperation extends BaseOperation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupBlocksOperation extends BaseOperation {
|
||||||
|
type: typeof UNDO_REDO_OPERATIONS.GROUP_BLOCKS
|
||||||
|
data: {
|
||||||
|
groupId: string
|
||||||
|
blockIds: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UngroupBlocksOperation extends BaseOperation {
|
||||||
|
type: typeof UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS
|
||||||
|
data: {
|
||||||
|
groupId: string
|
||||||
|
blockIds: string[]
|
||||||
|
parentGroupId?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type Operation =
|
export type Operation =
|
||||||
| BatchAddBlocksOperation
|
| BatchAddBlocksOperation
|
||||||
| BatchRemoveBlocksOperation
|
| BatchRemoveBlocksOperation
|
||||||
@@ -139,6 +156,8 @@ export type Operation =
|
|||||||
| ApplyDiffOperation
|
| ApplyDiffOperation
|
||||||
| AcceptDiffOperation
|
| AcceptDiffOperation
|
||||||
| RejectDiffOperation
|
| RejectDiffOperation
|
||||||
|
| GroupBlocksOperation
|
||||||
|
| UngroupBlocksOperation
|
||||||
|
|
||||||
export interface OperationEntry {
|
export interface OperationEntry {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import type {
|
|||||||
BatchRemoveBlocksOperation,
|
BatchRemoveBlocksOperation,
|
||||||
BatchRemoveEdgesOperation,
|
BatchRemoveEdgesOperation,
|
||||||
BatchUpdateParentOperation,
|
BatchUpdateParentOperation,
|
||||||
|
GroupBlocksOperation,
|
||||||
Operation,
|
Operation,
|
||||||
OperationEntry,
|
OperationEntry,
|
||||||
|
UngroupBlocksOperation,
|
||||||
} from '@/stores/undo-redo/types'
|
} from '@/stores/undo-redo/types'
|
||||||
|
|
||||||
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
|
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
|
||||||
@@ -164,6 +166,30 @@ export function createInverseOperation(operation: Operation): Operation {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case UNDO_REDO_OPERATIONS.GROUP_BLOCKS: {
|
||||||
|
const op = operation as GroupBlocksOperation
|
||||||
|
return {
|
||||||
|
...operation,
|
||||||
|
type: UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS,
|
||||||
|
data: {
|
||||||
|
groupId: op.data.groupId,
|
||||||
|
blockIds: op.data.blockIds,
|
||||||
|
},
|
||||||
|
} as UngroupBlocksOperation
|
||||||
|
}
|
||||||
|
|
||||||
|
case UNDO_REDO_OPERATIONS.UNGROUP_BLOCKS: {
|
||||||
|
const op = operation as UngroupBlocksOperation
|
||||||
|
return {
|
||||||
|
...operation,
|
||||||
|
type: UNDO_REDO_OPERATIONS.GROUP_BLOCKS,
|
||||||
|
data: {
|
||||||
|
groupId: op.data.groupId,
|
||||||
|
blockIds: op.data.blockIds,
|
||||||
|
},
|
||||||
|
} as GroupBlocksOperation
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
const exhaustiveCheck: never = operation
|
const exhaustiveCheck: never = operation
|
||||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
|
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function cloneWorkflowState(state: WorkflowState): WorkflowState {
|
|||||||
edges: structuredClone(state.edges || []),
|
edges: structuredClone(state.edges || []),
|
||||||
loops: structuredClone(state.loops || {}),
|
loops: structuredClone(state.loops || {}),
|
||||||
parallels: structuredClone(state.parallels || {}),
|
parallels: structuredClone(state.parallels || {}),
|
||||||
|
groups: structuredClone(state.groups || {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -298,11 +298,26 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
|||||||
let workflowState: any
|
let workflowState: any
|
||||||
|
|
||||||
if (workflowData?.state) {
|
if (workflowData?.state) {
|
||||||
|
const blocks = workflowData.state.blocks || {}
|
||||||
|
|
||||||
|
// Reconstruct groups from blocks' groupId data
|
||||||
|
const reconstructedGroups: Record<string, { id: string; blockIds: string[] }> = {}
|
||||||
|
Object.entries(blocks).forEach(([blockId, block]: [string, any]) => {
|
||||||
|
const groupId = block?.data?.groupId
|
||||||
|
if (groupId) {
|
||||||
|
if (!reconstructedGroups[groupId]) {
|
||||||
|
reconstructedGroups[groupId] = { id: groupId, blockIds: [] }
|
||||||
|
}
|
||||||
|
reconstructedGroups[groupId].blockIds.push(blockId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
workflowState = {
|
workflowState = {
|
||||||
blocks: workflowData.state.blocks || {},
|
blocks,
|
||||||
edges: workflowData.state.edges || [],
|
edges: workflowData.state.edges || [],
|
||||||
loops: workflowData.state.loops || {},
|
loops: workflowData.state.loops || {},
|
||||||
parallels: workflowData.state.parallels || {},
|
parallels: workflowData.state.parallels || {},
|
||||||
|
groups: reconstructedGroups,
|
||||||
lastSaved: Date.now(),
|
lastSaved: Date.now(),
|
||||||
deploymentStatuses: {},
|
deploymentStatuses: {},
|
||||||
}
|
}
|
||||||
@@ -312,6 +327,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
|||||||
edges: [],
|
edges: [],
|
||||||
loops: {},
|
loops: {},
|
||||||
parallels: {},
|
parallels: {},
|
||||||
|
groups: {},
|
||||||
deploymentStatuses: {},
|
deploymentStatuses: {},
|
||||||
lastSaved: Date.now(),
|
lastSaved: Date.now(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ const initialState = {
|
|||||||
edges: [],
|
edges: [],
|
||||||
loops: {},
|
loops: {},
|
||||||
parallels: {},
|
parallels: {},
|
||||||
|
groups: {},
|
||||||
lastSaved: undefined,
|
lastSaved: undefined,
|
||||||
deploymentStatuses: {},
|
deploymentStatuses: {},
|
||||||
needsRedeployment: false,
|
needsRedeployment: false,
|
||||||
@@ -577,6 +578,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
edges: state.edges,
|
edges: state.edges,
|
||||||
loops: state.loops,
|
loops: state.loops,
|
||||||
parallels: state.parallels,
|
parallels: state.parallels,
|
||||||
|
groups: state.groups,
|
||||||
lastSaved: state.lastSaved,
|
lastSaved: state.lastSaved,
|
||||||
deploymentStatuses: state.deploymentStatuses,
|
deploymentStatuses: state.deploymentStatuses,
|
||||||
needsRedeployment: state.needsRedeployment,
|
needsRedeployment: state.needsRedeployment,
|
||||||
@@ -597,6 +599,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
Object.keys(workflowState.parallels || {}).length > 0
|
Object.keys(workflowState.parallels || {}).length > 0
|
||||||
? workflowState.parallels
|
? workflowState.parallels
|
||||||
: generateParallelBlocks(nextBlocks)
|
: generateParallelBlocks(nextBlocks)
|
||||||
|
const nextGroups = workflowState.groups || state.groups
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -604,6 +607,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
edges: nextEdges,
|
edges: nextEdges,
|
||||||
loops: nextLoops,
|
loops: nextLoops,
|
||||||
parallels: nextParallels,
|
parallels: nextParallels,
|
||||||
|
groups: nextGroups,
|
||||||
deploymentStatuses: workflowState.deploymentStatuses || state.deploymentStatuses,
|
deploymentStatuses: workflowState.deploymentStatuses || state.deploymentStatuses,
|
||||||
needsRedeployment:
|
needsRedeployment:
|
||||||
workflowState.needsRedeployment !== undefined
|
workflowState.needsRedeployment !== undefined
|
||||||
@@ -1333,6 +1337,126 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
getDragStartPosition: () => {
|
getDragStartPosition: () => {
|
||||||
return get().dragStartPosition || null
|
return get().dragStartPosition || null
|
||||||
},
|
},
|
||||||
|
|
||||||
|
groupBlocks: (blockIds: string[], groupId?: string) => {
|
||||||
|
if (blockIds.length === 0) return ''
|
||||||
|
|
||||||
|
const newGroupId = groupId || crypto.randomUUID()
|
||||||
|
const currentGroups = get().groups || {}
|
||||||
|
const currentBlocks = get().blocks
|
||||||
|
|
||||||
|
// Create the new group with all selected block IDs
|
||||||
|
const updatedGroups = { ...currentGroups }
|
||||||
|
updatedGroups[newGroupId] = {
|
||||||
|
id: newGroupId,
|
||||||
|
blockIds: [...blockIds],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update blocks: set groupId and push to groupStack
|
||||||
|
const newBlocks = { ...currentBlocks }
|
||||||
|
for (const blockId of blockIds) {
|
||||||
|
if (newBlocks[blockId]) {
|
||||||
|
const currentData = newBlocks[blockId].data || {}
|
||||||
|
const currentStack = Array.isArray(currentData.groupStack) ? currentData.groupStack : []
|
||||||
|
newBlocks[blockId] = {
|
||||||
|
...newBlocks[blockId],
|
||||||
|
data: {
|
||||||
|
...currentData,
|
||||||
|
groupId: newGroupId,
|
||||||
|
groupStack: [...currentStack, newGroupId],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
blocks: newBlocks,
|
||||||
|
groups: updatedGroups,
|
||||||
|
})
|
||||||
|
|
||||||
|
get().updateLastSaved()
|
||||||
|
logger.info('Created block group', {
|
||||||
|
groupId: newGroupId,
|
||||||
|
blockCount: blockIds.length,
|
||||||
|
})
|
||||||
|
return newGroupId
|
||||||
|
},
|
||||||
|
|
||||||
|
ungroupBlocks: (groupId: string) => {
|
||||||
|
const currentGroups = get().groups || {}
|
||||||
|
const currentBlocks = get().blocks
|
||||||
|
const group = currentGroups[groupId]
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
logger.warn('Attempted to ungroup non-existent group', { groupId })
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockIds = [...group.blockIds]
|
||||||
|
|
||||||
|
// Remove the group from the groups record
|
||||||
|
const updatedGroups = { ...currentGroups }
|
||||||
|
delete updatedGroups[groupId]
|
||||||
|
|
||||||
|
// Update blocks: pop from groupStack and set groupId to the previous level
|
||||||
|
const newBlocks = { ...currentBlocks }
|
||||||
|
for (const blockId of blockIds) {
|
||||||
|
if (newBlocks[blockId]) {
|
||||||
|
const currentData = { ...newBlocks[blockId].data }
|
||||||
|
const currentStack = Array.isArray(currentData.groupStack)
|
||||||
|
? [...currentData.groupStack]
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Pop the current groupId from the stack
|
||||||
|
if (currentStack.length > 0 && currentStack[currentStack.length - 1] === groupId) {
|
||||||
|
currentStack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The new groupId is the top of the remaining stack, or undefined if empty
|
||||||
|
const newGroupId =
|
||||||
|
currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined
|
||||||
|
|
||||||
|
if (newGroupId) {
|
||||||
|
currentData.groupId = newGroupId
|
||||||
|
currentData.groupStack = currentStack
|
||||||
|
} else {
|
||||||
|
// Remove groupId and groupStack if stack is empty
|
||||||
|
delete currentData.groupId
|
||||||
|
delete currentData.groupStack
|
||||||
|
}
|
||||||
|
|
||||||
|
newBlocks[blockId] = {
|
||||||
|
...newBlocks[blockId],
|
||||||
|
data: currentData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
blocks: newBlocks,
|
||||||
|
groups: updatedGroups,
|
||||||
|
})
|
||||||
|
|
||||||
|
get().updateLastSaved()
|
||||||
|
logger.info('Ungrouped blocks', {
|
||||||
|
groupId,
|
||||||
|
blockCount: blockIds.length,
|
||||||
|
})
|
||||||
|
return blockIds
|
||||||
|
},
|
||||||
|
|
||||||
|
getGroupBlockIds: (groupId: string) => {
|
||||||
|
const groups = get().groups || {}
|
||||||
|
const group = groups[groupId]
|
||||||
|
|
||||||
|
if (!group) return []
|
||||||
|
|
||||||
|
return [...group.blockIds]
|
||||||
|
},
|
||||||
|
|
||||||
|
getGroups: () => {
|
||||||
|
return get().groups || {}
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{ name: 'workflow-store' }
|
{ name: 'workflow-store' }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export interface BlockData {
|
|||||||
|
|
||||||
// Container node type (for ReactFlow node type determination)
|
// Container node type (for ReactFlow node type determination)
|
||||||
type?: string
|
type?: string
|
||||||
|
|
||||||
|
// Block group membership
|
||||||
|
groupId?: string
|
||||||
|
/** Stack of group IDs for hierarchical grouping (oldest to newest) */
|
||||||
|
groupStack?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockLayoutState {
|
export interface BlockLayoutState {
|
||||||
@@ -144,6 +149,20 @@ export interface Variable {
|
|||||||
value: unknown
|
value: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a group of blocks on the canvas.
|
||||||
|
* Groups can be nested (a group can contain other groups via block membership).
|
||||||
|
* When a block is in a group, it stores the groupId in its data.
|
||||||
|
*/
|
||||||
|
export interface BlockGroup {
|
||||||
|
/** Unique identifier for the group */
|
||||||
|
id: string
|
||||||
|
/** Optional display name for the group */
|
||||||
|
name?: string
|
||||||
|
/** Block IDs that are direct members of this group */
|
||||||
|
blockIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface DragStartPosition {
|
export interface DragStartPosition {
|
||||||
id: string
|
id: string
|
||||||
x: number
|
x: number
|
||||||
@@ -157,6 +176,8 @@ export interface WorkflowState {
|
|||||||
lastSaved?: number
|
lastSaved?: number
|
||||||
loops: Record<string, Loop>
|
loops: Record<string, Loop>
|
||||||
parallels: Record<string, Parallel>
|
parallels: Record<string, Parallel>
|
||||||
|
/** Block groups for organizing blocks on the canvas */
|
||||||
|
groups?: Record<string, BlockGroup>
|
||||||
lastUpdate?: number
|
lastUpdate?: number
|
||||||
metadata?: {
|
metadata?: {
|
||||||
name?: string
|
name?: string
|
||||||
@@ -243,6 +264,28 @@ export interface WorkflowActions {
|
|||||||
workflowState: WorkflowState,
|
workflowState: WorkflowState,
|
||||||
options?: { updateLastSaved?: boolean }
|
options?: { updateLastSaved?: boolean }
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
|
// Block group operations
|
||||||
|
/**
|
||||||
|
* Groups the specified blocks together.
|
||||||
|
* If any blocks are already in a group, they are removed from their current group first.
|
||||||
|
* @returns The new group ID
|
||||||
|
*/
|
||||||
|
groupBlocks: (blockIds: string[], groupId?: string) => string
|
||||||
|
/**
|
||||||
|
* Ungroups a group, removing it and releasing its blocks.
|
||||||
|
* If the group has a parent group, blocks are moved to the parent group.
|
||||||
|
* @returns The block IDs that were in the group
|
||||||
|
*/
|
||||||
|
ungroupBlocks: (groupId: string) => string[]
|
||||||
|
/**
|
||||||
|
* Gets all block IDs in a group, including blocks in nested groups (recursive).
|
||||||
|
*/
|
||||||
|
getGroupBlockIds: (groupId: string, recursive?: boolean) => string[]
|
||||||
|
/**
|
||||||
|
* Gets all groups in the workflow.
|
||||||
|
*/
|
||||||
|
getGroups: () => Record<string, BlockGroup>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkflowStore = WorkflowState & WorkflowActions
|
export type WorkflowStore = WorkflowState & WorkflowActions
|
||||||
|
|||||||
Reference in New Issue
Block a user