mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
Consolidated workflow and workflow-block for simplicity and clarity with ReactFlow
This commit is contained in:
@@ -1,51 +1,56 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { RectangleHorizontal, RectangleVertical } from 'lucide-react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import { useUpdateNodeInternals } from 'reactflow'
|
||||
import { Handle, NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflow/store'
|
||||
import { BlockConfig, SubBlockConfig } from '../../../../../blocks/types'
|
||||
import { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
||||
import { ActionBar } from './components/action-bar/action-bar'
|
||||
import { ConnectionBlocks } from './components/connection-blocks/connection-blocks'
|
||||
import { SubBlock } from './components/sub-block/sub-block'
|
||||
|
||||
interface WorkflowBlockProps {
|
||||
id: string
|
||||
interface WorkflowBlockData {
|
||||
type: string
|
||||
position: { x: number; y: number }
|
||||
config: BlockConfig
|
||||
name: string
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBlockProps) {
|
||||
// Combine both interfaces into a single component
|
||||
export function WorkflowBlock({ id, data, selected }: NodeProps<WorkflowBlockData>) {
|
||||
const { type, config, name } = data
|
||||
const { toolbar, workflow } = config
|
||||
|
||||
// State management
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedName, setEditedName] = useState('')
|
||||
|
||||
// Refs
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
|
||||
// Store selectors
|
||||
const isEnabled = useWorkflowStore((state) => state.blocks[id]?.enabled ?? true)
|
||||
const horizontalHandles = useWorkflowStore(
|
||||
(state) => state.blocks[id]?.horizontalHandles ?? false
|
||||
)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedName, setEditedName] = useState('')
|
||||
const updateBlockName = useWorkflowStore((state) => state.updateBlockName)
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
const isWide = useWorkflowStore((state) => state.blocks[id]?.isWide ?? false)
|
||||
|
||||
// Store actions
|
||||
const updateBlockName = useWorkflowStore((state) => state.updateBlockName)
|
||||
const toggleBlockWide = useWorkflowStore((state) => state.toggleBlockWide)
|
||||
|
||||
// Add effect to update node internals when handles change
|
||||
// Update node internals when handles change
|
||||
useEffect(() => {
|
||||
updateNodeInternals(id)
|
||||
}, [id, horizontalHandles])
|
||||
}, [id, horizontalHandles, updateNodeInternals])
|
||||
|
||||
// SubBlock layout management
|
||||
function groupSubBlocks(subBlocks: SubBlockConfig[]) {
|
||||
// Filter out hidden subblocks
|
||||
const visibleSubBlocks = subBlocks.filter((block) => !block.hidden)
|
||||
|
||||
const rows: SubBlockConfig[][] = []
|
||||
let currentRow: SubBlockConfig[] = []
|
||||
let currentRowWidth = 0
|
||||
@@ -71,6 +76,7 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
|
||||
|
||||
const subBlockRows = groupSubBlocks(workflow.subBlocks)
|
||||
|
||||
// Name editing handlers
|
||||
const handleNameClick = () => {
|
||||
setEditedName(name)
|
||||
setIsEditing(true)
|
||||
@@ -104,6 +110,7 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
|
||||
{selected && <ActionBar blockId={id} />}
|
||||
<ConnectionBlocks blockId={id} setIsConnecting={setIsConnecting} />
|
||||
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={horizontalHandles ? Position.Left : Position.Top}
|
||||
@@ -121,6 +128,7 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
|
||||
isConnectableEnd={true}
|
||||
/>
|
||||
|
||||
{/* Block Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b workflow-drag-handle cursor-grab [&:active]:cursor-grabbing">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
@@ -175,6 +183,7 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block Content */}
|
||||
<div className="px-4 pt-3 pb-4 space-y-4 cursor-pointer">
|
||||
{subBlockRows.map((row, rowIndex) => (
|
||||
<div key={`row-${rowIndex}`} className="flex gap-4">
|
||||
@@ -190,7 +199,7 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main output handle - only render if not a condition block */}
|
||||
{/* Output Handle */}
|
||||
{type !== 'condition' && (
|
||||
<Handle
|
||||
type="source"
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
ConnectionLineType,
|
||||
EdgeTypes,
|
||||
NodeTypes,
|
||||
useOnViewportChange,
|
||||
useReactFlow,
|
||||
} from 'reactflow'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { initializeStateLogger } from '@/stores/workflow/logger'
|
||||
import { useWorkflowStore } from '@/stores/workflow/store'
|
||||
import { NotificationList } from '@/app/w/components/notifications/notifications'
|
||||
import { getBlock } from '../../../../../blocks'
|
||||
import { useWorkflowExecution } from '../../../hooks/use-workflow-execution'
|
||||
import { CustomEdge } from '../custom-edge/custom-edge'
|
||||
import { createLoopNode, getRelativeLoopPosition } from '../workflow-loop/workflow-loop'
|
||||
import { WorkflowNode } from '../workflow-node/workflow-node'
|
||||
|
||||
// Define custom node and edge types for ReactFlow
|
||||
const nodeTypes: NodeTypes = {
|
||||
workflowBlock: WorkflowNode,
|
||||
}
|
||||
const edgeTypes: EdgeTypes = { custom: CustomEdge }
|
||||
|
||||
export function WorkflowCanvas() {
|
||||
// Track selected elements in the workflow
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null)
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||
|
||||
// Store references and state management hooks
|
||||
const { isExecuting, executionResult, handleRunWorkflow } = useWorkflowExecution()
|
||||
const {
|
||||
blocks,
|
||||
edges,
|
||||
addBlock,
|
||||
updateBlockPosition,
|
||||
addEdge,
|
||||
removeEdge,
|
||||
canUndo,
|
||||
canRedo,
|
||||
undo,
|
||||
redo,
|
||||
} = useWorkflowStore()
|
||||
const { addNotification } = useNotificationStore()
|
||||
const { project, setViewport } = useReactFlow()
|
||||
const { loops } = useWorkflowStore()
|
||||
|
||||
// Transform blocks and loops into ReactFlow node format
|
||||
const nodes = useMemo(() => {
|
||||
const nodeArray: any[] = []
|
||||
|
||||
// Add loop group nodes first
|
||||
Object.entries(loops).forEach(([loopId, loop]) => {
|
||||
const loopNode = createLoopNode({ loopId, loop, blocks })
|
||||
if (loopNode) {
|
||||
nodeArray.push(loopNode)
|
||||
}
|
||||
})
|
||||
|
||||
// Add block nodes with relative positions if they're in a loop
|
||||
Object.entries(blocks).forEach(([blockId, block]) => {
|
||||
// Skip loop position entries that don't have proper block structure
|
||||
if (!block.type || !block.name) {
|
||||
console.log('Skipping invalid block:', blockId, block)
|
||||
return
|
||||
}
|
||||
|
||||
// Get block configuration
|
||||
const blockConfig = getBlock(block.type)
|
||||
if (!blockConfig) {
|
||||
console.error(`No configuration found for block type: ${block.type}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Find if block belongs to any loop
|
||||
const parentLoop = Object.entries(loops).find(([_, loop]) => loop.nodes.includes(block.id))
|
||||
|
||||
let position = block.position
|
||||
if (parentLoop) {
|
||||
const [loopId] = parentLoop
|
||||
const loopNode = nodeArray.find((node) => node.id === `loop-${loopId}`)
|
||||
if (loopNode) {
|
||||
position = getRelativeLoopPosition(block.position, loopNode.position)
|
||||
}
|
||||
}
|
||||
|
||||
nodeArray.push({
|
||||
id: block.id,
|
||||
type: 'workflowBlock',
|
||||
position,
|
||||
parentId: parentLoop ? `loop-${parentLoop[0]}` : undefined,
|
||||
dragHandle: '.workflow-drag-handle',
|
||||
selected: block.id === selectedBlockId,
|
||||
data: {
|
||||
type: block.type,
|
||||
config: blockConfig,
|
||||
name: block.name,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return nodeArray
|
||||
}, [blocks, loops, selectedBlockId])
|
||||
|
||||
// Update node position handler
|
||||
const onNodesChange = useCallback(
|
||||
(changes: any) => {
|
||||
changes.forEach((change: any) => {
|
||||
if (change.type === 'position' && change.position) {
|
||||
const node = nodes.find((n) => n.id === change.id)
|
||||
if (!node) return
|
||||
|
||||
// If node is part of a loop, convert position back to absolute
|
||||
if (node.parentId) {
|
||||
const loopNode = nodes.find((n) => n.id === node.parentId)
|
||||
if (loopNode) {
|
||||
const absolutePosition = {
|
||||
x: change.position.x + loopNode.position.x,
|
||||
y: change.position.y + loopNode.position.y,
|
||||
}
|
||||
updateBlockPosition(change.id, absolutePosition)
|
||||
}
|
||||
} else {
|
||||
updateBlockPosition(change.id, change.position)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[nodes, updateBlockPosition]
|
||||
)
|
||||
|
||||
// Handle edge removal and updates
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: any) => {
|
||||
changes.forEach((change: any) => {
|
||||
if (change.type === 'remove') {
|
||||
removeEdge(change.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
[removeEdge]
|
||||
)
|
||||
|
||||
// Create new edges when nodes are connected
|
||||
const onConnect = useCallback(
|
||||
(connection: any) => {
|
||||
// Use ReactFlow's addEdge utility
|
||||
const newEdge = {
|
||||
...connection,
|
||||
id: crypto.randomUUID(),
|
||||
type: 'custom',
|
||||
}
|
||||
|
||||
// Validate connection before adding
|
||||
if (newEdge.source && newEdge.target) {
|
||||
addEdge(newEdge)
|
||||
}
|
||||
},
|
||||
[addEdge]
|
||||
)
|
||||
|
||||
// Handle new block creation from toolbar drag and drop
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.dataTransfer.getData('application/json'))
|
||||
if (data.type === 'connectionBlock') return
|
||||
|
||||
const reactFlowBounds = event.currentTarget.getBoundingClientRect()
|
||||
const position = project({
|
||||
x: event.clientX - reactFlowBounds.left,
|
||||
y: event.clientY - reactFlowBounds.top,
|
||||
})
|
||||
|
||||
const blockConfig = getBlock(data.type)
|
||||
if (!blockConfig) {
|
||||
console.error('Invalid block type:', data.type)
|
||||
return
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const name = `${blockConfig.toolbar.title} ${
|
||||
Object.values(blocks).filter((b) => b.type === data.type).length + 1
|
||||
}`
|
||||
|
||||
addBlock(id, data.type, name, position)
|
||||
} catch (err) {
|
||||
console.error('Error dropping block:', err)
|
||||
}
|
||||
},
|
||||
[project, blocks, addBlock]
|
||||
)
|
||||
|
||||
// Update selection state when clicking nodes
|
||||
const onNodeClick = useCallback((event: React.MouseEvent, node: any) => {
|
||||
event.stopPropagation()
|
||||
setSelectedBlockId(node.id)
|
||||
setSelectedEdgeId(null)
|
||||
}, [])
|
||||
|
||||
// Update onPaneClick to clear selections
|
||||
const onPaneClick = useCallback((event: React.MouseEvent) => {
|
||||
setSelectedBlockId(null)
|
||||
setSelectedEdgeId(null)
|
||||
}, [])
|
||||
|
||||
// Update selected edge when clicking on connections
|
||||
const onEdgeClick = useCallback((event: React.MouseEvent, edge: any) => {
|
||||
setSelectedEdgeId(edge.id)
|
||||
setSelectedBlockId(null)
|
||||
}, [])
|
||||
|
||||
// Handle keyboard shortcuts for edge deletion
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedEdgeId) {
|
||||
removeEdge(selectedEdgeId)
|
||||
setSelectedEdgeId(null)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedEdgeId, removeEdge])
|
||||
|
||||
// Initialize state logging for debugging
|
||||
useEffect(() => {
|
||||
initializeStateLogger()
|
||||
}, [])
|
||||
|
||||
const edgesWithSelection = edges.map((edge) => ({
|
||||
...edge,
|
||||
type: edge.type || 'custom',
|
||||
data: {
|
||||
selectedEdgeId,
|
||||
onDelete: (edgeId: string) => {
|
||||
removeEdge(edgeId)
|
||||
setSelectedEdgeId(null)
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Replace useViewport with useOnViewportChange
|
||||
useOnViewportChange({
|
||||
onStart: ({ x, y, zoom }) => {
|
||||
console.log('Viewport change start:', {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
zoom: zoom.toFixed(2),
|
||||
})
|
||||
},
|
||||
onEnd: ({ x, y, zoom }) => {
|
||||
console.log('Viewport change end:', {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
zoom: zoom.toFixed(2),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// For the random movement function, we can use setViewport from useReactFlow
|
||||
const moveToRandomLocation = useCallback(() => {
|
||||
const randomX = Math.random() * 1000 - 500
|
||||
const randomY = Math.random() * 1000 - 500
|
||||
const randomZoom = Math.random() * 0.5 + 0.5
|
||||
|
||||
setViewport(
|
||||
{
|
||||
x: randomX,
|
||||
y: randomY,
|
||||
zoom: randomZoom,
|
||||
},
|
||||
{ duration: 500 }
|
||||
)
|
||||
}, [setViewport])
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-[calc(100vh-4rem)]">
|
||||
<NotificationList />
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edgesWithSelection}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
onDrop={onDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
fitView
|
||||
maxZoom={1}
|
||||
panOnScroll
|
||||
defaultEdgeOptions={{ type: 'custom' }}
|
||||
edgeTypes={edgeTypes}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
connectionLineStyle={{
|
||||
stroke: '#94a3b8',
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: '5,5',
|
||||
}}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
elementsSelectable={true}
|
||||
selectNodesOnDrag={false}
|
||||
nodesConnectable={true}
|
||||
nodesDraggable={true}
|
||||
draggable={false}
|
||||
noWheelClassName="allow-scroll"
|
||||
edgesFocusable={true}
|
||||
edgesUpdatable={true}
|
||||
>
|
||||
<Background />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NodeProps } from 'reactflow'
|
||||
import { useWorkflowStore } from '@/stores/workflow/store'
|
||||
import { BlockConfig } from '../../../../../blocks/types'
|
||||
import { WorkflowBlock } from '../workflow-block/workflow-block'
|
||||
|
||||
interface WorkflowNodeData {
|
||||
type: string
|
||||
config: BlockConfig
|
||||
name: string
|
||||
}
|
||||
|
||||
export const WorkflowNode = ({ data, id, xPos, yPos, selected }: NodeProps<WorkflowNodeData>) => {
|
||||
const [key, setKey] = useState(0)
|
||||
const horizontalHandles = useWorkflowStore(
|
||||
(state) => state.blocks[id]?.horizontalHandles ?? false
|
||||
)
|
||||
|
||||
// Add effect to trigger immediate re-render after handle toggle
|
||||
useEffect(() => {
|
||||
setKey((prev) => prev + 1)
|
||||
}, [horizontalHandles])
|
||||
|
||||
return (
|
||||
<WorkflowBlock
|
||||
key={key}
|
||||
id={id}
|
||||
type={data.type}
|
||||
position={{ x: xPos, y: yPos }}
|
||||
config={data.config}
|
||||
name={data.name}
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { ReactFlowProvider } from 'reactflow'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
ConnectionLineType,
|
||||
EdgeTypes,
|
||||
NodeTypes,
|
||||
ReactFlowProvider,
|
||||
useOnViewportChange,
|
||||
useReactFlow,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { initializeStateLogger } from '@/stores/workflow/logger'
|
||||
import { useWorkflowRegistry } from '@/stores/workflow/registry'
|
||||
import { WorkflowCanvas } from './components/workflow-canvas/workflow-canvas'
|
||||
import { useWorkflowStore } from '@/stores/workflow/store'
|
||||
import { NotificationList } from '@/app/w/components/notifications/notifications'
|
||||
import { getBlock } from '../../../blocks'
|
||||
import { useWorkflowExecution } from '../hooks/use-workflow-execution'
|
||||
import { CustomEdge } from './components/custom-edge/custom-edge'
|
||||
import { WorkflowBlock } from './components/workflow-block/workflow-block'
|
||||
import { createLoopNode, getRelativeLoopPosition } from './components/workflow-loop/workflow-loop'
|
||||
|
||||
export default function Workflow() {
|
||||
// Track if initial data loading is complete
|
||||
// Define custom node and edge types
|
||||
const nodeTypes: NodeTypes = { workflowBlock: WorkflowBlock }
|
||||
const edgeTypes: EdgeTypes = { custom: CustomEdge }
|
||||
|
||||
function WorkflowContent() {
|
||||
// State
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null)
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
// Hooks
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { workflows, setActiveWorkflow, addWorkflow } = useWorkflowRegistry()
|
||||
const { project, setViewport } = useReactFlow()
|
||||
|
||||
// Load saved workflows from localStorage on component mount
|
||||
// Store access
|
||||
const { addNotification } = useNotificationStore()
|
||||
const { workflows, setActiveWorkflow, addWorkflow } = useWorkflowRegistry()
|
||||
const { blocks, edges, loops, addBlock, updateBlockPosition, addEdge, removeEdge } =
|
||||
useWorkflowStore()
|
||||
|
||||
// Initialize workflow
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedRegistry = localStorage.getItem('workflow-registry')
|
||||
@@ -26,11 +55,10 @@ export default function Workflow() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle workflow initialization and navigation
|
||||
// Init workflow
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return
|
||||
|
||||
// Create a new workflow with default values
|
||||
const createInitialWorkflow = () => {
|
||||
const id = crypto.randomUUID()
|
||||
const newWorkflow = {
|
||||
@@ -44,19 +72,16 @@ export default function Workflow() {
|
||||
return id
|
||||
}
|
||||
|
||||
// Ensure valid workflow ID and redirect if necessary
|
||||
const validateAndNavigate = () => {
|
||||
const workflowIds = Object.keys(workflows)
|
||||
const currentId = params.id as string
|
||||
|
||||
// Create first workflow if none exist
|
||||
if (workflowIds.length === 0) {
|
||||
const newId = createInitialWorkflow()
|
||||
router.replace(`/w/${newId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to first workflow if current ID is invalid
|
||||
if (!workflows[currentId]) {
|
||||
router.replace(`/w/${workflowIds[0]}`)
|
||||
return
|
||||
@@ -68,14 +93,243 @@ export default function Workflow() {
|
||||
validateAndNavigate()
|
||||
}, [params.id, workflows, setActiveWorkflow, addWorkflow, router, isInitialized])
|
||||
|
||||
// Don't render until initial data is loaded
|
||||
if (!isInitialized) {
|
||||
return null
|
||||
}
|
||||
// Transform blocks and loops into ReactFlow nodes
|
||||
const nodes = useMemo(() => {
|
||||
const nodeArray: any[] = []
|
||||
|
||||
// Add loop group nodes
|
||||
Object.entries(loops).forEach(([loopId, loop]) => {
|
||||
const loopNode = createLoopNode({ loopId, loop, blocks })
|
||||
if (loopNode) nodeArray.push(loopNode)
|
||||
})
|
||||
|
||||
// Add block nodes
|
||||
Object.entries(blocks).forEach(([blockId, block]) => {
|
||||
if (!block.type || !block.name) {
|
||||
console.log('Skipping invalid block:', blockId, block)
|
||||
return
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(block.type)
|
||||
if (!blockConfig) {
|
||||
console.error(`No configuration found for block type: ${block.type}`)
|
||||
return
|
||||
}
|
||||
|
||||
const parentLoop = Object.entries(loops).find(([_, loop]) => loop.nodes.includes(block.id))
|
||||
let position = block.position
|
||||
|
||||
if (parentLoop) {
|
||||
const [loopId] = parentLoop
|
||||
const loopNode = nodeArray.find((node) => node.id === `loop-${loopId}`)
|
||||
if (loopNode) {
|
||||
position = getRelativeLoopPosition(block.position, loopNode.position)
|
||||
}
|
||||
}
|
||||
|
||||
nodeArray.push({
|
||||
id: block.id,
|
||||
type: 'workflowBlock',
|
||||
position,
|
||||
parentId: parentLoop ? `loop-${parentLoop[0]}` : undefined,
|
||||
dragHandle: '.workflow-drag-handle',
|
||||
selected: block.id === selectedBlockId,
|
||||
data: {
|
||||
type: block.type,
|
||||
config: blockConfig,
|
||||
name: block.name,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return nodeArray
|
||||
}, [blocks, loops, selectedBlockId])
|
||||
|
||||
// Update nodes
|
||||
const onNodesChange = useCallback(
|
||||
(changes: any) => {
|
||||
changes.forEach((change: any) => {
|
||||
if (change.type === 'position' && change.position) {
|
||||
const node = nodes.find((n) => n.id === change.id)
|
||||
if (!node) return
|
||||
|
||||
if (node.parentId) {
|
||||
const loopNode = nodes.find((n) => n.id === node.parentId)
|
||||
if (loopNode) {
|
||||
const absolutePosition = {
|
||||
x: change.position.x + loopNode.position.x,
|
||||
y: change.position.y + loopNode.position.y,
|
||||
}
|
||||
updateBlockPosition(change.id, absolutePosition)
|
||||
}
|
||||
} else {
|
||||
updateBlockPosition(change.id, change.position)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[nodes, updateBlockPosition]
|
||||
)
|
||||
|
||||
// Update edges
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: any) => {
|
||||
changes.forEach((change: any) => {
|
||||
if (change.type === 'remove') {
|
||||
removeEdge(change.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
[removeEdge]
|
||||
)
|
||||
|
||||
// Handle connections
|
||||
const onConnect = useCallback(
|
||||
(connection: any) => {
|
||||
if (connection.source && connection.target) {
|
||||
addEdge({
|
||||
...connection,
|
||||
id: crypto.randomUUID(),
|
||||
type: 'custom',
|
||||
})
|
||||
}
|
||||
},
|
||||
[addEdge]
|
||||
)
|
||||
|
||||
// Handle drops
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.dataTransfer.getData('application/json'))
|
||||
if (data.type === 'connectionBlock') return
|
||||
|
||||
const reactFlowBounds = event.currentTarget.getBoundingClientRect()
|
||||
const position = project({
|
||||
x: event.clientX - reactFlowBounds.left,
|
||||
y: event.clientY - reactFlowBounds.top,
|
||||
})
|
||||
|
||||
const blockConfig = getBlock(data.type)
|
||||
if (!blockConfig) {
|
||||
console.error('Invalid block type:', data.type)
|
||||
return
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const name = `${blockConfig.toolbar.title} ${
|
||||
Object.values(blocks).filter((b) => b.type === data.type).length + 1
|
||||
}`
|
||||
|
||||
addBlock(id, data.type, name, position)
|
||||
} catch (err) {
|
||||
console.error('Error dropping block:', err)
|
||||
}
|
||||
},
|
||||
[project, blocks, addBlock]
|
||||
)
|
||||
|
||||
// Node selection
|
||||
const onNodeClick = useCallback((event: React.MouseEvent, node: any) => {
|
||||
event.stopPropagation()
|
||||
setSelectedBlockId(node.id)
|
||||
setSelectedEdgeId(null)
|
||||
}, [])
|
||||
|
||||
// Clear selection
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedBlockId(null)
|
||||
setSelectedEdgeId(null)
|
||||
}, [])
|
||||
|
||||
// Edge selection
|
||||
const onEdgeClick = useCallback((event: React.MouseEvent, edge: any) => {
|
||||
setSelectedEdgeId(edge.id)
|
||||
setSelectedBlockId(null)
|
||||
}, [])
|
||||
|
||||
// Transform edges to include selection state
|
||||
const edgesWithSelection = edges.map((edge) => ({
|
||||
...edge,
|
||||
type: edge.type || 'custom',
|
||||
data: {
|
||||
selectedEdgeId,
|
||||
onDelete: (edgeId: string) => {
|
||||
removeEdge(edgeId)
|
||||
setSelectedEdgeId(null)
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedEdgeId) {
|
||||
removeEdge(selectedEdgeId)
|
||||
setSelectedEdgeId(null)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedEdgeId, removeEdge])
|
||||
|
||||
// Initialize state logging
|
||||
useEffect(() => {
|
||||
initializeStateLogger()
|
||||
}, [])
|
||||
|
||||
if (!isInitialized) return null
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-[calc(100vh-4rem)]">
|
||||
<NotificationList />
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edgesWithSelection}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onDrop={onDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
fitView
|
||||
maxZoom={1}
|
||||
panOnScroll
|
||||
defaultEdgeOptions={{ type: 'custom' }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
connectionLineStyle={{
|
||||
stroke: '#94a3b8',
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: '5,5',
|
||||
}}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
elementsSelectable={true}
|
||||
selectNodesOnDrag={false}
|
||||
nodesConnectable={true}
|
||||
nodesDraggable={true}
|
||||
draggable={false}
|
||||
noWheelClassName="allow-scroll"
|
||||
edgesFocusable={true}
|
||||
edgesUpdatable={true}
|
||||
>
|
||||
<Background />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Workflow wrapper
|
||||
export default function Workflow() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowCanvas />
|
||||
<WorkflowContent />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user