mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(undo-redo): undo/redo for canvas editing (#1392)
* feat(undo-redo): support undo-redo on canvas * fix zoom live subscribe * progress * fix subflows * progress * fix subflow logic * pruning stacks * centralize unique naming logic * fix type issues * address greptile comments * remove timeouts
This commit is contained in:
+141
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { Minus, Plus, Redo2, Undo2 } from 'lucide-react'
|
||||
import { useReactFlow, useStore } from 'reactflow'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
export function FloatingControls() {
|
||||
const { zoomIn, zoomOut } = useReactFlow()
|
||||
// Subscribe to React Flow store so zoom % live-updates while zooming
|
||||
const zoom = useStore((s: any) =>
|
||||
Array.isArray(s.transform) ? s.transform[2] : s.viewport?.zoom
|
||||
)
|
||||
const { undo, redo } = useCollaborativeWorkflow()
|
||||
const { showFloatingControls } = useGeneralStore()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { data: session } = useSession()
|
||||
const userId = session?.user?.id || 'unknown'
|
||||
const stacks = useUndoRedoStore((s) => s.stacks)
|
||||
|
||||
const undoRedoSizes = (() => {
|
||||
const key = activeWorkflowId && userId ? `${activeWorkflowId}:${userId}` : ''
|
||||
const stack = (key && stacks[key]) || { undo: [], redo: [] }
|
||||
return { undoSize: stack.undo.length, redoSize: stack.redo.length }
|
||||
})()
|
||||
const currentZoom = Math.round(((zoom as number) || 1) * 100)
|
||||
|
||||
if (!showFloatingControls) return null
|
||||
|
||||
const handleZoomIn = () => {
|
||||
zoomIn({ duration: 200 })
|
||||
}
|
||||
|
||||
const handleZoomOut = () => {
|
||||
zoomOut({ duration: 200 })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='-translate-x-1/2 fixed bottom-6 left-1/2 z-10'>
|
||||
<div className='flex items-center gap-1 rounded-[14px] border bg-card/95 p-1 shadow-lg backdrop-blur-sm'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={handleZoomOut}
|
||||
disabled={currentZoom <= 10}
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-[10px]',
|
||||
'hover:bg-muted/80',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Minus className='h-4 w-4' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Zoom Out</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className='flex w-12 items-center justify-center font-medium text-muted-foreground text-sm'>
|
||||
{currentZoom}%
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={handleZoomIn}
|
||||
disabled={currentZoom >= 200}
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-[10px]',
|
||||
'hover:bg-muted/80',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Zoom In</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className='mx-1 h-6 w-px bg-border' />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={undo}
|
||||
disabled={undoRedoSizes.undoSize === 0}
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-[10px]',
|
||||
'hover:bg-muted/80',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Undo2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className='text-center'>
|
||||
<p>Undo</p>
|
||||
<p className='text-muted-foreground text-xs'>Cmd+Z</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={redo}
|
||||
disabled={undoRedoSizes.redoSize === 0}
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-[10px]',
|
||||
'hover:bg-muted/80',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Redo2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className='text-center'>
|
||||
<p>Redo</p>
|
||||
<p className='text-muted-foreground text-xs'>Cmd+Shift+Z</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
|
||||
import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar'
|
||||
import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls'
|
||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
||||
import { FloatingControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/floating-controls/floating-controls'
|
||||
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel'
|
||||
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
@@ -39,6 +40,7 @@ import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { hasWorkflowsInitiallyLoaded, useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { getUniqueBlockName } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('Workflow')
|
||||
@@ -89,7 +91,12 @@ const WorkflowContent = React.memo(() => {
|
||||
// Use the clean abstraction for current workflow state
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
|
||||
const { updateNodeDimensions, updateBlockPosition: storeUpdateBlockPosition } = useWorkflowStore()
|
||||
const {
|
||||
updateNodeDimensions,
|
||||
updateBlockPosition: storeUpdateBlockPosition,
|
||||
setDragStartPosition,
|
||||
getDragStartPosition,
|
||||
} = useWorkflowStore()
|
||||
|
||||
// Get copilot cleanup function
|
||||
const copilotCleanup = useCopilotStore((state) => state.cleanup)
|
||||
@@ -175,6 +182,8 @@ const WorkflowContent = React.memo(() => {
|
||||
collaborativeUpdateBlockPosition,
|
||||
collaborativeUpdateParentId: updateParentId,
|
||||
collaborativeSetSubblockValue,
|
||||
undo,
|
||||
redo,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
// Execution and debug mode state
|
||||
@@ -229,10 +238,50 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
}, [permissionsError, workspaceId])
|
||||
|
||||
// Helper function to update a node's parent with proper position calculation
|
||||
const updateNodeParent = useCallback(
|
||||
(nodeId: string, newParentId: string | null) => {
|
||||
return updateNodeParentUtil(
|
||||
(nodeId: string, newParentId: string | null, affectedEdges: any[] = []) => {
|
||||
const node = getNodes().find((n: any) => n.id === nodeId)
|
||||
if (!node) return
|
||||
|
||||
const currentBlock = blocks[nodeId]
|
||||
if (!currentBlock) return
|
||||
|
||||
const oldParentId = node.parentId || currentBlock.data?.parentId
|
||||
const oldPosition = { ...node.position }
|
||||
|
||||
// affectedEdges are edges that are either being removed (when leaving a subflow)
|
||||
// or being added (when entering a subflow)
|
||||
if (!affectedEdges.length && !newParentId && oldParentId) {
|
||||
affectedEdges = edgesForDisplay.filter((e) => e.source === nodeId || e.target === nodeId)
|
||||
}
|
||||
|
||||
let newPosition = oldPosition
|
||||
if (newParentId) {
|
||||
const getNodeAbsolutePosition = (id: string): { x: number; y: number } => {
|
||||
const n = getNodes().find((node: any) => node.id === id)
|
||||
if (!n) return { x: 0, y: 0 }
|
||||
if (!n.parentId) return n.position
|
||||
const parentPos = getNodeAbsolutePosition(n.parentId)
|
||||
return { x: parentPos.x + n.position.x, y: parentPos.y + n.position.y }
|
||||
}
|
||||
const nodeAbsPos = getNodeAbsolutePosition(nodeId)
|
||||
const parentAbsPos = getNodeAbsolutePosition(newParentId)
|
||||
newPosition = {
|
||||
x: nodeAbsPos.x - parentAbsPos.x,
|
||||
y: nodeAbsPos.y - parentAbsPos.y,
|
||||
}
|
||||
} else if (oldParentId) {
|
||||
const getNodeAbsolutePosition = (id: string): { x: number; y: number } => {
|
||||
const n = getNodes().find((node: any) => node.id === id)
|
||||
if (!n) return { x: 0, y: 0 }
|
||||
if (!n.parentId) return n.position
|
||||
const parentPos = getNodeAbsolutePosition(n.parentId)
|
||||
return { x: parentPos.x + n.position.x, y: parentPos.y + n.position.y }
|
||||
}
|
||||
newPosition = getNodeAbsolutePosition(nodeId)
|
||||
}
|
||||
|
||||
const result = updateNodeParentUtil(
|
||||
nodeId,
|
||||
newParentId,
|
||||
getNodes,
|
||||
@@ -240,8 +289,32 @@ const WorkflowContent = React.memo(() => {
|
||||
updateParentId,
|
||||
() => resizeLoopNodes(getNodes, updateNodeDimensions, blocks)
|
||||
)
|
||||
|
||||
if (oldParentId !== newParentId) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('workflow-record-parent-update', {
|
||||
detail: {
|
||||
blockId: nodeId,
|
||||
oldParentId: oldParentId || undefined,
|
||||
newParentId: newParentId || undefined,
|
||||
oldPosition,
|
||||
newPosition,
|
||||
affectedEdges: affectedEdges.map((e) => ({ ...e })),
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
[getNodes, collaborativeUpdateBlockPosition, updateParentId, updateNodeDimensions, blocks]
|
||||
[
|
||||
getNodes,
|
||||
collaborativeUpdateBlockPosition,
|
||||
updateParentId,
|
||||
updateNodeDimensions,
|
||||
blocks,
|
||||
edgesForDisplay,
|
||||
]
|
||||
)
|
||||
|
||||
// Function to resize all loop nodes with improved hierarchy handling
|
||||
@@ -345,23 +418,29 @@ const WorkflowContent = React.memo(() => {
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement
|
||||
const isEditableElement =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.hasAttribute('contenteditable')
|
||||
|
||||
if (isEditableElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey && event.key === 'L' && !event.ctrlKey && !event.metaKey) {
|
||||
// Don't trigger if user is typing in an input, textarea, or contenteditable element
|
||||
const activeElement = document.activeElement
|
||||
const isEditableElement =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.hasAttribute('contenteditable')
|
||||
|
||||
if (isEditableElement) {
|
||||
return // Allow normal typing behavior
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (cleanup) cleanup()
|
||||
|
||||
cleanup = debouncedAutoLayout()
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
undo()
|
||||
} else if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
(event.key === 'Z' || (event.key === 'z' && event.shiftKey))
|
||||
) {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,7 +450,7 @@ const WorkflowContent = React.memo(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
if (cleanup) cleanup()
|
||||
}
|
||||
}, [debouncedAutoLayout])
|
||||
}, [debouncedAutoLayout, undo, redo])
|
||||
|
||||
// Listen for explicit remove-from-subflow actions from ActionBar
|
||||
useEffect(() => {
|
||||
@@ -381,17 +460,28 @@ const WorkflowContent = React.memo(() => {
|
||||
if (!blockId) return
|
||||
|
||||
try {
|
||||
// Remove parent-child relationship while preserving absolute position
|
||||
updateNodeParent(blockId, null)
|
||||
const currentBlock = blocks[blockId]
|
||||
const parentId = currentBlock?.data?.parentId
|
||||
|
||||
// Remove all edges connected to this block
|
||||
const connectedEdges = edgesForDisplay.filter(
|
||||
if (!parentId) return
|
||||
|
||||
// Find ALL edges connected to this block
|
||||
const edgesToRemove = edgesForDisplay.filter(
|
||||
(e) => e.source === blockId || e.target === blockId
|
||||
)
|
||||
|
||||
connectedEdges.forEach((edge) => {
|
||||
// Set flag to skip individual edge recording for undo/redo
|
||||
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
|
||||
|
||||
// Remove edges first
|
||||
edgesToRemove.forEach((edge) => {
|
||||
removeEdge(edge.id)
|
||||
})
|
||||
|
||||
// Then update parent relationship
|
||||
updateNodeParent(blockId, null, edgesToRemove)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } }))
|
||||
} catch (err) {
|
||||
logger.error('Failed to remove from subflow', { err })
|
||||
}
|
||||
@@ -485,10 +575,8 @@ const WorkflowContent = React.memo(() => {
|
||||
// Create a unique ID and name for the container
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
// Auto-number the blocks based on existing blocks of the same type
|
||||
const existingBlocksOfType = Object.values(blocks).filter((b) => b.type === type)
|
||||
const blockNumber = existingBlocksOfType.length + 1
|
||||
const name = type === 'loop' ? `Loop ${blockNumber}` : `Parallel ${blockNumber}`
|
||||
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
// Calculate the center position of the viewport
|
||||
const centerPosition = project({
|
||||
@@ -549,7 +637,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
// Create a new block with a unique ID
|
||||
const id = crypto.randomUUID()
|
||||
const name = `${blockConfig.name} ${Object.values(blocks).filter((b) => b.type === type).length + 1}`
|
||||
const name = getUniqueBlockName(blockConfig.name, blocks)
|
||||
|
||||
// Auto-connect logic
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
@@ -626,10 +714,8 @@ const WorkflowContent = React.memo(() => {
|
||||
// Create a unique ID and name for the container
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
// Auto-number the blocks based on existing blocks of the same type
|
||||
const existingBlocksOfType = Object.values(blocks).filter((b) => b.type === data.type)
|
||||
const blockNumber = existingBlocksOfType.length + 1
|
||||
const name = data.type === 'loop' ? `Loop ${blockNumber}` : `Parallel ${blockNumber}`
|
||||
const baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
// Check if we're dropping inside another container
|
||||
if (containerInfo) {
|
||||
@@ -698,12 +784,9 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
// Generate id and name here so they're available in all code paths
|
||||
const id = crypto.randomUUID()
|
||||
const name =
|
||||
data.type === 'loop'
|
||||
? `Loop ${Object.values(blocks).filter((b) => b.type === 'loop').length + 1}`
|
||||
: data.type === 'parallel'
|
||||
? `Parallel ${Object.values(blocks).filter((b) => b.type === 'parallel').length + 1}`
|
||||
: `${blockConfig!.name} ${Object.values(blocks).filter((b) => b.type === data.type).length + 1}`
|
||||
const baseName =
|
||||
data.type === 'loop' ? 'Loop' : data.type === 'parallel' ? 'Parallel' : blockConfig!.name
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
if (containerInfo) {
|
||||
// Calculate position relative to the container node
|
||||
@@ -717,18 +800,9 @@ const WorkflowContent = React.memo(() => {
|
||||
(b) => b.data?.parentId === containerInfo.loopId
|
||||
)
|
||||
|
||||
// Add block with parent info
|
||||
addBlock(id, data.type, name, relativePosition, {
|
||||
parentId: containerInfo.loopId,
|
||||
extent: 'parent',
|
||||
})
|
||||
|
||||
// Resize the container node to fit the new block
|
||||
// Immediate resize without delay
|
||||
resizeLoopNodesWrapper()
|
||||
|
||||
// Auto-connect logic for blocks inside containers
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled && data.type !== 'starter') {
|
||||
if (existingChildBlocks.length > 0) {
|
||||
// Connect to the nearest existing child block within the container
|
||||
@@ -747,14 +821,14 @@ const WorkflowContent = React.memo(() => {
|
||||
id: closestBlock.id,
|
||||
type: closestBlock.type,
|
||||
})
|
||||
addEdge({
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No existing children: connect from the container's start handle
|
||||
@@ -764,16 +838,35 @@ const WorkflowContent = React.memo(() => {
|
||||
? 'loop-start-source'
|
||||
: 'parallel-start-source'
|
||||
|
||||
addEdge({
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: containerInfo.loopId,
|
||||
target: id,
|
||||
sourceHandle: startSourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add block with parent info AND autoConnectEdge (atomic operation)
|
||||
addBlock(
|
||||
id,
|
||||
data.type,
|
||||
name,
|
||||
relativePosition,
|
||||
{
|
||||
parentId: containerInfo.loopId,
|
||||
extent: 'parent',
|
||||
},
|
||||
containerInfo.loopId,
|
||||
'parent',
|
||||
autoConnectEdge
|
||||
)
|
||||
|
||||
// Resize the container node to fit the new block
|
||||
// Immediate resize without delay
|
||||
resizeLoopNodesWrapper()
|
||||
} else {
|
||||
// Regular auto-connect logic
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
@@ -1349,8 +1442,15 @@ const WorkflowContent = React.memo(() => {
|
||||
// Store the original parent ID when starting to drag
|
||||
const currentParentId = node.parentId || blocks[node.id]?.data?.parentId || null
|
||||
setDragStartParentId(currentParentId)
|
||||
// Store starting position for undo/redo move entry
|
||||
setDragStartPosition({
|
||||
id: node.id,
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
parentId: currentParentId,
|
||||
})
|
||||
},
|
||||
[blocks]
|
||||
[blocks, setDragStartPosition]
|
||||
)
|
||||
|
||||
// Handle node drag stop to establish parent-child relationships
|
||||
@@ -1366,6 +1466,29 @@ const WorkflowContent = React.memo(() => {
|
||||
// This ensures other users see the smooth final position
|
||||
collaborativeUpdateBlockPosition(node.id, node.position)
|
||||
|
||||
// Record single move entry on drag end to avoid micro-moves
|
||||
try {
|
||||
const start = getDragStartPosition()
|
||||
if (start && start.id === node.id) {
|
||||
const before = { x: start.x, y: start.y, parentId: start.parentId }
|
||||
const after = {
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
parentId: node.parentId || blocks[node.id]?.data?.parentId,
|
||||
}
|
||||
const moved =
|
||||
before.x !== after.x || before.y !== after.y || before.parentId !== after.parentId
|
||||
if (moved) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('workflow-record-move', {
|
||||
detail: { blockId: node.id, before, after },
|
||||
})
|
||||
)
|
||||
}
|
||||
setDragStartPosition(null)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent
|
||||
if (potentialParentId === dragStartParentId) return
|
||||
|
||||
@@ -1409,8 +1532,8 @@ const WorkflowContent = React.memo(() => {
|
||||
y: nodeAbsPosBefore.y - containerAbsPosBefore.y,
|
||||
}
|
||||
|
||||
// Moving to a new parent container
|
||||
updateNodeParent(node.id, potentialParentId)
|
||||
// Prepare edges that will be added when moving into the container
|
||||
const edgesToAdd: any[] = []
|
||||
|
||||
// Auto-connect when moving an existing block into a container
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
@@ -1437,7 +1560,7 @@ const WorkflowContent = React.memo(() => {
|
||||
id: closestBlock.id,
|
||||
type: closestBlock.type,
|
||||
})
|
||||
addEdge({
|
||||
edgesToAdd.push({
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: node.id,
|
||||
@@ -1454,7 +1577,7 @@ const WorkflowContent = React.memo(() => {
|
||||
? 'loop-start-source'
|
||||
: 'parallel-start-source'
|
||||
|
||||
addEdge({
|
||||
edgesToAdd.push({
|
||||
id: crypto.randomUUID(),
|
||||
source: potentialParentId,
|
||||
target: node.id,
|
||||
@@ -1464,6 +1587,17 @@ const WorkflowContent = React.memo(() => {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Skip recording these edges separately since they're part of the parent update
|
||||
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
|
||||
|
||||
// Moving to a new parent container - pass the edges that will be added
|
||||
updateNodeParent(node.id, potentialParentId, edgesToAdd)
|
||||
|
||||
// Now add the edges after parent update
|
||||
edgesToAdd.forEach((edge) => addEdge(edge))
|
||||
|
||||
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } }))
|
||||
}
|
||||
|
||||
// Reset state
|
||||
@@ -1481,6 +1615,8 @@ const WorkflowContent = React.memo(() => {
|
||||
determineSourceHandle,
|
||||
blocks,
|
||||
getNodeAbsolutePositionWrapper,
|
||||
getDragStartPosition,
|
||||
setDragStartPosition,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1620,6 +1756,9 @@ const WorkflowContent = React.memo(() => {
|
||||
{/* Floating Control Bar */}
|
||||
<ControlBar hasValidationErrors={nestedSubflowErrors.size > 0} />
|
||||
|
||||
{/* Floating Controls (Zoom, Undo, Redo) */}
|
||||
<FloatingControls />
|
||||
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edgesWithSelection}
|
||||
|
||||
+41
@@ -19,6 +19,8 @@ const TOOLTIPS = {
|
||||
autoPan: 'Automatically pan to active blocks during workflow execution.',
|
||||
consoleExpandedByDefault:
|
||||
'Show console entries expanded by default. When disabled, entries will be collapsed by default.',
|
||||
floatingControls:
|
||||
'Show floating controls for zoom, undo, and redo at the bottom of the workflow canvas.',
|
||||
}
|
||||
|
||||
export function General() {
|
||||
@@ -28,6 +30,7 @@ export function General() {
|
||||
|
||||
const isAutoPanEnabled = useGeneralStore((state) => state.isAutoPanEnabled)
|
||||
const isConsoleExpandedByDefault = useGeneralStore((state) => state.isConsoleExpandedByDefault)
|
||||
const showFloatingControls = useGeneralStore((state) => state.showFloatingControls)
|
||||
|
||||
// Loading states
|
||||
const isAutoConnectLoading = useGeneralStore((state) => state.isAutoConnectLoading)
|
||||
@@ -37,6 +40,7 @@ export function General() {
|
||||
(state) => state.isConsoleExpandedByDefaultLoading
|
||||
)
|
||||
const isThemeLoading = useGeneralStore((state) => state.isThemeLoading)
|
||||
const isFloatingControlsLoading = useGeneralStore((state) => state.isFloatingControlsLoading)
|
||||
|
||||
const setTheme = useGeneralStore((state) => state.setTheme)
|
||||
const toggleAutoConnect = useGeneralStore((state) => state.toggleAutoConnect)
|
||||
@@ -45,6 +49,7 @@ export function General() {
|
||||
const toggleConsoleExpandedByDefault = useGeneralStore(
|
||||
(state) => state.toggleConsoleExpandedByDefault
|
||||
)
|
||||
const toggleFloatingControls = useGeneralStore((state) => state.toggleFloatingControls)
|
||||
|
||||
// Sync theme from store to next-themes when theme changes
|
||||
useEffect(() => {
|
||||
@@ -77,6 +82,12 @@ export function General() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFloatingControlsChange = async (checked: boolean) => {
|
||||
if (checked !== showFloatingControls && !isFloatingControlsLoading) {
|
||||
await toggleFloatingControls()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='px-6 pt-4 pb-2'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
@@ -241,6 +252,36 @@ export function General() {
|
||||
disabled={isLoading || isConsoleExpandedByDefaultLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='floating-controls' className='font-normal'>
|
||||
Floating controls
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about floating controls'
|
||||
disabled={isLoading || isFloatingControlsLoading}
|
||||
>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.floatingControls}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
id='floating-controls'
|
||||
checked={showFloatingControls}
|
||||
onCheckedChange={handleFloatingControlsChange}
|
||||
disabled={isLoading || isFloatingControlsLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,63 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
import { useSocket } from '@/contexts/socket-context'
|
||||
import { useUndoRedo } from '@/hooks/use-undo-redo'
|
||||
import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { getUniqueBlockName, mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { Position } from '@/stores/workflows/workflow/types'
|
||||
import type { BlockState, Position } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('CollaborativeWorkflow')
|
||||
|
||||
export function useCollaborativeWorkflow() {
|
||||
const undoRedo = useUndoRedo()
|
||||
const isUndoRedoInProgress = useRef(false)
|
||||
const skipEdgeRecording = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const moveHandler = (e: any) => {
|
||||
const { blockId, before, after } = e.detail || {}
|
||||
if (!blockId || !before || !after) return
|
||||
// Don't record moves during undo/redo operations
|
||||
if (isUndoRedoInProgress.current) return
|
||||
undoRedo.recordMove(blockId, before, after)
|
||||
}
|
||||
|
||||
const parentUpdateHandler = (e: any) => {
|
||||
const { blockId, oldParentId, newParentId, oldPosition, newPosition, affectedEdges } =
|
||||
e.detail || {}
|
||||
if (!blockId) return
|
||||
// Don't record during undo/redo operations
|
||||
if (isUndoRedoInProgress.current) return
|
||||
undoRedo.recordUpdateParent(
|
||||
blockId,
|
||||
oldParentId,
|
||||
newParentId,
|
||||
oldPosition,
|
||||
newPosition,
|
||||
affectedEdges
|
||||
)
|
||||
}
|
||||
|
||||
const skipEdgeHandler = (e: any) => {
|
||||
const { skip } = e.detail || {}
|
||||
skipEdgeRecording.current = skip
|
||||
}
|
||||
|
||||
window.addEventListener('workflow-record-move', moveHandler)
|
||||
window.addEventListener('workflow-record-parent-update', parentUpdateHandler)
|
||||
window.addEventListener('skip-edge-recording', skipEdgeHandler)
|
||||
return () => {
|
||||
window.removeEventListener('workflow-record-move', moveHandler)
|
||||
window.removeEventListener('workflow-record-parent-update', parentUpdateHandler)
|
||||
window.removeEventListener('skip-edge-recording', skipEdgeHandler)
|
||||
}
|
||||
}, [undoRedo])
|
||||
const {
|
||||
isConnected,
|
||||
currentWorkflowId,
|
||||
@@ -125,6 +171,15 @@ export function useCollaborativeWorkflow() {
|
||||
if (payload.autoConnectEdge) {
|
||||
workflowStore.addEdge(payload.autoConnectEdge)
|
||||
}
|
||||
// Apply subblock values if present in payload
|
||||
if (payload.subBlocks && typeof payload.subBlocks === 'object') {
|
||||
Object.entries(payload.subBlocks).forEach(([subblockId, subblock]) => {
|
||||
const value = (subblock as any)?.value
|
||||
if (value !== undefined && value !== null) {
|
||||
subBlockStore.setValue(payload.id, subblockId, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'update-position': {
|
||||
const blockId = payload.id
|
||||
@@ -157,11 +212,40 @@ export function useCollaborativeWorkflow() {
|
||||
case 'update-name':
|
||||
workflowStore.updateBlockName(payload.id, payload.name)
|
||||
break
|
||||
case 'remove':
|
||||
workflowStore.removeBlock(payload.id)
|
||||
// Clean up position timestamp tracking for removed blocks
|
||||
lastPositionTimestamps.current.delete(payload.id)
|
||||
case 'remove': {
|
||||
const blockId = payload.id
|
||||
const blocksToRemove = new Set<string>([blockId])
|
||||
|
||||
const findAllDescendants = (parentId: string) => {
|
||||
Object.entries(workflowStore.blocks).forEach(([id, block]) => {
|
||||
if (block.data?.parentId === parentId) {
|
||||
blocksToRemove.add(id)
|
||||
findAllDescendants(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
findAllDescendants(blockId)
|
||||
|
||||
workflowStore.removeBlock(blockId)
|
||||
lastPositionTimestamps.current.delete(blockId)
|
||||
|
||||
const updatedBlocks = useWorkflowStore.getState().blocks
|
||||
const updatedEdges = useWorkflowStore.getState().edges
|
||||
const graph = {
|
||||
blocksById: updatedBlocks,
|
||||
edgesById: Object.fromEntries(updatedEdges.map((e) => [e.id, e])),
|
||||
}
|
||||
|
||||
const undoRedoStore = useUndoRedoStore.getState()
|
||||
const stackKeys = Object.keys(undoRedoStore.stacks)
|
||||
stackKeys.forEach((key) => {
|
||||
const [workflowId, userId] = key.split(':')
|
||||
if (workflowId === activeWorkflowId) {
|
||||
undoRedoStore.pruneInvalidEntries(workflowId, userId, graph)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'toggle-enabled':
|
||||
workflowStore.toggleBlockEnabled(payload.id)
|
||||
break
|
||||
@@ -222,9 +306,26 @@ export function useCollaborativeWorkflow() {
|
||||
case 'add':
|
||||
workflowStore.addEdge(payload as Edge)
|
||||
break
|
||||
case 'remove':
|
||||
case 'remove': {
|
||||
workflowStore.removeEdge(payload.id)
|
||||
|
||||
const updatedBlocks = useWorkflowStore.getState().blocks
|
||||
const updatedEdges = useWorkflowStore.getState().edges
|
||||
const graph = {
|
||||
blocksById: updatedBlocks,
|
||||
edgesById: Object.fromEntries(updatedEdges.map((e) => [e.id, e])),
|
||||
}
|
||||
|
||||
const undoRedoStore = useUndoRedoStore.getState()
|
||||
const stackKeys = Object.keys(undoRedoStore.stacks)
|
||||
stackKeys.forEach((key) => {
|
||||
const [workflowId, userId] = key.split(':')
|
||||
if (workflowId === activeWorkflowId) {
|
||||
undoRedoStore.pruneInvalidEntries(workflowId, userId, graph)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (target === 'subflow') {
|
||||
switch (operation) {
|
||||
@@ -338,13 +439,14 @@ export function useCollaborativeWorkflow() {
|
||||
const { workflowId } = data
|
||||
logger.warn(`Workflow ${workflowId} has been deleted`)
|
||||
|
||||
// If the deleted workflow is the currently active one, we need to handle this gracefully
|
||||
if (activeWorkflowId === workflowId) {
|
||||
logger.info(
|
||||
`Currently active workflow ${workflowId} was deleted, stopping collaborative operations`
|
||||
)
|
||||
// The workflow registry should handle switching to another workflow
|
||||
// We just need to stop any pending collaborative operations
|
||||
|
||||
const currentUserId = session?.user?.id || 'unknown'
|
||||
useUndoRedoStore.getState().clear(workflowId, currentUserId)
|
||||
|
||||
isApplyingRemoteChange.current = false
|
||||
}
|
||||
}
|
||||
@@ -400,6 +502,22 @@ export function useCollaborativeWorkflow() {
|
||||
}))
|
||||
|
||||
logger.info(`Successfully loaded reverted workflow state for ${workflowId}`)
|
||||
|
||||
const graph = {
|
||||
blocksById: workflowData.state.blocks || {},
|
||||
edgesById: Object.fromEntries(
|
||||
(workflowData.state.edges || []).map((e: any) => [e.id, e])
|
||||
),
|
||||
}
|
||||
|
||||
const undoRedoStore = useUndoRedoStore.getState()
|
||||
const stackKeys = Object.keys(undoRedoStore.stacks)
|
||||
stackKeys.forEach((key) => {
|
||||
const [wfId, userId] = key.split(':')
|
||||
if (wfId === workflowId) {
|
||||
undoRedoStore.pruneInvalidEntries(wfId, userId, graph)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
isApplyingRemoteChange.current = false
|
||||
}
|
||||
@@ -617,6 +735,9 @@ export function useCollaborativeWorkflow() {
|
||||
workflowStore.addEdge(autoConnectEdge)
|
||||
}
|
||||
|
||||
// Record for undo AFTER adding (pass the autoConnectEdge explicitly)
|
||||
undoRedo.recordAddBlock(id, autoConnectEdge)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -685,6 +806,9 @@ export function useCollaborativeWorkflow() {
|
||||
if (autoConnectEdge) {
|
||||
workflowStore.addEdge(autoConnectEdge)
|
||||
}
|
||||
|
||||
// Record for undo AFTER adding (pass the autoConnectEdge explicitly)
|
||||
undoRedo.recordAddBlock(id, autoConnectEdge)
|
||||
},
|
||||
[
|
||||
workflowStore,
|
||||
@@ -694,6 +818,7 @@ export function useCollaborativeWorkflow() {
|
||||
isShowingDiff,
|
||||
isInActiveRoom,
|
||||
currentWorkflowId,
|
||||
undoRedo,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -701,13 +826,44 @@ export function useCollaborativeWorkflow() {
|
||||
(id: string) => {
|
||||
cancelOperationsForBlock(id)
|
||||
|
||||
// Get all blocks that will be removed (including nested blocks in subflows)
|
||||
const blocksToRemove = new Set<string>([id])
|
||||
const findAllDescendants = (parentId: string) => {
|
||||
Object.entries(workflowStore.blocks).forEach(([blockId, block]) => {
|
||||
if (block.data?.parentId === parentId) {
|
||||
blocksToRemove.add(blockId)
|
||||
findAllDescendants(blockId)
|
||||
}
|
||||
})
|
||||
}
|
||||
findAllDescendants(id)
|
||||
|
||||
// Capture state before removal, including all nested blocks with subblock values
|
||||
const allBlocks = mergeSubblockState(workflowStore.blocks, activeWorkflowId || undefined)
|
||||
const capturedBlocks: Record<string, BlockState> = {}
|
||||
blocksToRemove.forEach((blockId) => {
|
||||
if (allBlocks[blockId]) {
|
||||
capturedBlocks[blockId] = allBlocks[blockId]
|
||||
}
|
||||
})
|
||||
|
||||
// Capture all edges connected to any of the blocks being removed
|
||||
const edges = workflowStore.edges.filter(
|
||||
(edge) => blocksToRemove.has(edge.source) || blocksToRemove.has(edge.target)
|
||||
)
|
||||
|
||||
if (Object.keys(capturedBlocks).length > 0) {
|
||||
undoRedo.recordRemoveBlock(id, capturedBlocks[id], edges, capturedBlocks)
|
||||
}
|
||||
|
||||
executeQueuedOperation('remove', 'block', { id }, () => workflowStore.removeBlock(id))
|
||||
},
|
||||
[executeQueuedOperation, workflowStore, cancelOperationsForBlock]
|
||||
[executeQueuedOperation, workflowStore, cancelOperationsForBlock, undoRedo, activeWorkflowId]
|
||||
)
|
||||
|
||||
const collaborativeUpdateBlockPosition = useCallback(
|
||||
(id: string, position: Position) => {
|
||||
// Only apply position updates here (no undo recording to avoid micro-moves)
|
||||
executeQueuedDebouncedOperation('update-position', 'block', { id, position }, () =>
|
||||
workflowStore.updateBlockPosition(id, position)
|
||||
)
|
||||
@@ -812,17 +968,27 @@ export function useCollaborativeWorkflow() {
|
||||
const collaborativeAddEdge = useCallback(
|
||||
(edge: Edge) => {
|
||||
executeQueuedOperation('add', 'edge', edge, () => workflowStore.addEdge(edge))
|
||||
// Only record edge addition if it's not part of a parent update operation
|
||||
if (!skipEdgeRecording.current) {
|
||||
undoRedo.recordAddEdge(edge.id)
|
||||
}
|
||||
},
|
||||
[executeQueuedOperation, workflowStore]
|
||||
[executeQueuedOperation, workflowStore, undoRedo]
|
||||
)
|
||||
|
||||
const collaborativeRemoveEdge = useCallback(
|
||||
(edgeId: string) => {
|
||||
const edge = workflowStore.edges.find((e) => e.id === edgeId)
|
||||
// Only record edge removal if it's not part of a parent update operation
|
||||
if (edge && !skipEdgeRecording.current) {
|
||||
undoRedo.recordRemoveEdge(edgeId, edge)
|
||||
}
|
||||
|
||||
executeQueuedOperation('remove', 'edge', { id: edgeId }, () =>
|
||||
workflowStore.removeEdge(edgeId)
|
||||
)
|
||||
},
|
||||
[executeQueuedOperation, workflowStore]
|
||||
[executeQueuedOperation, workflowStore, undoRedo]
|
||||
)
|
||||
|
||||
const collaborativeSetSubblockValue = useCallback(
|
||||
@@ -960,10 +1126,7 @@ export function useCollaborativeWorkflow() {
|
||||
y: sourceBlock.position.y + 20,
|
||||
}
|
||||
|
||||
const match = sourceBlock.name.match(/(.*?)(\d+)?$/)
|
||||
const newName = match?.[2]
|
||||
? `${match[1]}${Number.parseInt(match[2]) + 1}`
|
||||
: `${sourceBlock.name} 1`
|
||||
const newName = getUniqueBlockName(sourceBlock.name, workflowStore.blocks)
|
||||
|
||||
// Get subblock values from the store
|
||||
const subBlockValues = subBlockStore.workflowValues[activeWorkflowId || '']?.[sourceId] || {}
|
||||
@@ -1049,6 +1212,9 @@ export function useCollaborativeWorkflow() {
|
||||
subBlockStore.setValue(newId, subblockId, value)
|
||||
})
|
||||
}
|
||||
|
||||
// Record for undo after the block is added
|
||||
undoRedo.recordDuplicateBlock(sourceId, newId, duplicatedBlockData, undefined)
|
||||
})
|
||||
},
|
||||
[
|
||||
@@ -1058,6 +1224,7 @@ export function useCollaborativeWorkflow() {
|
||||
activeWorkflowId,
|
||||
isInActiveRoom,
|
||||
currentWorkflowId,
|
||||
undoRedo,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1329,5 +1496,23 @@ export function useCollaborativeWorkflow() {
|
||||
// Direct access to stores for non-collaborative operations
|
||||
workflowStore,
|
||||
subBlockStore,
|
||||
|
||||
// Undo/Redo operations (wrapped to prevent recording moves during undo/redo)
|
||||
undo: useCallback(() => {
|
||||
isUndoRedoInProgress.current = true
|
||||
undoRedo.undo()
|
||||
queueMicrotask(() => {
|
||||
isUndoRedoInProgress.current = false
|
||||
})
|
||||
}, [undoRedo]),
|
||||
redo: useCallback(() => {
|
||||
isUndoRedoInProgress.current = true
|
||||
undoRedo.redo()
|
||||
queueMicrotask(() => {
|
||||
isUndoRedoInProgress.current = false
|
||||
})
|
||||
}, [undoRedo]),
|
||||
getUndoRedoSizes: undoRedo.getStackSizes,
|
||||
clearUndoRedo: undoRedo.clearStacks,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
isAutoPanEnabled: true,
|
||||
isConsoleExpandedByDefault: true,
|
||||
isDebugModeEnabled: false,
|
||||
showFloatingControls: true,
|
||||
theme: 'system' as const, // Keep for compatibility but not used
|
||||
telemetryEnabled: true,
|
||||
isLoading: false,
|
||||
@@ -34,6 +35,7 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
isTelemetryLoading: false,
|
||||
isBillingUsageNotificationsLoading: false,
|
||||
isBillingUsageNotificationsEnabled: true,
|
||||
isFloatingControlsLoading: false,
|
||||
}
|
||||
|
||||
// Optimistic update helper
|
||||
@@ -101,6 +103,17 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
set({ isDebugModeEnabled: !get().isDebugModeEnabled })
|
||||
},
|
||||
|
||||
toggleFloatingControls: async () => {
|
||||
if (get().isFloatingControlsLoading) return
|
||||
const newValue = !get().showFloatingControls
|
||||
await updateSettingOptimistic(
|
||||
'showFloatingControls',
|
||||
newValue,
|
||||
'isFloatingControlsLoading',
|
||||
'showFloatingControls'
|
||||
)
|
||||
},
|
||||
|
||||
setTheme: async (theme) => {
|
||||
if (get().isThemeLoading) return
|
||||
|
||||
@@ -203,6 +216,7 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
isAutoConnectEnabled: data.autoConnect,
|
||||
isAutoPanEnabled: data.autoPan ?? true,
|
||||
isConsoleExpandedByDefault: data.consoleExpandedByDefault ?? true,
|
||||
showFloatingControls: data.showFloatingControls ?? true,
|
||||
theme: data.theme || 'system',
|
||||
telemetryEnabled: data.telemetryEnabled,
|
||||
isBillingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface General {
|
||||
isAutoPanEnabled: boolean
|
||||
isConsoleExpandedByDefault: boolean
|
||||
isDebugModeEnabled: boolean
|
||||
showFloatingControls: boolean
|
||||
theme: 'system' | 'light' | 'dark'
|
||||
telemetryEnabled: boolean
|
||||
isLoading: boolean
|
||||
@@ -14,14 +15,15 @@ export interface General {
|
||||
isTelemetryLoading: boolean
|
||||
isBillingUsageNotificationsLoading: boolean
|
||||
isBillingUsageNotificationsEnabled: boolean
|
||||
isFloatingControlsLoading: boolean
|
||||
}
|
||||
|
||||
export interface GeneralActions {
|
||||
toggleAutoConnect: () => Promise<void>
|
||||
|
||||
toggleAutoPan: () => Promise<void>
|
||||
toggleConsoleExpandedByDefault: () => Promise<void>
|
||||
toggleDebugMode: () => void
|
||||
toggleFloatingControls: () => Promise<void>
|
||||
setTheme: (theme: 'system' | 'light' | 'dark') => Promise<void>
|
||||
setTelemetryEnabled: (enabled: boolean) => Promise<void>
|
||||
setBillingUsageNotificationsEnabled: (enabled: boolean) => Promise<void>
|
||||
@@ -36,6 +38,7 @@ export type UserSettings = {
|
||||
autoConnect: boolean
|
||||
autoPan: boolean
|
||||
consoleExpandedByDefault: boolean
|
||||
showFloatingControls: boolean
|
||||
telemetryEnabled: boolean
|
||||
isBillingUsageNotificationsEnabled: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useUndoRedoStore } from './store'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
@@ -0,0 +1,356 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import type {
|
||||
MoveBlockOperation,
|
||||
Operation,
|
||||
OperationEntry,
|
||||
RemoveBlockOperation,
|
||||
RemoveEdgeOperation,
|
||||
UndoRedoState,
|
||||
} from './types'
|
||||
|
||||
const logger = createLogger('UndoRedoStore')
|
||||
const DEFAULT_CAPACITY = 15
|
||||
|
||||
function getStackKey(workflowId: string, userId: string): string {
|
||||
return `${workflowId}:${userId}`
|
||||
}
|
||||
|
||||
function isOperationApplicable(
|
||||
operation: Operation,
|
||||
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
|
||||
): boolean {
|
||||
switch (operation.type) {
|
||||
case 'remove-block': {
|
||||
const op = operation as RemoveBlockOperation
|
||||
return Boolean(graph.blocksById[op.data.blockId])
|
||||
}
|
||||
case 'add-block': {
|
||||
const blockId = operation.data.blockId
|
||||
return !graph.blocksById[blockId]
|
||||
}
|
||||
case 'move-block': {
|
||||
const op = operation as MoveBlockOperation
|
||||
return Boolean(graph.blocksById[op.data.blockId])
|
||||
}
|
||||
case 'update-parent': {
|
||||
const blockId = operation.data.blockId
|
||||
return Boolean(graph.blocksById[blockId])
|
||||
}
|
||||
case 'duplicate-block': {
|
||||
const duplicatedId = operation.data.duplicatedBlockId
|
||||
return Boolean(graph.blocksById[duplicatedId])
|
||||
}
|
||||
case 'remove-edge': {
|
||||
const op = operation as RemoveEdgeOperation
|
||||
return Boolean(graph.edgesById[op.data.edgeId])
|
||||
}
|
||||
case 'add-edge': {
|
||||
const edgeId = operation.data.edgeId
|
||||
return !graph.edgesById[edgeId]
|
||||
}
|
||||
case 'add-subflow':
|
||||
case 'remove-subflow': {
|
||||
const subflowId = operation.data.subflowId
|
||||
return operation.type === 'remove-subflow'
|
||||
? Boolean(graph.blocksById[subflowId])
|
||||
: !graph.blocksById[subflowId]
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const useUndoRedoStore = create<UndoRedoState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
stacks: {},
|
||||
capacity: DEFAULT_CAPACITY,
|
||||
|
||||
push: (workflowId: string, userId: string, entry: OperationEntry) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key] || { undo: [], redo: [] }
|
||||
|
||||
// Coalesce consecutive move-block operations for the same block
|
||||
if (entry.operation.type === 'move-block') {
|
||||
const incoming = entry.operation as MoveBlockOperation
|
||||
const last = stack.undo[stack.undo.length - 1]
|
||||
|
||||
// Skip no-op moves
|
||||
const b1 = incoming.data.before
|
||||
const a1 = incoming.data.after
|
||||
const sameParent = (b1.parentId ?? null) === (a1.parentId ?? null)
|
||||
if (b1.x === a1.x && b1.y === a1.y && sameParent) {
|
||||
logger.debug('Skipped no-op move push')
|
||||
return
|
||||
}
|
||||
|
||||
if (last && last.operation.type === 'move-block' && last.inverse.type === 'move-block') {
|
||||
const prev = last.operation as MoveBlockOperation
|
||||
if (prev.data.blockId === incoming.data.blockId) {
|
||||
// Merge: keep earliest before, latest after
|
||||
const mergedBefore = prev.data.before
|
||||
const mergedAfter = incoming.data.after
|
||||
|
||||
const sameAfter =
|
||||
mergedBefore.x === mergedAfter.x &&
|
||||
mergedBefore.y === mergedAfter.y &&
|
||||
(mergedBefore.parentId ?? null) === (mergedAfter.parentId ?? null)
|
||||
|
||||
const newUndoCoalesced: OperationEntry[] = sameAfter
|
||||
? stack.undo.slice(0, -1)
|
||||
: (() => {
|
||||
const op = entry.operation as MoveBlockOperation
|
||||
const inv = entry.inverse as MoveBlockOperation
|
||||
const newEntry: OperationEntry = {
|
||||
id: entry.id,
|
||||
createdAt: entry.createdAt,
|
||||
operation: {
|
||||
id: op.id,
|
||||
type: 'move-block',
|
||||
timestamp: op.timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: {
|
||||
blockId: incoming.data.blockId,
|
||||
before: mergedBefore,
|
||||
after: mergedAfter,
|
||||
},
|
||||
},
|
||||
inverse: {
|
||||
id: inv.id,
|
||||
type: 'move-block',
|
||||
timestamp: inv.timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: {
|
||||
blockId: incoming.data.blockId,
|
||||
before: mergedAfter,
|
||||
after: mergedBefore,
|
||||
},
|
||||
},
|
||||
}
|
||||
return [...stack.undo.slice(0, -1), newEntry]
|
||||
})()
|
||||
|
||||
set({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[key]: { undo: newUndoCoalesced, redo: [] },
|
||||
},
|
||||
})
|
||||
|
||||
logger.debug('Coalesced consecutive move operations', {
|
||||
workflowId,
|
||||
userId,
|
||||
blockId: incoming.data.blockId,
|
||||
undoSize: newUndoCoalesced.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newUndo = [...stack.undo, entry]
|
||||
if (newUndo.length > state.capacity) {
|
||||
newUndo.shift()
|
||||
}
|
||||
|
||||
set({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[key]: { undo: newUndo, redo: [] },
|
||||
},
|
||||
})
|
||||
|
||||
logger.debug('Pushed operation to undo stack', {
|
||||
workflowId,
|
||||
userId,
|
||||
operationType: entry.operation.type,
|
||||
undoSize: newUndo.length,
|
||||
})
|
||||
},
|
||||
|
||||
undo: (workflowId: string, userId: string) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key]
|
||||
|
||||
if (!stack || stack.undo.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entry = stack.undo[stack.undo.length - 1]
|
||||
const newUndo = stack.undo.slice(0, -1)
|
||||
const newRedo = [...stack.redo, entry]
|
||||
|
||||
if (newRedo.length > state.capacity) {
|
||||
newRedo.shift()
|
||||
}
|
||||
|
||||
set({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[key]: { undo: newUndo, redo: newRedo },
|
||||
},
|
||||
})
|
||||
|
||||
logger.debug('Undo operation', {
|
||||
workflowId,
|
||||
userId,
|
||||
operationType: entry.operation.type,
|
||||
undoSize: newUndo.length,
|
||||
redoSize: newRedo.length,
|
||||
})
|
||||
|
||||
return entry
|
||||
},
|
||||
|
||||
redo: (workflowId: string, userId: string) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key]
|
||||
|
||||
if (!stack || stack.redo.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entry = stack.redo[stack.redo.length - 1]
|
||||
const newRedo = stack.redo.slice(0, -1)
|
||||
const newUndo = [...stack.undo, entry]
|
||||
|
||||
if (newUndo.length > state.capacity) {
|
||||
newUndo.shift()
|
||||
}
|
||||
|
||||
set({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[key]: { undo: newUndo, redo: newRedo },
|
||||
},
|
||||
})
|
||||
|
||||
logger.debug('Redo operation', {
|
||||
workflowId,
|
||||
userId,
|
||||
operationType: entry.operation.type,
|
||||
undoSize: newUndo.length,
|
||||
redoSize: newRedo.length,
|
||||
})
|
||||
|
||||
return entry
|
||||
},
|
||||
|
||||
clear: (workflowId: string, userId: string) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const { [key]: _, ...rest } = state.stacks
|
||||
|
||||
set({ stacks: rest })
|
||||
|
||||
logger.debug('Cleared undo/redo stacks', { workflowId, userId })
|
||||
},
|
||||
|
||||
clearRedo: (workflowId: string, userId: string) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key]
|
||||
|
||||
if (!stack) return
|
||||
|
||||
set({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[key]: { ...stack, redo: [] },
|
||||
},
|
||||
})
|
||||
|
||||
logger.debug('Cleared redo stack', { workflowId, userId })
|
||||
},
|
||||
|
||||
getStackSizes: (workflowId: string, userId: string) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key]
|
||||
|
||||
if (!stack) {
|
||||
return { undoSize: 0, redoSize: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
undoSize: stack.undo.length,
|
||||
redoSize: stack.redo.length,
|
||||
}
|
||||
},
|
||||
|
||||
setCapacity: (capacity: number) => {
|
||||
const state = get()
|
||||
const newStacks: typeof state.stacks = {}
|
||||
|
||||
for (const [key, stack] of Object.entries(state.stacks)) {
|
||||
newStacks[key] = {
|
||||
undo: stack.undo.slice(-capacity),
|
||||
redo: stack.redo.slice(-capacity),
|
||||
}
|
||||
}
|
||||
|
||||
set({ capacity, stacks: newStacks })
|
||||
|
||||
logger.debug('Set capacity', { capacity })
|
||||
},
|
||||
|
||||
pruneInvalidEntries: (
|
||||
workflowId: string,
|
||||
userId: string,
|
||||
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
|
||||
) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key]
|
||||
|
||||
if (!stack) return
|
||||
|
||||
const originalUndoCount = stack.undo.length
|
||||
const originalRedoCount = stack.redo.length
|
||||
|
||||
const validUndo = stack.undo.filter((entry) => isOperationApplicable(entry.inverse, graph))
|
||||
|
||||
const validRedo = stack.redo.filter((entry) =>
|
||||
isOperationApplicable(entry.operation, graph)
|
||||
)
|
||||
|
||||
const prunedUndoCount = originalUndoCount - validUndo.length
|
||||
const prunedRedoCount = originalRedoCount - validRedo.length
|
||||
|
||||
if (prunedUndoCount > 0 || prunedRedoCount > 0) {
|
||||
set({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[key]: { undo: validUndo, redo: validRedo },
|
||||
},
|
||||
})
|
||||
|
||||
logger.debug('Pruned invalid entries', {
|
||||
workflowId,
|
||||
userId,
|
||||
prunedUndo: prunedUndoCount,
|
||||
prunedRedo: prunedRedoCount,
|
||||
remainingUndo: validUndo.length,
|
||||
remainingRedo: validRedo.length,
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'workflow-undo-redo',
|
||||
partialize: (state) => ({
|
||||
stacks: state.stacks,
|
||||
capacity: state.capacity,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export type OperationType =
|
||||
| 'add-block'
|
||||
| 'remove-block'
|
||||
| 'add-edge'
|
||||
| 'remove-edge'
|
||||
| 'add-subflow'
|
||||
| 'remove-subflow'
|
||||
| 'move-block'
|
||||
| 'move-subflow'
|
||||
| 'duplicate-block'
|
||||
| 'update-parent'
|
||||
|
||||
export interface BaseOperation {
|
||||
id: string
|
||||
type: OperationType
|
||||
timestamp: number
|
||||
workflowId: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface AddBlockOperation extends BaseOperation {
|
||||
type: 'add-block'
|
||||
data: {
|
||||
blockId: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoveBlockOperation extends BaseOperation {
|
||||
type: 'remove-block'
|
||||
data: {
|
||||
blockId: string
|
||||
blockSnapshot: BlockState | null
|
||||
edgeSnapshots?: Edge[]
|
||||
allBlockSnapshots?: Record<string, BlockState>
|
||||
}
|
||||
}
|
||||
|
||||
export interface AddEdgeOperation extends BaseOperation {
|
||||
type: 'add-edge'
|
||||
data: {
|
||||
edgeId: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoveEdgeOperation extends BaseOperation {
|
||||
type: 'remove-edge'
|
||||
data: {
|
||||
edgeId: string
|
||||
edgeSnapshot: Edge | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface AddSubflowOperation extends BaseOperation {
|
||||
type: 'add-subflow'
|
||||
data: {
|
||||
subflowId: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoveSubflowOperation extends BaseOperation {
|
||||
type: 'remove-subflow'
|
||||
data: {
|
||||
subflowId: string
|
||||
subflowSnapshot: BlockState | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface MoveBlockOperation extends BaseOperation {
|
||||
type: 'move-block'
|
||||
data: {
|
||||
blockId: string
|
||||
before: {
|
||||
x: number
|
||||
y: number
|
||||
parentId?: string
|
||||
}
|
||||
after: {
|
||||
x: number
|
||||
y: number
|
||||
parentId?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface MoveSubflowOperation extends BaseOperation {
|
||||
type: 'move-subflow'
|
||||
data: {
|
||||
subflowId: string
|
||||
before: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
after: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface DuplicateBlockOperation extends BaseOperation {
|
||||
type: 'duplicate-block'
|
||||
data: {
|
||||
sourceBlockId: string
|
||||
duplicatedBlockId: string
|
||||
duplicatedBlockSnapshot: BlockState
|
||||
autoConnectEdge?: Edge
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateParentOperation extends BaseOperation {
|
||||
type: 'update-parent'
|
||||
data: {
|
||||
blockId: string
|
||||
oldParentId?: string
|
||||
newParentId?: string
|
||||
oldPosition: { x: number; y: number }
|
||||
newPosition: { x: number; y: number }
|
||||
affectedEdges?: Edge[]
|
||||
}
|
||||
}
|
||||
|
||||
export type Operation =
|
||||
| AddBlockOperation
|
||||
| RemoveBlockOperation
|
||||
| AddEdgeOperation
|
||||
| RemoveEdgeOperation
|
||||
| AddSubflowOperation
|
||||
| RemoveSubflowOperation
|
||||
| MoveBlockOperation
|
||||
| MoveSubflowOperation
|
||||
| DuplicateBlockOperation
|
||||
| UpdateParentOperation
|
||||
|
||||
export interface OperationEntry {
|
||||
id: string
|
||||
operation: Operation
|
||||
inverse: Operation
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface UndoRedoState {
|
||||
stacks: Record<
|
||||
string,
|
||||
{
|
||||
undo: OperationEntry[]
|
||||
redo: OperationEntry[]
|
||||
}
|
||||
>
|
||||
capacity: number
|
||||
push: (workflowId: string, userId: string, entry: OperationEntry) => void
|
||||
undo: (workflowId: string, userId: string) => OperationEntry | null
|
||||
redo: (workflowId: string, userId: string) => OperationEntry | null
|
||||
clear: (workflowId: string, userId: string) => void
|
||||
clearRedo: (workflowId: string, userId: string) => void
|
||||
getStackSizes: (workflowId: string, userId: string) => { undoSize: number; redoSize: number }
|
||||
setCapacity: (capacity: number) => void
|
||||
pruneInvalidEntries: (
|
||||
workflowId: string,
|
||||
userId: string,
|
||||
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
|
||||
) => void
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { Operation, OperationEntry } from './types'
|
||||
|
||||
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
operation,
|
||||
inverse,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createInverseOperation(operation: Operation): Operation {
|
||||
switch (operation.type) {
|
||||
case 'add-block':
|
||||
return {
|
||||
...operation,
|
||||
type: 'remove-block',
|
||||
data: {
|
||||
blockId: operation.data.blockId,
|
||||
blockSnapshot: null,
|
||||
edgeSnapshots: [],
|
||||
},
|
||||
}
|
||||
|
||||
case 'remove-block':
|
||||
return {
|
||||
...operation,
|
||||
type: 'add-block',
|
||||
data: {
|
||||
blockId: operation.data.blockId,
|
||||
},
|
||||
}
|
||||
|
||||
case 'add-edge':
|
||||
return {
|
||||
...operation,
|
||||
type: 'remove-edge',
|
||||
data: {
|
||||
edgeId: operation.data.edgeId,
|
||||
edgeSnapshot: null,
|
||||
},
|
||||
}
|
||||
|
||||
case 'remove-edge':
|
||||
return {
|
||||
...operation,
|
||||
type: 'add-edge',
|
||||
data: {
|
||||
edgeId: operation.data.edgeId,
|
||||
},
|
||||
}
|
||||
|
||||
case 'add-subflow':
|
||||
return {
|
||||
...operation,
|
||||
type: 'remove-subflow',
|
||||
data: {
|
||||
subflowId: operation.data.subflowId,
|
||||
subflowSnapshot: null,
|
||||
},
|
||||
}
|
||||
|
||||
case 'remove-subflow':
|
||||
return {
|
||||
...operation,
|
||||
type: 'add-subflow',
|
||||
data: {
|
||||
subflowId: operation.data.subflowId,
|
||||
},
|
||||
}
|
||||
|
||||
case 'move-block':
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
blockId: operation.data.blockId,
|
||||
before: operation.data.after,
|
||||
after: operation.data.before,
|
||||
},
|
||||
}
|
||||
|
||||
case 'move-subflow':
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
subflowId: operation.data.subflowId,
|
||||
before: operation.data.after,
|
||||
after: operation.data.before,
|
||||
},
|
||||
}
|
||||
|
||||
case 'duplicate-block':
|
||||
return {
|
||||
...operation,
|
||||
type: 'remove-block',
|
||||
data: {
|
||||
blockId: operation.data.duplicatedBlockId,
|
||||
blockSnapshot: operation.data.duplicatedBlockSnapshot,
|
||||
edgeSnapshots: [],
|
||||
},
|
||||
}
|
||||
|
||||
case 'update-parent':
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
blockId: operation.data.blockId,
|
||||
oldParentId: operation.data.newParentId,
|
||||
newParentId: operation.data.oldParentId,
|
||||
oldPosition: operation.data.newPosition,
|
||||
newPosition: operation.data.oldPosition,
|
||||
affectedEdges: operation.data.affectedEdges,
|
||||
},
|
||||
}
|
||||
|
||||
default: {
|
||||
const exhaustiveCheck: never = operation
|
||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as any).type}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function operationToCollaborativePayload(operation: Operation): {
|
||||
operation: string
|
||||
target: string
|
||||
payload: any
|
||||
} {
|
||||
switch (operation.type) {
|
||||
case 'add-block':
|
||||
return {
|
||||
operation: 'add',
|
||||
target: 'block',
|
||||
payload: { id: operation.data.blockId },
|
||||
}
|
||||
|
||||
case 'remove-block':
|
||||
return {
|
||||
operation: 'remove',
|
||||
target: 'block',
|
||||
payload: { id: operation.data.blockId },
|
||||
}
|
||||
|
||||
case 'add-edge':
|
||||
return {
|
||||
operation: 'add',
|
||||
target: 'edge',
|
||||
payload: { id: operation.data.edgeId },
|
||||
}
|
||||
|
||||
case 'remove-edge':
|
||||
return {
|
||||
operation: 'remove',
|
||||
target: 'edge',
|
||||
payload: { id: operation.data.edgeId },
|
||||
}
|
||||
|
||||
case 'add-subflow':
|
||||
return {
|
||||
operation: 'add',
|
||||
target: 'subflow',
|
||||
payload: { id: operation.data.subflowId },
|
||||
}
|
||||
|
||||
case 'remove-subflow':
|
||||
return {
|
||||
operation: 'remove',
|
||||
target: 'subflow',
|
||||
payload: { id: operation.data.subflowId },
|
||||
}
|
||||
|
||||
case 'move-block':
|
||||
return {
|
||||
operation: 'update-position',
|
||||
target: 'block',
|
||||
payload: {
|
||||
id: operation.data.blockId,
|
||||
x: operation.data.after.x,
|
||||
y: operation.data.after.y,
|
||||
parentId: operation.data.after.parentId,
|
||||
},
|
||||
}
|
||||
|
||||
case 'move-subflow':
|
||||
return {
|
||||
operation: 'update-position',
|
||||
target: 'subflow',
|
||||
payload: {
|
||||
id: operation.data.subflowId,
|
||||
x: operation.data.after.x,
|
||||
y: operation.data.after.y,
|
||||
},
|
||||
}
|
||||
|
||||
case 'duplicate-block':
|
||||
return {
|
||||
operation: 'duplicate',
|
||||
target: 'block',
|
||||
payload: {
|
||||
sourceId: operation.data.sourceBlockId,
|
||||
duplicatedId: operation.data.duplicatedBlockId,
|
||||
},
|
||||
}
|
||||
|
||||
case 'update-parent':
|
||||
return {
|
||||
operation: 'update-parent',
|
||||
target: 'block',
|
||||
payload: {
|
||||
id: operation.data.blockId,
|
||||
parentId: operation.data.newParentId,
|
||||
x: operation.data.newPosition.x,
|
||||
y: operation.data.newPosition.y,
|
||||
},
|
||||
}
|
||||
|
||||
default: {
|
||||
const exhaustiveCheck: never = operation
|
||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as any).type}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,48 @@
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Normalizes a block name for comparison by converting to lowercase and removing spaces
|
||||
* @param name - The block name to normalize
|
||||
* @returns The normalized name
|
||||
*/
|
||||
export function normalizeBlockName(name: string): string {
|
||||
return name.toLowerCase().replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique block name by finding the highest number suffix among existing blocks
|
||||
* with the same base name and incrementing it
|
||||
* @param baseName - The base name for the block (e.g., "API 1", "Agent", "Loop 3")
|
||||
* @param existingBlocks - Record of existing blocks to check against
|
||||
* @returns A unique block name with an appropriate number suffix
|
||||
*/
|
||||
export function getUniqueBlockName(baseName: string, existingBlocks: Record<string, any>): string {
|
||||
const baseNameMatch = baseName.match(/^(.*?)(\s+\d+)?$/)
|
||||
const namePrefix = baseNameMatch ? baseNameMatch[1].trim() : baseName
|
||||
|
||||
const normalizedBase = normalizeBlockName(namePrefix)
|
||||
|
||||
const existingNumbers = Object.values(existingBlocks)
|
||||
.filter((block) => {
|
||||
const blockNameMatch = block.name?.match(/^(.*?)(\s+\d+)?$/)
|
||||
const blockPrefix = blockNameMatch ? blockNameMatch[1].trim() : block.name
|
||||
return blockPrefix && normalizeBlockName(blockPrefix) === normalizedBase
|
||||
})
|
||||
.map((block) => {
|
||||
const match = block.name?.match(/(\d+)$/)
|
||||
return match ? Number.parseInt(match[1], 10) : 0
|
||||
})
|
||||
|
||||
const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0
|
||||
|
||||
if (maxNumber === 0 && existingNumbers.length === 0) {
|
||||
return `${namePrefix} 1`
|
||||
}
|
||||
|
||||
return `${namePrefix} ${maxNumber + 1}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges workflow block states with subblock values while maintaining block structure
|
||||
* @param blocks - Block configurations from workflow store
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
} from '@/stores/workflows/middleware'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import {
|
||||
getUniqueBlockName,
|
||||
mergeSubblockState,
|
||||
normalizeBlockName,
|
||||
} from '@/stores/workflows/utils'
|
||||
import type {
|
||||
Position,
|
||||
SubBlockState,
|
||||
@@ -521,11 +525,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
y: block.position.y + 20,
|
||||
}
|
||||
|
||||
// More efficient name handling
|
||||
const match = block.name.match(/(.*?)(\d+)?$/)
|
||||
const newName = match?.[2]
|
||||
? `${match[1]}${Number.parseInt(match[2]) + 1}`
|
||||
: `${block.name} 1`
|
||||
const newName = getUniqueBlockName(block.name, get().blocks)
|
||||
|
||||
// Get merged state to capture current subblock values
|
||||
const mergedBlock = mergeSubblockState(get().blocks, id)[id]
|
||||
@@ -602,11 +602,6 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
const oldBlock = get().blocks[id]
|
||||
if (!oldBlock) return false
|
||||
|
||||
// Helper function to normalize block names (same as resolver)
|
||||
const normalizeBlockName = (blockName: string): string => {
|
||||
return blockName.toLowerCase().replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
// Check for normalized name collisions
|
||||
const normalizedNewName = normalizeBlockName(name)
|
||||
const currentBlocks = get().blocks
|
||||
@@ -1173,6 +1168,14 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
generateParallelBlocks: () => {
|
||||
return generateParallelBlocks(get().blocks)
|
||||
},
|
||||
|
||||
setDragStartPosition: (position) => {
|
||||
set({ dragStartPosition: position })
|
||||
},
|
||||
|
||||
getDragStartPosition: () => {
|
||||
return get().dragStartPosition || null
|
||||
},
|
||||
})),
|
||||
{ name: 'workflow-store' }
|
||||
)
|
||||
|
||||
@@ -128,6 +128,13 @@ export interface Parallel {
|
||||
parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs
|
||||
}
|
||||
|
||||
export interface DragStartPosition {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
export interface WorkflowState {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
@@ -142,6 +149,8 @@ export interface WorkflowState {
|
||||
deploymentStatuses?: Record<string, DeploymentStatus>
|
||||
needsRedeployment?: boolean
|
||||
hasActiveWebhook?: boolean
|
||||
// Drag state for undo/redo
|
||||
dragStartPosition?: DragStartPosition | null
|
||||
}
|
||||
|
||||
// New interface for sync control
|
||||
@@ -203,6 +212,8 @@ export interface WorkflowActions {
|
||||
revertToDeployedState: (deployedState: WorkflowState) => void
|
||||
toggleBlockAdvancedMode: (id: string) => void
|
||||
toggleBlockTriggerMode: (id: string) => void
|
||||
setDragStartPosition: (position: DragStartPosition | null) => void
|
||||
getDragStartPosition: () => DragStartPosition | null
|
||||
|
||||
// Add the sync control methods to the WorkflowActions interface
|
||||
sync: SyncControl
|
||||
|
||||
Reference in New Issue
Block a user