mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement: workflow, blocks, preview, avatars, output-select (#2840)
* improvement(workflow): ui/ux, refactors, optimizations * improvement: blocks, preview, avatars * improvement(output-select): ui * update API endpoint picker to match output selector * improvement: subflow ui/ux --------- Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
@@ -100,6 +100,7 @@ export const ActionBar = memo(
|
||||
const isStartBlock = blockType === 'starter' || blockType === 'start_trigger'
|
||||
const isResponseBlock = blockType === 'response'
|
||||
const isNoteBlock = blockType === 'note'
|
||||
const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
|
||||
|
||||
/**
|
||||
* Get appropriate tooltip message based on disabled state
|
||||
@@ -125,7 +126,7 @@ export const ActionBar = memo(
|
||||
'dark:border-transparent dark:bg-[var(--surface-4)]'
|
||||
)}
|
||||
>
|
||||
{!isNoteBlock && (
|
||||
{!isNoteBlock && !isSubflowBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
@@ -148,7 +149,7 @@ export const ActionBar = memo(
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{!isStartBlock && !isResponseBlock && (
|
||||
{!isStartBlock && !isResponseBlock && !isSubflowBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
@@ -169,7 +170,7 @@ export const ActionBar = memo(
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{!isNoteBlock && (
|
||||
{!isNoteBlock && !isSubflowBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
@@ -331,13 +331,16 @@ export function OutputSelect({
|
||||
return (
|
||||
<Combobox
|
||||
size='sm'
|
||||
className='!w-fit !py-[2px] [&>svg]:!ml-[4px] [&>svg]:!h-3 [&>svg]:!w-3 [&>span]:!text-[var(--text-secondary)] min-w-[100px] rounded-[6px] bg-transparent px-[9px] hover:bg-[var(--surface-5)] dark:hover:border-[var(--surface-6)] dark:hover:bg-transparent [&>span]:text-center'
|
||||
className='!w-fit !py-[2px] min-w-[100px] rounded-[6px] px-[9px]'
|
||||
groups={comboboxGroups}
|
||||
options={[]}
|
||||
multiSelect
|
||||
multiSelectValues={normalizedSelectedValues}
|
||||
onMultiSelectChange={onOutputSelect}
|
||||
placeholder={selectedDisplayText}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{selectedDisplayText}</span>
|
||||
}
|
||||
disabled={disabled || workflowOutputs.length === 0}
|
||||
align={align}
|
||||
maxHeight={maxHeight}
|
||||
|
||||
@@ -4,13 +4,13 @@ import type { NodeProps } from 'reactflow'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
|
||||
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import {
|
||||
BLOCK_DIMENSIONS,
|
||||
useBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { ActionBar } from '../workflow-block/components'
|
||||
import type { WorkflowBlockProps } from '../workflow-block/types'
|
||||
|
||||
interface NoteBlockNodeData extends WorkflowBlockProps {}
|
||||
|
||||
@@ -3,16 +3,12 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, Clipboard } from 'lucide-react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
Code,
|
||||
Combobox,
|
||||
Label,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
@@ -602,48 +598,19 @@ console.log(limits);`
|
||||
<span>{copied.async ? 'Copied' : 'Copy'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className='min-w-0 max-w-full'>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
|
||||
>
|
||||
<span className='whitespace-nowrap text-[12px]'>
|
||||
{getAsyncExampleTitle()}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
maxHeight={300}
|
||||
maxWidth={300}
|
||||
minWidth={160}
|
||||
border
|
||||
>
|
||||
<PopoverItem
|
||||
active={asyncExampleType === 'execute'}
|
||||
onClick={() => setAsyncExampleType('execute')}
|
||||
>
|
||||
Execute Job
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
active={asyncExampleType === 'status'}
|
||||
onClick={() => setAsyncExampleType('status')}
|
||||
>
|
||||
Check Status
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
active={asyncExampleType === 'rate-limits'}
|
||||
onClick={() => setAsyncExampleType('rate-limits')}
|
||||
>
|
||||
Rate Limits
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Combobox
|
||||
size='sm'
|
||||
className='!w-fit !py-[2px] min-w-[100px] rounded-[6px] px-[9px]'
|
||||
options={[
|
||||
{ label: 'Execute Job', value: 'execute' },
|
||||
{ label: 'Check Status', value: 'status' },
|
||||
{ label: 'Rate Limits', value: 'rate-limits' },
|
||||
]}
|
||||
value={asyncExampleType}
|
||||
onChange={(value) => setAsyncExampleType(value as AsyncExampleType)}
|
||||
align='end'
|
||||
dropdownWidth={160}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Code.Viewer
|
||||
|
||||
@@ -334,7 +334,6 @@ export function GeneralDeploy({
|
||||
}}
|
||||
onPaneClick={() => setExpandedSelectedBlockId(null)}
|
||||
selectedBlockId={expandedSelectedBlockId}
|
||||
lightweight
|
||||
/>
|
||||
</div>
|
||||
{expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
|
||||
import { Button, Trash } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
|
||||
/**
|
||||
@@ -18,11 +18,16 @@ import { usePanelEditorStore } from '@/stores/panel'
|
||||
const SubflowNodeStyles: React.FC = () => {
|
||||
return (
|
||||
<style jsx global>{`
|
||||
/* Z-index management for subflow nodes */
|
||||
/* Z-index management for subflow nodes - default behind blocks */
|
||||
.workflow-container .react-flow__node-subflowNode {
|
||||
z-index: -1 !important;
|
||||
}
|
||||
|
||||
/* Selected subflows appear above other subflows but below blocks (z-21) */
|
||||
.workflow-container .react-flow__node-subflowNode:has([data-subflow-selected='true']) {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
/* Drag-over states */
|
||||
.loop-node-drag-over,
|
||||
.parallel-node-drag-over {
|
||||
@@ -63,8 +68,8 @@ export interface SubflowNodeData {
|
||||
*/
|
||||
export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow()
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const currentBlock = currentWorkflow.getBlockById(id)
|
||||
@@ -80,6 +85,8 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const isFocused = currentBlockId === id
|
||||
|
||||
const isPreviewSelected = data?.isPreviewSelected || false
|
||||
|
||||
/**
|
||||
* Calculate the nesting level of this subflow node based on its parent hierarchy.
|
||||
* Used to apply appropriate styling for nested containers.
|
||||
@@ -125,8 +132,6 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
|
||||
}
|
||||
|
||||
const isPreviewSelected = data?.isPreviewSelected || false
|
||||
|
||||
/**
|
||||
* Determine the ring styling based on subflow state priority:
|
||||
* 1. Focused (selected in editor) or preview selected - blue ring
|
||||
@@ -162,7 +167,12 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
data-node-id={id}
|
||||
data-type='subflowNode'
|
||||
data-nesting-level={nestingLevel}
|
||||
data-subflow-selected={isFocused || isPreviewSelected}
|
||||
>
|
||||
{!isPreview && (
|
||||
<ActionBar blockId={id} blockType={data.kind} disabled={!userPermissions.canEdit} />
|
||||
)}
|
||||
|
||||
{/* Header Section */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -180,18 +190,6 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
{!isPreview && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
collaborativeBatchRemoveBlocks([id])
|
||||
}}
|
||||
className='h-[14px] w-[14px] p-0 opacity-0 transition-opacity duration-100 group-hover:opacity-100'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isPreview && (
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useBlockConnections } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-block-connections'
|
||||
|
||||
interface ConnectionsProps {
|
||||
blockId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays incoming connections at the bottom left of the workflow block
|
||||
*/
|
||||
export function Connections({ blockId }: ConnectionsProps) {
|
||||
const { incomingConnections, hasIncomingConnections } = useBlockConnections(blockId)
|
||||
|
||||
if (!hasIncomingConnections) return null
|
||||
|
||||
const connectionCount = incomingConnections.length
|
||||
const connectionText = `${connectionCount} ${connectionCount === 1 ? 'connection' : 'connections'}`
|
||||
|
||||
return (
|
||||
<div className='pointer-events-none absolute top-full left-0 ml-[8px] flex items-center gap-[8px] pt-[8px] opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<span className='text-[12px] text-[var(--text-tertiary)]'>{connectionText}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ActionBar } from './action-bar/action-bar'
|
||||
export { Connections } from './connections/connections'
|
||||
@@ -9,10 +9,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
ActionBar,
|
||||
Connections,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components'
|
||||
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
|
||||
import {
|
||||
useBlockProperties,
|
||||
useChildWorkflow,
|
||||
@@ -934,8 +931,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
|
||||
)}
|
||||
|
||||
{shouldShowDefaultHandles && <Connections blockId={id} />}
|
||||
|
||||
{shouldShowDefaultHandles && (
|
||||
<Handle
|
||||
type='target'
|
||||
|
||||
@@ -126,16 +126,7 @@ export function WorkflowControls() {
|
||||
</PopoverTrigger>
|
||||
<Tooltip.Content side='top'>{mode === 'hand' ? 'Mover' : 'Pointer'}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<PopoverContent align='center' side='top' sideOffset={8} maxWidth={100} minWidth={100}>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
setMode('cursor')
|
||||
setIsCanvasModeOpen(false)
|
||||
}}
|
||||
>
|
||||
<Cursor className='h-3 w-3' />
|
||||
<span>Pointer</span>
|
||||
</PopoverItem>
|
||||
<PopoverContent side='top' sideOffset={8} maxWidth={100} minWidth={100}>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
setMode('hand')
|
||||
@@ -145,6 +136,15 @@ export function WorkflowControls() {
|
||||
<Hand className='h-3 w-3' />
|
||||
<span>Mover</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
setMode('cursor')
|
||||
setIsCanvasModeOpen(false)
|
||||
}}
|
||||
>
|
||||
<Cursor className='h-3 w-3' />
|
||||
<span>Pointer</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
|
||||
@@ -161,6 +161,24 @@ function calculatePasteOffset(
|
||||
}
|
||||
}
|
||||
|
||||
function mapEdgesByNode(edges: Edge[], nodeIds: Set<string>): Map<string, Edge[]> {
|
||||
const result = new Map<string, Edge[]>()
|
||||
edges.forEach((edge) => {
|
||||
if (nodeIds.has(edge.source)) {
|
||||
const list = result.get(edge.source) ?? []
|
||||
list.push(edge)
|
||||
result.set(edge.source, list)
|
||||
return
|
||||
}
|
||||
if (nodeIds.has(edge.target)) {
|
||||
const list = result.get(edge.target) ?? []
|
||||
list.push(edge)
|
||||
result.set(edge.target, list)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/** Custom node types for ReactFlow. */
|
||||
const nodeTypes: NodeTypes = {
|
||||
workflowBlock: WorkflowBlock,
|
||||
@@ -178,12 +196,16 @@ const edgeTypes: EdgeTypes = {
|
||||
const defaultEdgeOptions = { type: 'custom' }
|
||||
|
||||
const reactFlowStyles = [
|
||||
'bg-[var(--bg)]',
|
||||
'[&_.react-flow__edges]:!z-0',
|
||||
'[&_.react-flow__node]:!z-[21]',
|
||||
'[&_.react-flow__handle]:!z-[30]',
|
||||
'[&_.react-flow__edge-labels]:!z-[60]',
|
||||
'[&_.react-flow__pane]:!bg-transparent',
|
||||
'[&_.react-flow__renderer]:!bg-transparent',
|
||||
'[&_.react-flow__pane]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__pane]:select-none',
|
||||
'[&_.react-flow__selectionpane]:select-none',
|
||||
'[&_.react-flow__renderer]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__viewport]:!bg-[var(--bg)]',
|
||||
'[&_.react-flow__background]:hidden',
|
||||
].join(' ')
|
||||
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
|
||||
@@ -200,7 +222,6 @@ interface BlockData {
|
||||
id: string
|
||||
type: string
|
||||
position: { x: number; y: number }
|
||||
distance: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,6 +343,59 @@ const WorkflowContent = React.memo(() => {
|
||||
return resizeLoopNodes(updateNodeDimensions)
|
||||
}, [resizeLoopNodes, updateNodeDimensions])
|
||||
|
||||
/** Checks if a node can be placed inside a container (loop/parallel). */
|
||||
const canNodeEnterContainer = useCallback(
|
||||
(node: Node): boolean => {
|
||||
if (node.data?.type === 'starter') return false
|
||||
if (node.type === 'subflowNode') return false
|
||||
const block = blocks[node.id]
|
||||
return !(block && TriggerUtils.isTriggerBlock(block))
|
||||
},
|
||||
[blocks]
|
||||
)
|
||||
|
||||
/** Shifts position updates to ensure nodes stay within container bounds. */
|
||||
const shiftUpdatesToContainerBounds = useCallback(
|
||||
<T extends { newPosition: { x: number; y: number } }>(rawUpdates: T[]): T[] => {
|
||||
if (rawUpdates.length === 0) return rawUpdates
|
||||
|
||||
const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x))
|
||||
const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y))
|
||||
|
||||
const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING
|
||||
const targetMinY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
|
||||
|
||||
const shiftX = minX < targetMinX ? targetMinX - minX : 0
|
||||
const shiftY = minY < targetMinY ? targetMinY - minY : 0
|
||||
|
||||
if (shiftX === 0 && shiftY === 0) return rawUpdates
|
||||
|
||||
return rawUpdates.map((u) => ({
|
||||
...u,
|
||||
newPosition: {
|
||||
x: u.newPosition.x + shiftX,
|
||||
y: u.newPosition.y + shiftY,
|
||||
},
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/** Applies highlight styling to a container node during drag operations. */
|
||||
const highlightContainerNode = useCallback(
|
||||
(containerId: string, containerKind: 'loop' | 'parallel') => {
|
||||
clearDragHighlights()
|
||||
const containerElement = document.querySelector(`[data-id="${containerId}"]`)
|
||||
if (containerElement) {
|
||||
containerElement.classList.add(
|
||||
containerKind === 'loop' ? 'loop-node-drag-over' : 'parallel-node-drag-over'
|
||||
)
|
||||
document.body.style.cursor = 'copy'
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
|
||||
|
||||
const isWorkflowEmpty = useMemo(() => Object.keys(blocks).length === 0, [blocks])
|
||||
@@ -503,6 +577,99 @@ const WorkflowContent = React.memo(() => {
|
||||
[collaborativeBatchUpdateParent]
|
||||
)
|
||||
|
||||
/**
|
||||
* Executes a batch parent update for nodes being moved into or out of containers.
|
||||
* Consolidates the common logic used by onNodeDragStop and onSelectionDragStop.
|
||||
*/
|
||||
const executeBatchParentUpdate = useCallback(
|
||||
(nodesToProcess: Node[], targetParentId: string | null, logMessage: string) => {
|
||||
// Build set of node IDs for efficient lookup
|
||||
const nodeIds = new Set(nodesToProcess.map((n) => n.id))
|
||||
|
||||
// Filter to nodes whose parent is actually changing
|
||||
const nodesNeedingUpdate = nodesToProcess.filter((n) => {
|
||||
const block = blocks[n.id]
|
||||
if (!block) return false
|
||||
const currentParent = block.data?.parentId || null
|
||||
// Skip if the node's parent is also being moved (keep children with their parent)
|
||||
if (currentParent && nodeIds.has(currentParent)) return false
|
||||
return currentParent !== targetParentId
|
||||
})
|
||||
|
||||
if (nodesNeedingUpdate.length === 0) return
|
||||
|
||||
// Filter out nodes that cannot enter containers (when target is a container)
|
||||
const validNodes = targetParentId
|
||||
? nodesNeedingUpdate.filter(canNodeEnterContainer)
|
||||
: nodesNeedingUpdate
|
||||
|
||||
if (validNodes.length === 0) return
|
||||
|
||||
// Find boundary edges (edges that cross the container boundary)
|
||||
const movingNodeIds = new Set(validNodes.map((n) => n.id))
|
||||
const boundaryEdges = edgesForDisplay.filter((e) => {
|
||||
const sourceInSelection = movingNodeIds.has(e.source)
|
||||
const targetInSelection = movingNodeIds.has(e.target)
|
||||
return sourceInSelection !== targetInSelection
|
||||
})
|
||||
const boundaryEdgesByNode = mapEdgesByNode(boundaryEdges, movingNodeIds)
|
||||
|
||||
// Build position updates
|
||||
const rawUpdates = validNodes.map((n) => {
|
||||
const edgesForThisNode = boundaryEdgesByNode.get(n.id) ?? []
|
||||
const newPosition = targetParentId
|
||||
? calculateRelativePosition(n.id, targetParentId, true)
|
||||
: getNodeAbsolutePosition(n.id)
|
||||
return {
|
||||
blockId: n.id,
|
||||
newParentId: targetParentId,
|
||||
newPosition,
|
||||
affectedEdges: edgesForThisNode,
|
||||
}
|
||||
})
|
||||
|
||||
// Shift to container bounds if moving into a container
|
||||
const updates = targetParentId ? shiftUpdatesToContainerBounds(rawUpdates) : rawUpdates
|
||||
|
||||
collaborativeBatchUpdateParent(updates)
|
||||
|
||||
// Update display nodes
|
||||
setDisplayNodes((nodes) =>
|
||||
nodes.map((node) => {
|
||||
const update = updates.find((u) => u.blockId === node.id)
|
||||
if (update) {
|
||||
return {
|
||||
...node,
|
||||
position: update.newPosition,
|
||||
parentId: update.newParentId ?? undefined,
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
)
|
||||
|
||||
// Resize container if moving into one
|
||||
if (targetParentId) {
|
||||
resizeLoopNodesWrapper()
|
||||
}
|
||||
|
||||
logger.info(logMessage, {
|
||||
targetParentId,
|
||||
nodeCount: validNodes.length,
|
||||
})
|
||||
},
|
||||
[
|
||||
blocks,
|
||||
edgesForDisplay,
|
||||
canNodeEnterContainer,
|
||||
calculateRelativePosition,
|
||||
getNodeAbsolutePosition,
|
||||
shiftUpdatesToContainerBounds,
|
||||
collaborativeBatchUpdateParent,
|
||||
resizeLoopNodesWrapper,
|
||||
]
|
||||
)
|
||||
|
||||
const addBlock = useCallback(
|
||||
(
|
||||
id: string,
|
||||
@@ -515,6 +682,9 @@ const WorkflowContent = React.memo(() => {
|
||||
autoConnectEdge?: Edge,
|
||||
triggerMode?: boolean
|
||||
) => {
|
||||
pendingSelectionRef.current = new Set([id])
|
||||
setSelectedEdges(new Map())
|
||||
|
||||
const blockData: Record<string, unknown> = { ...(data || {}) }
|
||||
if (parentId) blockData.parentId = parentId
|
||||
if (extent) blockData.extent = extent
|
||||
@@ -533,7 +703,7 @@ const WorkflowContent = React.memo(() => {
|
||||
collaborativeBatchAddBlocks([block], autoConnectEdge ? [autoConnectEdge] : [], {}, {}, {})
|
||||
usePanelEditorStore.getState().setCurrentBlockId(id)
|
||||
},
|
||||
[collaborativeBatchAddBlocks]
|
||||
[collaborativeBatchAddBlocks, setSelectedEdges]
|
||||
)
|
||||
|
||||
const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore(
|
||||
@@ -674,104 +844,52 @@ const WorkflowContent = React.memo(() => {
|
||||
copyBlocks(blockIds)
|
||||
}, [contextMenuBlocks, copyBlocks])
|
||||
|
||||
/**
|
||||
* Executes a paste operation with validation and selection handling.
|
||||
* Consolidates shared logic for context paste, duplicate, and keyboard paste.
|
||||
*/
|
||||
const executePasteOperation = useCallback(
|
||||
(operation: 'paste' | 'duplicate', pasteOffset: { x: number; y: number }) => {
|
||||
const pasteData = preparePasteData(pasteOffset)
|
||||
if (!pasteData) return
|
||||
|
||||
const pastedBlocksArray = Object.values(pasteData.blocks)
|
||||
const validation = validateTriggerPaste(pastedBlocksArray, blocks, operation)
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: validation.message!,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
|
||||
pendingSelectionRef.current = new Set([
|
||||
...(pendingSelectionRef.current ?? []),
|
||||
...pastedBlocksArray.map((b) => b.id),
|
||||
])
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocksArray,
|
||||
pasteData.edges,
|
||||
pasteData.loops,
|
||||
pasteData.parallels,
|
||||
pasteData.subBlockValues
|
||||
)
|
||||
},
|
||||
[preparePasteData, blocks, addNotification, activeWorkflowId, collaborativeBatchAddBlocks]
|
||||
)
|
||||
|
||||
const handleContextPaste = useCallback(() => {
|
||||
if (!hasClipboard()) return
|
||||
|
||||
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
|
||||
|
||||
const pasteData = preparePasteData(pasteOffset)
|
||||
if (!pasteData) return
|
||||
|
||||
const {
|
||||
blocks: pastedBlocks,
|
||||
edges: pastedEdges,
|
||||
loops: pastedLoops,
|
||||
parallels: pastedParallels,
|
||||
subBlockValues: pastedSubBlockValues,
|
||||
} = pasteData
|
||||
|
||||
const pastedBlocksArray = Object.values(pastedBlocks)
|
||||
const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'paste')
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: validation.message!,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
|
||||
pendingSelectionRef.current = new Set([
|
||||
...(pendingSelectionRef.current ?? []),
|
||||
...pastedBlocksArray.map((b) => b.id),
|
||||
])
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocksArray,
|
||||
pastedEdges,
|
||||
pastedLoops,
|
||||
pastedParallels,
|
||||
pastedSubBlockValues
|
||||
)
|
||||
}, [
|
||||
hasClipboard,
|
||||
clipboard,
|
||||
screenToFlowPosition,
|
||||
preparePasteData,
|
||||
blocks,
|
||||
activeWorkflowId,
|
||||
addNotification,
|
||||
collaborativeBatchAddBlocks,
|
||||
])
|
||||
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
|
||||
}, [hasClipboard, executePasteOperation, clipboard, screenToFlowPosition])
|
||||
|
||||
const handleContextDuplicate = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((b) => b.id)
|
||||
copyBlocks(blockIds)
|
||||
const pasteData = preparePasteData(DEFAULT_PASTE_OFFSET)
|
||||
if (!pasteData) return
|
||||
|
||||
const {
|
||||
blocks: pastedBlocks,
|
||||
edges: pastedEdges,
|
||||
loops: pastedLoops,
|
||||
parallels: pastedParallels,
|
||||
subBlockValues: pastedSubBlockValues,
|
||||
} = pasteData
|
||||
|
||||
const pastedBlocksArray = Object.values(pastedBlocks)
|
||||
const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'duplicate')
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: validation.message!,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
|
||||
pendingSelectionRef.current = new Set([
|
||||
...(pendingSelectionRef.current ?? []),
|
||||
...pastedBlocksArray.map((b) => b.id),
|
||||
])
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocksArray,
|
||||
pastedEdges,
|
||||
pastedLoops,
|
||||
pastedParallels,
|
||||
pastedSubBlockValues
|
||||
)
|
||||
}, [
|
||||
contextMenuBlocks,
|
||||
copyBlocks,
|
||||
preparePasteData,
|
||||
blocks,
|
||||
activeWorkflowId,
|
||||
addNotification,
|
||||
collaborativeBatchAddBlocks,
|
||||
])
|
||||
copyBlocks(contextMenuBlocks.map((b) => b.id))
|
||||
executePasteOperation('duplicate', DEFAULT_PASTE_OFFSET)
|
||||
}, [contextMenuBlocks, copyBlocks, executePasteOperation])
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((b) => b.id)
|
||||
@@ -880,36 +998,7 @@ const WorkflowContent = React.memo(() => {
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
||||
if (effectivePermissions.canEdit && hasClipboard()) {
|
||||
event.preventDefault()
|
||||
|
||||
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
|
||||
|
||||
const pasteData = preparePasteData(pasteOffset)
|
||||
if (pasteData) {
|
||||
const pastedBlocks = Object.values(pasteData.blocks)
|
||||
const validation = validateTriggerPaste(pastedBlocks, blocks, 'paste')
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: validation.message!,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
|
||||
pendingSelectionRef.current = new Set([
|
||||
...(pendingSelectionRef.current ?? []),
|
||||
...pastedBlocks.map((b) => b.id),
|
||||
])
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocks,
|
||||
pasteData.edges,
|
||||
pasteData.loops,
|
||||
pasteData.parallels,
|
||||
pasteData.subBlockValues
|
||||
)
|
||||
}
|
||||
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -926,15 +1015,11 @@ const WorkflowContent = React.memo(() => {
|
||||
redo,
|
||||
getNodes,
|
||||
copyBlocks,
|
||||
preparePasteData,
|
||||
collaborativeBatchAddBlocks,
|
||||
hasClipboard,
|
||||
effectivePermissions.canEdit,
|
||||
blocks,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
clipboard,
|
||||
screenToFlowPosition,
|
||||
executePasteOperation,
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -962,34 +1047,42 @@ const WorkflowContent = React.memo(() => {
|
||||
const containerAtPoint = isPointInLoopNode(newNodePosition)
|
||||
const nodeIndex = new Map(getNodes().map((n) => [n.id, n]))
|
||||
|
||||
const candidates = Object.entries(blocks)
|
||||
.filter(([id, block]) => {
|
||||
if (!block.enabled) return false
|
||||
if (block.type === 'response') return false
|
||||
const node = nodeIndex.get(id)
|
||||
if (!node) return false
|
||||
const closest = Object.entries(blocks).reduce<{
|
||||
id: string
|
||||
type: string
|
||||
position: { x: number; y: number }
|
||||
distanceSquared: number
|
||||
} | null>((acc, [id, block]) => {
|
||||
if (!block.enabled) return acc
|
||||
if (block.type === 'response') return acc
|
||||
const node = nodeIndex.get(id)
|
||||
if (!node) return acc
|
||||
|
||||
const blockParentId = blocks[id]?.data?.parentId
|
||||
const dropParentId = containerAtPoint?.loopId
|
||||
if (dropParentId !== blockParentId) return false
|
||||
const blockParentId = blocks[id]?.data?.parentId
|
||||
const dropParentId = containerAtPoint?.loopId
|
||||
if (dropParentId !== blockParentId) return acc
|
||||
|
||||
return true
|
||||
})
|
||||
.map(([id, block]) => {
|
||||
const anchor = getNodeAnchorPosition(id)
|
||||
const distance = Math.sqrt(
|
||||
(anchor.x - newNodePosition.x) ** 2 + (anchor.y - newNodePosition.y) ** 2
|
||||
)
|
||||
const anchor = getNodeAnchorPosition(id)
|
||||
const distanceSquared =
|
||||
(anchor.x - newNodePosition.x) ** 2 + (anchor.y - newNodePosition.y) ** 2
|
||||
if (!acc || distanceSquared < acc.distanceSquared) {
|
||||
return {
|
||||
id,
|
||||
type: block.type,
|
||||
position: anchor,
|
||||
distance,
|
||||
distanceSquared,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
}
|
||||
return acc
|
||||
}, null)
|
||||
|
||||
return candidates[0] || null
|
||||
if (!closest) return null
|
||||
|
||||
return {
|
||||
id: closest.id,
|
||||
type: closest.type,
|
||||
position: closest.position,
|
||||
}
|
||||
},
|
||||
[blocks, getNodes, getNodeAnchorPosition, isPointInLoopNode]
|
||||
)
|
||||
@@ -1053,15 +1146,28 @@ const WorkflowContent = React.memo(() => {
|
||||
candidateBlocks: { id: string; type: string; position: { x: number; y: number } }[],
|
||||
targetPosition: { x: number; y: number }
|
||||
): { id: string; type: string; position: { x: number; y: number } } | undefined => {
|
||||
return candidateBlocks
|
||||
.filter((b) => b.type !== 'response')
|
||||
.map((b) => ({
|
||||
block: b,
|
||||
distance: Math.sqrt(
|
||||
(b.position.x - targetPosition.x) ** 2 + (b.position.y - targetPosition.y) ** 2
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
||||
const closest = candidateBlocks.reduce<{
|
||||
id: string
|
||||
type: string
|
||||
position: { x: number; y: number }
|
||||
distanceSquared: number
|
||||
} | null>((acc, block) => {
|
||||
if (block.type === 'response') return acc
|
||||
const distanceSquared =
|
||||
(block.position.x - targetPosition.x) ** 2 + (block.position.y - targetPosition.y) ** 2
|
||||
if (!acc || distanceSquared < acc.distanceSquared) {
|
||||
return { ...block, distanceSquared }
|
||||
}
|
||||
return acc
|
||||
}, null)
|
||||
|
||||
return closest
|
||||
? {
|
||||
id: closest.id,
|
||||
type: closest.type,
|
||||
position: closest.position,
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
[]
|
||||
)
|
||||
@@ -1637,39 +1743,27 @@ const WorkflowContent = React.memo(() => {
|
||||
// Check if hovering over a container node
|
||||
const containerInfo = isPointInLoopNode(position)
|
||||
|
||||
// Clear any previous highlighting
|
||||
clearDragHighlights()
|
||||
|
||||
// Highlight container if hovering over it and not dragging a subflow
|
||||
// Subflow drag is marked by body class flag set by toolbar
|
||||
const isSubflowDrag = document.body.classList.contains('sim-drag-subflow')
|
||||
|
||||
if (containerInfo && !isSubflowDrag) {
|
||||
const containerElement = document.querySelector(`[data-id="${containerInfo.loopId}"]`)
|
||||
if (containerElement) {
|
||||
// Determine the type of container node for appropriate styling
|
||||
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
||||
if (
|
||||
containerNode?.type === 'subflowNode' &&
|
||||
(containerNode.data as SubflowNodeData)?.kind === 'loop'
|
||||
) {
|
||||
containerElement.classList.add('loop-node-drag-over')
|
||||
} else if (
|
||||
containerNode?.type === 'subflowNode' &&
|
||||
(containerNode.data as SubflowNodeData)?.kind === 'parallel'
|
||||
) {
|
||||
containerElement.classList.add('parallel-node-drag-over')
|
||||
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
||||
if (containerNode?.type === 'subflowNode') {
|
||||
const kind = (containerNode.data as SubflowNodeData)?.kind
|
||||
if (kind === 'loop' || kind === 'parallel') {
|
||||
highlightContainerNode(containerInfo.loopId, kind)
|
||||
}
|
||||
document.body.style.cursor = 'copy'
|
||||
}
|
||||
} else {
|
||||
clearDragHighlights()
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error in onDragOver', { err })
|
||||
}
|
||||
},
|
||||
[screenToFlowPosition, isPointInLoopNode, getNodes]
|
||||
[screenToFlowPosition, isPointInLoopNode, getNodes, highlightContainerNode]
|
||||
)
|
||||
|
||||
const loadingWorkflowRef = useRef<string | null>(null)
|
||||
@@ -1974,6 +2068,7 @@ const WorkflowContent = React.memo(() => {
|
||||
const targetInSelection = movingNodeIds.has(e.target)
|
||||
return sourceInSelection !== targetInSelection
|
||||
})
|
||||
const boundaryEdgesByNode = mapEdgesByNode(boundaryEdges, movingNodeIds)
|
||||
|
||||
// Collect absolute positions BEFORE any mutations
|
||||
const absolutePositions = new Map<string, { x: number; y: number }>()
|
||||
@@ -1984,9 +2079,7 @@ const WorkflowContent = React.memo(() => {
|
||||
// Build batch update with all blocks and their affected edges
|
||||
const updates = validBlockIds.map((blockId) => {
|
||||
const absolutePosition = absolutePositions.get(blockId)!
|
||||
const edgesForThisNode = boundaryEdges.filter(
|
||||
(e) => e.source === blockId || e.target === blockId
|
||||
)
|
||||
const edgesForThisNode = boundaryEdgesByNode.get(blockId) ?? []
|
||||
return {
|
||||
blockId,
|
||||
newParentId: null,
|
||||
@@ -2443,23 +2536,9 @@ const WorkflowContent = React.memo(() => {
|
||||
setPotentialParentId(bestContainerMatch.container.id)
|
||||
|
||||
// Add highlight class and change cursor
|
||||
const containerElement = document.querySelector(
|
||||
`[data-id="${bestContainerMatch.container.id}"]`
|
||||
)
|
||||
if (containerElement) {
|
||||
// Apply appropriate class based on container type
|
||||
if (
|
||||
bestContainerMatch.container.type === 'subflowNode' &&
|
||||
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'loop'
|
||||
) {
|
||||
containerElement.classList.add('loop-node-drag-over')
|
||||
} else if (
|
||||
bestContainerMatch.container.type === 'subflowNode' &&
|
||||
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'parallel'
|
||||
) {
|
||||
containerElement.classList.add('parallel-node-drag-over')
|
||||
}
|
||||
document.body.style.cursor = 'copy'
|
||||
const kind = (bestContainerMatch.container.data as SubflowNodeData)?.kind
|
||||
if (kind === 'loop' || kind === 'parallel') {
|
||||
highlightContainerNode(bestContainerMatch.container.id, kind)
|
||||
}
|
||||
} else {
|
||||
// Remove highlighting if no longer over a container
|
||||
@@ -2476,6 +2555,7 @@ const WorkflowContent = React.memo(() => {
|
||||
getNodeAbsolutePosition,
|
||||
getNodeDepth,
|
||||
updateContainerDimensionsDuringDrag,
|
||||
highlightContainerNode,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -2529,103 +2609,12 @@ const WorkflowContent = React.memo(() => {
|
||||
previousPositions: multiNodeDragStartRef.current,
|
||||
})
|
||||
|
||||
// Process parent updates for nodes whose parent is changing
|
||||
// Check each node individually - don't rely on dragStartParentId since
|
||||
// multi-node selections can contain nodes from different parents
|
||||
const selectedNodeIds = new Set(selectedNodes.map((n) => n.id))
|
||||
const nodesNeedingParentUpdate = selectedNodes.filter((n) => {
|
||||
const block = blocks[n.id]
|
||||
if (!block) return false
|
||||
const currentParent = block.data?.parentId || null
|
||||
// Skip if the node's parent is also being moved (keep children with their parent)
|
||||
if (currentParent && selectedNodeIds.has(currentParent)) return false
|
||||
// Node needs update if current parent !== target parent
|
||||
return currentParent !== potentialParentId
|
||||
})
|
||||
|
||||
if (nodesNeedingParentUpdate.length > 0) {
|
||||
// Filter out nodes that cannot be moved into subflows (when target is a subflow)
|
||||
const validNodes = nodesNeedingParentUpdate.filter((n) => {
|
||||
// These restrictions only apply when moving INTO a subflow
|
||||
if (potentialParentId) {
|
||||
if (n.data?.type === 'starter') return false
|
||||
const block = blocks[n.id]
|
||||
if (block && TriggerUtils.isTriggerBlock(block)) return false
|
||||
if (n.type === 'subflowNode') return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (validNodes.length > 0) {
|
||||
const movingNodeIds = new Set(validNodes.map((n) => n.id))
|
||||
const boundaryEdges = edgesForDisplay.filter((e) => {
|
||||
const sourceInSelection = movingNodeIds.has(e.source)
|
||||
const targetInSelection = movingNodeIds.has(e.target)
|
||||
return sourceInSelection !== targetInSelection
|
||||
})
|
||||
|
||||
const rawUpdates = validNodes.map((n) => {
|
||||
const edgesForThisNode = boundaryEdges.filter(
|
||||
(e) => e.source === n.id || e.target === n.id
|
||||
)
|
||||
const newPosition = potentialParentId
|
||||
? calculateRelativePosition(n.id, potentialParentId, true)
|
||||
: getNodeAbsolutePosition(n.id)
|
||||
return {
|
||||
blockId: n.id,
|
||||
newParentId: potentialParentId,
|
||||
newPosition,
|
||||
affectedEdges: edgesForThisNode,
|
||||
}
|
||||
})
|
||||
|
||||
let updates = rawUpdates
|
||||
if (potentialParentId) {
|
||||
const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x))
|
||||
const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y))
|
||||
|
||||
const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING
|
||||
const targetMinY =
|
||||
CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
|
||||
|
||||
const shiftX = minX < targetMinX ? targetMinX - minX : 0
|
||||
const shiftY = minY < targetMinY ? targetMinY - minY : 0
|
||||
|
||||
updates = rawUpdates.map((u) => ({
|
||||
...u,
|
||||
newPosition: {
|
||||
x: u.newPosition.x + shiftX,
|
||||
y: u.newPosition.y + shiftY,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
collaborativeBatchUpdateParent(updates)
|
||||
|
||||
setDisplayNodes((nodes) =>
|
||||
nodes.map((node) => {
|
||||
const update = updates.find((u) => u.blockId === node.id)
|
||||
if (update) {
|
||||
return {
|
||||
...node,
|
||||
position: update.newPosition,
|
||||
parentId: update.newParentId ?? undefined,
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
)
|
||||
|
||||
if (potentialParentId) {
|
||||
resizeLoopNodesWrapper()
|
||||
}
|
||||
|
||||
logger.info('Batch moved nodes to new parent', {
|
||||
targetParentId: potentialParentId,
|
||||
nodeCount: validNodes.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Process parent updates using shared helper
|
||||
executeBatchParentUpdate(
|
||||
selectedNodes,
|
||||
potentialParentId,
|
||||
'Batch moved nodes to new parent'
|
||||
)
|
||||
|
||||
// Clear drag start state
|
||||
setDragStartPosition(null)
|
||||
@@ -2818,31 +2807,15 @@ const WorkflowContent = React.memo(() => {
|
||||
edgesForDisplay,
|
||||
removeEdgesForNode,
|
||||
getNodeAbsolutePosition,
|
||||
calculateRelativePosition,
|
||||
resizeLoopNodesWrapper,
|
||||
getDragStartPosition,
|
||||
setDragStartPosition,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
collaborativeBatchUpdatePositions,
|
||||
collaborativeBatchUpdateParent,
|
||||
executeBatchParentUpdate,
|
||||
]
|
||||
)
|
||||
|
||||
// // Lock selection mode when selection drag starts (captures Shift state at drag start)
|
||||
// const onSelectionStart = useCallback(() => {
|
||||
// if (isShiftPressed) {
|
||||
// setIsSelectionDragActive(true)
|
||||
// }
|
||||
// }, [isShiftPressed])
|
||||
|
||||
// const onSelectionEnd = useCallback(() => {
|
||||
// requestAnimationFrame(() => {
|
||||
// setIsSelectionDragActive(false)
|
||||
// setDisplayNodes((nodes) => resolveParentChildSelectionConflicts(nodes, blocks))
|
||||
// })
|
||||
// }, [blocks])
|
||||
|
||||
/** Captures initial positions when selection drag starts (for marquee-selected nodes). */
|
||||
const onSelectionDragStart = useCallback(
|
||||
(_event: React.MouseEvent, nodes: Node[]) => {
|
||||
@@ -2883,13 +2856,7 @@ const WorkflowContent = React.memo(() => {
|
||||
if (nodes.length === 0) return
|
||||
|
||||
// Filter out nodes that can't be placed in containers
|
||||
const eligibleNodes = nodes.filter((n) => {
|
||||
if (n.data?.type === 'starter') return false
|
||||
if (n.type === 'subflowNode') return false
|
||||
const block = blocks[n.id]
|
||||
if (block && TriggerUtils.isTriggerBlock(block)) return false
|
||||
return true
|
||||
})
|
||||
const eligibleNodes = nodes.filter(canNodeEnterContainer)
|
||||
|
||||
// If no eligible nodes, clear any potential parent
|
||||
if (eligibleNodes.length === 0) {
|
||||
@@ -2969,18 +2936,12 @@ const WorkflowContent = React.memo(() => {
|
||||
const bestMatch = sortedContainers[0]
|
||||
|
||||
if (bestMatch.container.id !== potentialParentId) {
|
||||
clearDragHighlights()
|
||||
setPotentialParentId(bestMatch.container.id)
|
||||
|
||||
// Add highlight
|
||||
const containerElement = document.querySelector(`[data-id="${bestMatch.container.id}"]`)
|
||||
if (containerElement) {
|
||||
if ((bestMatch.container.data as SubflowNodeData)?.kind === 'loop') {
|
||||
containerElement.classList.add('loop-node-drag-over')
|
||||
} else if ((bestMatch.container.data as SubflowNodeData)?.kind === 'parallel') {
|
||||
containerElement.classList.add('parallel-node-drag-over')
|
||||
}
|
||||
document.body.style.cursor = 'copy'
|
||||
const kind = (bestMatch.container.data as SubflowNodeData)?.kind
|
||||
if (kind === 'loop' || kind === 'parallel') {
|
||||
highlightContainerNode(bestMatch.container.id, kind)
|
||||
}
|
||||
}
|
||||
} else if (potentialParentId) {
|
||||
@@ -2989,12 +2950,13 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
},
|
||||
[
|
||||
blocks,
|
||||
canNodeEnterContainer,
|
||||
getNodes,
|
||||
potentialParentId,
|
||||
getNodeAbsolutePosition,
|
||||
getNodeDepth,
|
||||
clearDragHighlights,
|
||||
highlightContainerNode,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -3009,102 +2971,8 @@ const WorkflowContent = React.memo(() => {
|
||||
previousPositions: multiNodeDragStartRef.current,
|
||||
})
|
||||
|
||||
// Process parent updates for nodes whose parent is changing
|
||||
// Check each node individually - don't rely on dragStartParentId since
|
||||
// multi-node selections can contain nodes from different parents
|
||||
const selectedNodeIds = new Set(nodes.map((n: Node) => n.id))
|
||||
const nodesNeedingParentUpdate = nodes.filter((n: Node) => {
|
||||
const block = blocks[n.id]
|
||||
if (!block) return false
|
||||
const currentParent = block.data?.parentId || null
|
||||
// Skip if the node's parent is also being moved (keep children with their parent)
|
||||
if (currentParent && selectedNodeIds.has(currentParent)) return false
|
||||
// Node needs update if current parent !== target parent
|
||||
return currentParent !== potentialParentId
|
||||
})
|
||||
|
||||
if (nodesNeedingParentUpdate.length > 0) {
|
||||
// Filter out nodes that cannot be moved into subflows (when target is a subflow)
|
||||
const validNodes = nodesNeedingParentUpdate.filter((n: Node) => {
|
||||
// These restrictions only apply when moving INTO a subflow
|
||||
if (potentialParentId) {
|
||||
if (n.data?.type === 'starter') return false
|
||||
const block = blocks[n.id]
|
||||
if (block && TriggerUtils.isTriggerBlock(block)) return false
|
||||
if (n.type === 'subflowNode') return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (validNodes.length > 0) {
|
||||
const movingNodeIds = new Set(validNodes.map((n: Node) => n.id))
|
||||
const boundaryEdges = edgesForDisplay.filter((e) => {
|
||||
const sourceInSelection = movingNodeIds.has(e.source)
|
||||
const targetInSelection = movingNodeIds.has(e.target)
|
||||
return sourceInSelection !== targetInSelection
|
||||
})
|
||||
|
||||
const rawUpdates = validNodes.map((n: Node) => {
|
||||
const edgesForThisNode = boundaryEdges.filter(
|
||||
(e) => e.source === n.id || e.target === n.id
|
||||
)
|
||||
const newPosition = potentialParentId
|
||||
? calculateRelativePosition(n.id, potentialParentId, true)
|
||||
: getNodeAbsolutePosition(n.id)
|
||||
return {
|
||||
blockId: n.id,
|
||||
newParentId: potentialParentId,
|
||||
newPosition,
|
||||
affectedEdges: edgesForThisNode,
|
||||
}
|
||||
})
|
||||
|
||||
let updates = rawUpdates
|
||||
if (potentialParentId) {
|
||||
const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x))
|
||||
const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y))
|
||||
|
||||
const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING
|
||||
const targetMinY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING
|
||||
|
||||
const shiftX = minX < targetMinX ? targetMinX - minX : 0
|
||||
const shiftY = minY < targetMinY ? targetMinY - minY : 0
|
||||
|
||||
updates = rawUpdates.map((u) => ({
|
||||
...u,
|
||||
newPosition: {
|
||||
x: u.newPosition.x + shiftX,
|
||||
y: u.newPosition.y + shiftY,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
collaborativeBatchUpdateParent(updates)
|
||||
|
||||
setDisplayNodes((nodes) =>
|
||||
nodes.map((node) => {
|
||||
const update = updates.find((u) => u.blockId === node.id)
|
||||
if (update) {
|
||||
return {
|
||||
...node,
|
||||
position: update.newPosition,
|
||||
parentId: update.newParentId ?? undefined,
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
)
|
||||
|
||||
if (potentialParentId) {
|
||||
resizeLoopNodesWrapper()
|
||||
}
|
||||
|
||||
logger.info('Batch moved selection to new parent', {
|
||||
targetParentId: potentialParentId,
|
||||
nodeCount: validNodes.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Process parent updates using shared helper
|
||||
executeBatchParentUpdate(nodes, potentialParentId, 'Batch moved selection to new parent')
|
||||
|
||||
// Clear drag state
|
||||
setDragStartPosition(null)
|
||||
@@ -3114,14 +2982,10 @@ const WorkflowContent = React.memo(() => {
|
||||
[
|
||||
blocks,
|
||||
getNodes,
|
||||
getNodeAbsolutePosition,
|
||||
collaborativeBatchUpdatePositions,
|
||||
collaborativeBatchUpdateParent,
|
||||
calculateRelativePosition,
|
||||
resizeLoopNodesWrapper,
|
||||
potentialParentId,
|
||||
edgesForDisplay,
|
||||
clearDragHighlights,
|
||||
executeBatchParentUpdate,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -3130,6 +2994,23 @@ const WorkflowContent = React.memo(() => {
|
||||
usePanelEditorStore.getState().clearCurrentBlock()
|
||||
}, [])
|
||||
|
||||
/** Prevents native text selection when starting a shift-drag on the pane. */
|
||||
const handleCanvasMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (!event.shiftKey) return
|
||||
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target) return
|
||||
|
||||
const isPaneTarget = Boolean(target.closest('.react-flow__pane, .react-flow__selectionpane'))
|
||||
if (!isPaneTarget) return
|
||||
|
||||
event.preventDefault()
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
selection.removeAllRanges()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles node click to select the node in ReactFlow.
|
||||
* Parent-child conflict resolution happens automatically in onNodesChange.
|
||||
@@ -3303,6 +3184,7 @@ const WorkflowContent = React.memo(() => {
|
||||
onConnectEnd={effectivePermissions.canEdit ? onConnectEnd : undefined}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onDrop={effectivePermissions.canEdit ? onDrop : undefined}
|
||||
onDragOver={effectivePermissions.canEdit ? onDragOver : undefined}
|
||||
onInit={(instance) => {
|
||||
|
||||
@@ -1156,11 +1156,6 @@ function BlockDetailsSidebarContent({
|
||||
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
{block.name || blockConfig.name}
|
||||
</span>
|
||||
{block.enabled === false && (
|
||||
<Badge variant='red' size='sm'>
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
{onClose && (
|
||||
<Button variant='ghost' className='!p-[4px] flex-shrink-0' onClick={onClose}>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
|
||||
@@ -181,17 +181,18 @@ interface FitViewOnChangeProps {
|
||||
*/
|
||||
function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) {
|
||||
const { fitView } = useReactFlow()
|
||||
const hasFittedRef = useRef(false)
|
||||
const lastNodeIdsRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (nodeIds.length > 0 && !hasFittedRef.current) {
|
||||
hasFittedRef.current = true
|
||||
// Small delay to ensure nodes are rendered before fitting
|
||||
const timeoutId = setTimeout(() => {
|
||||
fitView({ padding: fitPadding, duration: 200 })
|
||||
}, 50)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
if (!nodeIds.length) return
|
||||
const shouldFit = lastNodeIdsRef.current !== nodeIds
|
||||
if (!shouldFit) return
|
||||
lastNodeIdsRef.current = nodeIds
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
fitView({ padding: fitPadding, duration: 200 })
|
||||
}, 50)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [nodeIds, fitPadding, fitView])
|
||||
|
||||
return null
|
||||
|
||||
@@ -342,7 +342,7 @@ export function FolderItem({
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<span
|
||||
className='min-w-0 flex-1 truncate font-medium text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
onDoubleClick={handleDoubleClick}
|
||||
@@ -357,7 +357,7 @@ export function FolderItem({
|
||||
>
|
||||
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, useEffect, useMemo } from 'react'
|
||||
import { type CSSProperties, useMemo } from 'react'
|
||||
import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
@@ -19,11 +19,6 @@ const AVATAR_CONFIG = {
|
||||
|
||||
interface AvatarsProps {
|
||||
workflowId: string
|
||||
/**
|
||||
* Callback fired when the presence visibility changes.
|
||||
* Used by parent components to adjust layout (e.g., text truncation spacing).
|
||||
*/
|
||||
onPresenceChange?: (hasAvatars: boolean) => void
|
||||
}
|
||||
|
||||
interface PresenceUser {
|
||||
@@ -85,7 +80,7 @@ function UserAvatar({ user, index }: UserAvatarProps) {
|
||||
* @param props - Component props
|
||||
* @returns Avatar stack for workflow presence
|
||||
*/
|
||||
export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
|
||||
export function Avatars({ workflowId }: AvatarsProps) {
|
||||
const { presenceUsers, currentWorkflowId } = useSocket()
|
||||
const { data: session } = useSession()
|
||||
const currentUserId = session?.user?.id
|
||||
@@ -127,19 +122,12 @@ export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
|
||||
return { visibleUsers: visible, overflowCount: overflow }
|
||||
}, [workflowUsers, maxVisible])
|
||||
|
||||
useEffect(() => {
|
||||
const hasAnyAvatars = visibleUsers.length > 0
|
||||
if (typeof onPresenceChange === 'function') {
|
||||
onPresenceChange(hasAnyAvatars)
|
||||
}
|
||||
}, [visibleUsers, onPresenceChange])
|
||||
|
||||
if (visibleUsers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='-space-x-1 flex items-center'>
|
||||
<div className='-space-x-1 flex flex-shrink-0 items-center'>
|
||||
{overflowCount > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
|
||||
@@ -64,8 +64,6 @@ export function WorkflowItem({
|
||||
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
|
||||
const [canDeleteCaptured, setCanDeleteCaptured] = useState(true)
|
||||
|
||||
const [hasAvatars, setHasAvatars] = useState(false)
|
||||
|
||||
const capturedSelectionRef = useRef<{
|
||||
workflowIds: string[]
|
||||
workflowNames: string | string[]
|
||||
@@ -319,48 +317,50 @@ export function WorkflowItem({
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
|
||||
style={{ backgroundColor: workflow.color }}
|
||||
/>
|
||||
<div className={clsx('min-w-0 flex-1', hasAvatars && 'pr-[8px]')}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
className={clsx(
|
||||
'w-full border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
active
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
maxLength={100}
|
||||
disabled={isRenaming}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
'truncate font-medium',
|
||||
active
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
)}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
className={clsx(
|
||||
'w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
active
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
maxLength={100}
|
||||
disabled={isRenaming}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
'min-w-0 flex-1 truncate font-medium',
|
||||
active
|
||||
? 'text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
)}
|
||||
{!isEditing && <Avatars workflowId={workflow.id} />}
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Avatars workflowId={workflow.id} onPresenceChange={setHasAvatars} />
|
||||
<button
|
||||
type='button'
|
||||
onPointerDown={handleMorePointerDown}
|
||||
|
||||
Reference in New Issue
Block a user