mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
improvement(canvas): add multi-block select, add batch handle, enabled, and edge operations
This commit is contained in:
@@ -767,7 +767,7 @@ export default function PrivacyPolicy() {
|
||||
privacy@sim.ai
|
||||
</Link>
|
||||
</li>
|
||||
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94133, USA</li>
|
||||
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA</li>
|
||||
</ul>
|
||||
<p>We will respond to your request within a reasonable timeframe.</p>
|
||||
</section>
|
||||
|
||||
@@ -42,6 +42,38 @@
|
||||
animation: dash-animation 1.5s linear infinite !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Flow selection box styling
|
||||
* Uses brand-secondary color for selection highlighting
|
||||
*/
|
||||
.react-flow__selection {
|
||||
background: rgba(51, 180, 255, 0.08) !important;
|
||||
border: 1px solid var(--brand-secondary) !important;
|
||||
}
|
||||
|
||||
.react-flow__nodesselection-rect {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected node ring indicator
|
||||
* Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40)
|
||||
*/
|
||||
.react-flow__node.selected > div > div {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.react-flow__node.selected > div > div::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1.75px var(--brand-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Color tokens - single source of truth for all colors
|
||||
* Light mode: Warm theme
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
useFloatBoundarySync,
|
||||
useFloatDrag,
|
||||
useFloatResize,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float'
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float'
|
||||
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
|
||||
import type { BlockLog, ExecutionResult } from '@/executor/types'
|
||||
import { getChatPosition, useChatStore } from '@/stores/chat/store'
|
||||
|
||||
@@ -32,7 +32,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
|
||||
z-index: 9999;
|
||||
`
|
||||
|
||||
// Create icon container
|
||||
const iconContainer = document.createElement('div')
|
||||
iconContainer.style.cssText = `
|
||||
width: 24px;
|
||||
@@ -45,7 +44,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
// Clone the actual icon if provided
|
||||
if (info.iconElement) {
|
||||
const clonedIcon = info.iconElement.cloneNode(true) as HTMLElement
|
||||
clonedIcon.style.width = '16px'
|
||||
@@ -55,11 +53,10 @@ export function createDragPreview(info: DragItemInfo): HTMLElement {
|
||||
iconContainer.appendChild(clonedIcon)
|
||||
}
|
||||
|
||||
// Create text element
|
||||
const text = document.createElement('span')
|
||||
text.textContent = info.name
|
||||
text.style.cssText = `
|
||||
color: #FFFFFF;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -1489,9 +1489,7 @@ export function Terminal() {
|
||||
variant='ghost'
|
||||
className={clsx(
|
||||
'px-[8px] py-[6px] text-[12px]',
|
||||
!showInput &&
|
||||
hasInputData &&
|
||||
'!text-[var(--text-primary)] dark:!text-[var(--text-primary)]'
|
||||
!showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -1509,7 +1507,7 @@ export function Terminal() {
|
||||
variant='ghost'
|
||||
className={clsx(
|
||||
'px-[8px] py-[6px] text-[12px]',
|
||||
showInput && '!text-[var(--text-primary)]'
|
||||
showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -34,8 +34,8 @@ export const ActionBar = memo(
|
||||
const {
|
||||
collaborativeBatchAddBlocks,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeToggleBlockEnabled,
|
||||
collaborativeToggleBlockHandles,
|
||||
collaborativeBatchToggleBlockEnabled,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
} = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
@@ -121,7 +121,7 @@ export const ActionBar = memo(
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeToggleBlockEnabled(blockId)
|
||||
collaborativeBatchToggleBlockEnabled([blockId])
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
@@ -192,7 +192,7 @@ export const ActionBar = memo(
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeToggleBlockHandles(blockId)
|
||||
collaborativeBatchToggleBlockHandles([blockId])
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
export {
|
||||
clearDragHighlights,
|
||||
computeClampedPositionUpdates,
|
||||
getClampedPositionForNode,
|
||||
isInEditableElement,
|
||||
selectNodesDeferred,
|
||||
validateTriggerPaste,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
|
||||
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './float'
|
||||
export { useAutoLayout } from './use-auto-layout'
|
||||
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
|
||||
export { useBlockVisual } from './use-block-visual'
|
||||
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
|
||||
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './use-float'
|
||||
export { useNodeUtilities } from './use-node-utilities'
|
||||
export { usePreventZoom } from './use-prevent-zoom'
|
||||
export { useScrollManagement } from './use-scroll-management'
|
||||
|
||||
@@ -21,7 +21,7 @@ interface UseBlockVisualProps {
|
||||
|
||||
/**
|
||||
* Provides visual state and interaction handlers for workflow blocks.
|
||||
* Computes ring styling based on execution, focus, diff, and run path states.
|
||||
* Computes ring styling based on execution, diff, deletion, and run path states.
|
||||
* In preview mode, all interactive and execution-related visual states are disabled.
|
||||
*
|
||||
* @param props - The hook properties
|
||||
@@ -46,8 +46,6 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)
|
||||
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
const isFocused = isPreview ? false : currentBlockId === blockId
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!isPreview) {
|
||||
@@ -60,12 +58,11 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis
|
||||
getBlockRingStyles({
|
||||
isActive,
|
||||
isPending: isPreview ? false : isPending,
|
||||
isFocused,
|
||||
isDeletedBlock: isPreview ? false : isDeletedBlock,
|
||||
diffStatus: isPreview ? undefined : diffStatus,
|
||||
runPathStatus,
|
||||
}),
|
||||
[isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus, isPreview]
|
||||
[isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,6 @@ export type BlockRunPathStatus = 'success' | 'error' | undefined
|
||||
export interface BlockRingOptions {
|
||||
isActive: boolean
|
||||
isPending: boolean
|
||||
isFocused: boolean
|
||||
isDeletedBlock: boolean
|
||||
diffStatus: BlockDiffStatus
|
||||
runPathStatus: BlockRunPathStatus
|
||||
@@ -15,18 +14,17 @@ export interface BlockRingOptions {
|
||||
|
||||
/**
|
||||
* Derives visual ring visibility and class names for workflow blocks
|
||||
* based on execution, focus, diff, deletion, and run-path states.
|
||||
* based on execution, diff, deletion, and run-path states.
|
||||
*/
|
||||
export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
hasRing: boolean
|
||||
ringClassName: string
|
||||
} {
|
||||
const { isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus } = options
|
||||
const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus } = options
|
||||
|
||||
const hasRing =
|
||||
isActive ||
|
||||
isPending ||
|
||||
isFocused ||
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
isDeletedBlock ||
|
||||
@@ -39,34 +37,28 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
!isActive && hasRing && 'ring-[1.75px]',
|
||||
// Pending state: warning ring
|
||||
!isActive && isPending && 'ring-[var(--warning)]',
|
||||
// Focused (selected) state: brand ring
|
||||
!isActive && !isPending && isFocused && 'ring-[var(--brand-secondary)]',
|
||||
// Deleted state (highest priority after active/pending/focused)
|
||||
!isActive && !isPending && !isFocused && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
// Deleted state (highest priority after active/pending)
|
||||
!isActive && !isPending && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
// Diff states
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'new' &&
|
||||
'ring-[var(--brand-tertiary)]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'edited' &&
|
||||
'ring-[var(--warning)]',
|
||||
// Run path states (lowest priority - only show if no other states active)
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
runPathStatus === 'success' &&
|
||||
'ring-[var(--border-success)]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
runPathStatus === 'error' &&
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Node } from 'reactflow'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Checks if the currently focused element is an editable input.
|
||||
* Returns true if the user is typing in an input, textarea, or contenteditable element.
|
||||
*/
|
||||
export function isInEditableElement(): boolean {
|
||||
const activeElement = document.activeElement
|
||||
return (
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.hasAttribute('contenteditable') === true
|
||||
)
|
||||
}
|
||||
|
||||
interface TriggerValidationResult {
|
||||
isValid: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that pasting/duplicating trigger blocks won't violate constraints.
|
||||
* Returns validation result with error message if invalid.
|
||||
*/
|
||||
export function validateTriggerPaste(
|
||||
blocksToAdd: Array<{ type: string }>,
|
||||
existingBlocks: Record<string, BlockState>,
|
||||
action: 'paste' | 'duplicate'
|
||||
): TriggerValidationResult {
|
||||
for (const block of blocksToAdd) {
|
||||
if (TriggerUtils.isAnyTriggerType(block.type)) {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(existingBlocks, block.type)
|
||||
if (issue) {
|
||||
const actionText = action === 'paste' ? 'paste' : 'duplicate'
|
||||
const message =
|
||||
issue.issue === 'legacy'
|
||||
? `Cannot ${actionText} trigger blocks when a legacy Start block exists.`
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : 'Cannot duplicate.'}`
|
||||
return { isValid: false, message }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears drag highlight classes and resets cursor state.
|
||||
* Used when drag operations end or are cancelled.
|
||||
*/
|
||||
export function clearDragHighlights(): void {
|
||||
document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => {
|
||||
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
|
||||
})
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects nodes by their IDs after paste/duplicate operations.
|
||||
* Defers selection to next animation frame to allow displayNodes to sync from store first.
|
||||
* This is necessary because the component uses controlled state (nodes={displayNodes})
|
||||
* and newly added blocks need time to propagate through the store → derivedNodes → displayNodes cycle.
|
||||
*/
|
||||
export function selectNodesDeferred(
|
||||
nodeIds: string[],
|
||||
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void
|
||||
): void {
|
||||
const idsSet = new Set(nodeIds)
|
||||
requestAnimationFrame(() => {
|
||||
setDisplayNodes((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
...node,
|
||||
selected: idsSet.has(node.id),
|
||||
}))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
interface BlockData {
|
||||
height?: number
|
||||
data?: {
|
||||
parentId?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the final position for a node, clamping it to parent container if needed.
|
||||
* Returns the clamped position suitable for persistence.
|
||||
*/
|
||||
export function getClampedPositionForNode(
|
||||
nodeId: string,
|
||||
nodePosition: { x: number; y: number },
|
||||
blocks: Record<string, BlockData>,
|
||||
allNodes: Node[]
|
||||
): { x: number; y: number } {
|
||||
const currentBlock = blocks[nodeId]
|
||||
const currentParentId = currentBlock?.data?.parentId
|
||||
|
||||
if (!currentParentId) {
|
||||
return nodePosition
|
||||
}
|
||||
|
||||
const parentNode = allNodes.find((n) => n.id === currentParentId)
|
||||
if (!parentNode) {
|
||||
return nodePosition
|
||||
}
|
||||
|
||||
const containerDimensions = {
|
||||
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
const blockDimensions = {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(
|
||||
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
|
||||
BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
),
|
||||
}
|
||||
|
||||
return clampPositionToContainer(nodePosition, containerDimensions, blockDimensions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes position updates for multiple nodes, clamping each to its parent container.
|
||||
* Used for batch position updates after multi-node drag or selection drag.
|
||||
*/
|
||||
export function computeClampedPositionUpdates(
|
||||
nodes: Node[],
|
||||
blocks: Record<string, BlockData>,
|
||||
allNodes: Node[]
|
||||
): Array<{ id: string; position: { x: number; y: number } }> {
|
||||
return nodes.map((node) => ({
|
||||
id: node.id,
|
||||
position: getClampedPositionForNode(node.id, node.position, blocks, allNodes),
|
||||
}))
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import ReactFlow, {
|
||||
type NodeChange,
|
||||
type NodeTypes,
|
||||
ReactFlowProvider,
|
||||
SelectionMode,
|
||||
useReactFlow,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
@@ -42,9 +43,15 @@ import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import {
|
||||
clearDragHighlights,
|
||||
computeClampedPositionUpdates,
|
||||
getClampedPositionForNode,
|
||||
isInEditableElement,
|
||||
selectNodesDeferred,
|
||||
useAutoLayout,
|
||||
useCurrentWorkflow,
|
||||
useNodeUtilities,
|
||||
validateTriggerPaste,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu'
|
||||
import {
|
||||
@@ -180,11 +187,12 @@ const reactFlowStyles = [
|
||||
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
|
||||
const reactFlowProOptions = { hideAttribution: true } as const
|
||||
|
||||
interface SelectedEdgeInfo {
|
||||
id: string
|
||||
parentLoopId?: string
|
||||
contextId?: string
|
||||
}
|
||||
/**
|
||||
* Map from edge contextId to edge id.
|
||||
* Context IDs include parent loop info for edges inside loops.
|
||||
* The actual edge ID is stored as the value for deletion operations.
|
||||
*/
|
||||
type SelectedEdgesMap = Map<string, string>
|
||||
|
||||
interface BlockData {
|
||||
id: string
|
||||
@@ -200,7 +208,7 @@ interface BlockData {
|
||||
const WorkflowContent = React.memo(() => {
|
||||
const [isCanvasReady, setIsCanvasReady] = useState(false)
|
||||
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
|
||||
const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<SelectedEdgeInfo | null>(null)
|
||||
const [selectedEdges, setSelectedEdges] = useState<SelectedEdgesMap>(new Map())
|
||||
const [isShiftPressed, setIsShiftPressed] = useState(false)
|
||||
const [isSelectionDragActive, setIsSelectionDragActive] = useState(false)
|
||||
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
|
||||
@@ -280,7 +288,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
useStreamCleanup(copilotCleanup)
|
||||
|
||||
const { blocks, edges, isDiffMode, lastSaved } = currentWorkflow
|
||||
const { blocks, edges, lastSaved } = currentWorkflow
|
||||
|
||||
const isWorkflowReady = useMemo(
|
||||
() =>
|
||||
@@ -343,6 +351,11 @@ const WorkflowContent = React.memo(() => {
|
||||
/** Stores source node/handle info when a connection drag starts for drop-on-block detection. */
|
||||
const connectionSourceRef = useRef<{ nodeId: string; handleId: string } | null>(null)
|
||||
|
||||
/** Stores start positions for multi-node drag undo/redo recording. */
|
||||
const multiNodeDragStartRef = useRef<Map<string, { x: number; y: number; parentId?: string }>>(
|
||||
new Map()
|
||||
)
|
||||
|
||||
/** Re-applies diff markers when blocks change after socket rehydration. */
|
||||
const blocksRef = useRef(blocks)
|
||||
useEffect(() => {
|
||||
@@ -431,12 +444,13 @@ const WorkflowContent = React.memo(() => {
|
||||
const {
|
||||
collaborativeAddEdge: addEdge,
|
||||
collaborativeRemoveEdge: removeEdge,
|
||||
collaborativeBatchRemoveEdges,
|
||||
collaborativeBatchUpdatePositions,
|
||||
collaborativeUpdateParentId: updateParentId,
|
||||
collaborativeBatchAddBlocks,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeToggleBlockEnabled,
|
||||
collaborativeToggleBlockHandles,
|
||||
collaborativeBatchToggleBlockEnabled,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
undo,
|
||||
redo,
|
||||
} = useCollaborativeWorkflow()
|
||||
@@ -636,22 +650,14 @@ const WorkflowContent = React.memo(() => {
|
||||
} = pasteData
|
||||
|
||||
const pastedBlocksArray = Object.values(pastedBlocks)
|
||||
for (const block of pastedBlocksArray) {
|
||||
if (TriggerUtils.isAnyTriggerType(block.type)) {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type)
|
||||
if (issue) {
|
||||
const message =
|
||||
issue.issue === 'legacy'
|
||||
? 'Cannot paste trigger blocks when a legacy Start block exists.'
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before pasting.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'paste')
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: validation.message!,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
@@ -661,6 +667,11 @@ const WorkflowContent = React.memo(() => {
|
||||
pastedParallels,
|
||||
pastedSubBlockValues
|
||||
)
|
||||
|
||||
selectNodesDeferred(
|
||||
pastedBlocksArray.map((b) => b.id),
|
||||
setDisplayNodes
|
||||
)
|
||||
}, [
|
||||
hasClipboard,
|
||||
clipboard,
|
||||
@@ -687,22 +698,14 @@ const WorkflowContent = React.memo(() => {
|
||||
} = pasteData
|
||||
|
||||
const pastedBlocksArray = Object.values(pastedBlocks)
|
||||
for (const block of pastedBlocksArray) {
|
||||
if (TriggerUtils.isAnyTriggerType(block.type)) {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type)
|
||||
if (issue) {
|
||||
const message =
|
||||
issue.issue === 'legacy'
|
||||
? 'Cannot duplicate trigger blocks when a legacy Start block exists.'
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. Cannot duplicate.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'duplicate')
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: validation.message!,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
@@ -712,6 +715,11 @@ const WorkflowContent = React.memo(() => {
|
||||
pastedParallels,
|
||||
pastedSubBlockValues
|
||||
)
|
||||
|
||||
selectNodesDeferred(
|
||||
pastedBlocksArray.map((b) => b.id),
|
||||
setDisplayNodes
|
||||
)
|
||||
}, [
|
||||
contextMenuBlocks,
|
||||
copyBlocks,
|
||||
@@ -728,16 +736,14 @@ const WorkflowContent = React.memo(() => {
|
||||
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks])
|
||||
|
||||
const handleContextToggleEnabled = useCallback(() => {
|
||||
contextMenuBlocks.forEach((block) => {
|
||||
collaborativeToggleBlockEnabled(block.id)
|
||||
})
|
||||
}, [contextMenuBlocks, collaborativeToggleBlockEnabled])
|
||||
const blockIds = contextMenuBlocks.map((block) => block.id)
|
||||
collaborativeBatchToggleBlockEnabled(blockIds)
|
||||
}, [contextMenuBlocks, collaborativeBatchToggleBlockEnabled])
|
||||
|
||||
const handleContextToggleHandles = useCallback(() => {
|
||||
contextMenuBlocks.forEach((block) => {
|
||||
collaborativeToggleBlockHandles(block.id)
|
||||
})
|
||||
}, [contextMenuBlocks, collaborativeToggleBlockHandles])
|
||||
const blockIds = contextMenuBlocks.map((block) => block.id)
|
||||
collaborativeBatchToggleBlockHandles(blockIds)
|
||||
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
|
||||
|
||||
const handleContextRemoveFromSubflow = useCallback(() => {
|
||||
contextMenuBlocks.forEach((block) => {
|
||||
@@ -788,13 +794,7 @@ 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) {
|
||||
if (isInEditableElement()) {
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
@@ -840,22 +840,14 @@ const WorkflowContent = React.memo(() => {
|
||||
const pasteData = preparePasteData(pasteOffset)
|
||||
if (pasteData) {
|
||||
const pastedBlocks = Object.values(pasteData.blocks)
|
||||
for (const block of pastedBlocks) {
|
||||
if (TriggerUtils.isAnyTriggerType(block.type)) {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type)
|
||||
if (issue) {
|
||||
const message =
|
||||
issue.issue === 'legacy'
|
||||
? 'Cannot paste trigger blocks when a legacy Start block exists.'
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before pasting.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
const validation = validateTriggerPaste(pastedBlocks, blocks, 'paste')
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: validation.message!,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
@@ -865,6 +857,11 @@ const WorkflowContent = React.memo(() => {
|
||||
pasteData.parallels,
|
||||
pasteData.subBlockValues
|
||||
)
|
||||
|
||||
selectNodesDeferred(
|
||||
pastedBlocks.map((b) => b.id),
|
||||
setDisplayNodes
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1168,10 +1165,7 @@ const WorkflowContent = React.memo(() => {
|
||||
try {
|
||||
const containerInfo = isPointInLoopNode(position)
|
||||
|
||||
document
|
||||
.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over')
|
||||
.forEach((el) => el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over'))
|
||||
document.body.style.cursor = ''
|
||||
clearDragHighlights()
|
||||
document.body.classList.remove('sim-drag-subflow')
|
||||
|
||||
if (data.type === 'loop' || data.type === 'parallel') {
|
||||
@@ -1599,11 +1593,7 @@ const WorkflowContent = React.memo(() => {
|
||||
const containerInfo = isPointInLoopNode(position)
|
||||
|
||||
// Clear any previous highlighting
|
||||
document
|
||||
.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over')
|
||||
.forEach((el) => {
|
||||
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
|
||||
})
|
||||
clearDragHighlights()
|
||||
|
||||
// Highlight container if hovering over it and not dragging a subflow
|
||||
// Subflow drag is marked by body class flag set by toolbar
|
||||
@@ -1803,7 +1793,7 @@ const WorkflowContent = React.memo(() => {
|
||||
const nodeArray: Node[] = []
|
||||
|
||||
// Add block nodes
|
||||
Object.entries(blocks).forEach(([blockId, block]) => {
|
||||
Object.entries(blocks).forEach(([, block]) => {
|
||||
if (!block || !block.type || !block.name) {
|
||||
return
|
||||
}
|
||||
@@ -1880,8 +1870,11 @@ const WorkflowContent = React.memo(() => {
|
||||
},
|
||||
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||
// Use estimated dimensions for blocks without measured height to ensure selection bounds are correct
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(block.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT),
|
||||
height: block.height
|
||||
? Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT)
|
||||
: estimateBlockDimensions(block.type).height,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1933,7 +1926,14 @@ const WorkflowContent = React.memo(() => {
|
||||
}, [isShiftPressed])
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayNodes(derivedNodes)
|
||||
// Preserve selection state when syncing from derivedNodes
|
||||
setDisplayNodes((currentNodes) => {
|
||||
const selectedIds = new Set(currentNodes.filter((n) => n.selected).map((n) => n.id))
|
||||
return derivedNodes.map((node) => ({
|
||||
...node,
|
||||
selected: selectedIds.has(node.id),
|
||||
}))
|
||||
})
|
||||
}, [derivedNodes])
|
||||
|
||||
/** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */
|
||||
@@ -2247,12 +2247,8 @@ const WorkflowContent = React.memo(() => {
|
||||
if (isStarterBlock) {
|
||||
// If it's a starter block, remove any highlighting and don't allow it to be dragged into containers
|
||||
if (potentialParentId) {
|
||||
const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`)
|
||||
if (prevElement) {
|
||||
prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
|
||||
}
|
||||
clearDragHighlights()
|
||||
setPotentialParentId(null)
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
return // Exit early - don't process any container intersections for starter blocks
|
||||
}
|
||||
@@ -2264,12 +2260,8 @@ const WorkflowContent = React.memo(() => {
|
||||
if (node.type === 'subflowNode') {
|
||||
// Clear any highlighting for subflow nodes
|
||||
if (potentialParentId) {
|
||||
const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`)
|
||||
if (prevElement) {
|
||||
prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
|
||||
}
|
||||
clearDragHighlights()
|
||||
setPotentialParentId(null)
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
return // Exit early - subflows cannot be placed inside other subflows
|
||||
}
|
||||
@@ -2370,12 +2362,8 @@ const WorkflowContent = React.memo(() => {
|
||||
} else {
|
||||
// Remove highlighting if no longer over a container
|
||||
if (potentialParentId) {
|
||||
const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`)
|
||||
if (prevElement) {
|
||||
prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
|
||||
}
|
||||
clearDragHighlights()
|
||||
setPotentialParentId(null)
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2402,49 +2390,48 @@ const WorkflowContent = React.memo(() => {
|
||||
y: node.position.y,
|
||||
parentId: currentParentId,
|
||||
})
|
||||
|
||||
// Capture all selected nodes' positions for multi-node undo/redo
|
||||
const allNodes = getNodes()
|
||||
const selectedNodes = allNodes.filter((n) => n.selected)
|
||||
multiNodeDragStartRef.current.clear()
|
||||
selectedNodes.forEach((n) => {
|
||||
multiNodeDragStartRef.current.set(n.id, {
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
parentId: blocks[n.id]?.data?.parentId,
|
||||
})
|
||||
})
|
||||
},
|
||||
[blocks, setDragStartPosition]
|
||||
[blocks, setDragStartPosition, getNodes]
|
||||
)
|
||||
|
||||
/** Handles node drag stop to establish parent-child relationships. */
|
||||
const onNodeDragStop = useCallback(
|
||||
(_event: React.MouseEvent, node: any) => {
|
||||
// Clear UI effects
|
||||
document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => {
|
||||
el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')
|
||||
})
|
||||
document.body.style.cursor = ''
|
||||
clearDragHighlights()
|
||||
|
||||
// Get the block's current parent (if any)
|
||||
const currentBlock = blocks[node.id]
|
||||
const currentParentId = currentBlock?.data?.parentId
|
||||
// Get all selected nodes to update their positions too
|
||||
const allNodes = getNodes()
|
||||
const selectedNodes = allNodes.filter((n) => n.selected)
|
||||
|
||||
// Calculate position - clamp if inside a container
|
||||
let finalPosition = node.position
|
||||
if (currentParentId) {
|
||||
// Block is inside a container - clamp position to keep it fully inside
|
||||
const parentNode = getNodes().find((n) => n.id === currentParentId)
|
||||
if (parentNode) {
|
||||
const containerDimensions = {
|
||||
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
const blockDimensions = {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(
|
||||
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
|
||||
BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
),
|
||||
}
|
||||
// If multiple nodes are selected, update all their positions
|
||||
if (selectedNodes.length > 1) {
|
||||
const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
|
||||
collaborativeBatchUpdatePositions(positionUpdates, {
|
||||
previousPositions: multiNodeDragStartRef.current,
|
||||
})
|
||||
|
||||
finalPosition = clampPositionToContainer(
|
||||
node.position,
|
||||
containerDimensions,
|
||||
blockDimensions
|
||||
)
|
||||
}
|
||||
// Clear drag start state
|
||||
setDragStartPosition(null)
|
||||
setPotentialParentId(null)
|
||||
multiNodeDragStartRef.current.clear()
|
||||
return
|
||||
}
|
||||
|
||||
// Single node drag - original logic
|
||||
const finalPosition = getClampedPositionForNode(node.id, node.position, blocks, allNodes)
|
||||
|
||||
updateBlockPosition(node.id, finalPosition)
|
||||
|
||||
// Record single move entry on drag end to avoid micro-moves
|
||||
@@ -2577,6 +2564,7 @@ const WorkflowContent = React.memo(() => {
|
||||
setDragStartPosition,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
collaborativeBatchUpdatePositions,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -2596,47 +2584,41 @@ const WorkflowContent = React.memo(() => {
|
||||
requestAnimationFrame(() => setIsSelectionDragActive(false))
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const positionUpdates = nodes.map((node) => {
|
||||
const currentBlock = blocks[node.id]
|
||||
const currentParentId = currentBlock?.data?.parentId
|
||||
let finalPosition = node.position
|
||||
|
||||
if (currentParentId) {
|
||||
const parentNode = getNodes().find((n) => n.id === currentParentId)
|
||||
if (parentNode) {
|
||||
const containerDimensions = {
|
||||
width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
const blockDimensions = {
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(
|
||||
currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
|
||||
BLOCK_DIMENSIONS.MIN_HEIGHT
|
||||
),
|
||||
}
|
||||
finalPosition = clampPositionToContainer(
|
||||
node.position,
|
||||
containerDimensions,
|
||||
blockDimensions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { id: node.id, position: finalPosition }
|
||||
const allNodes = getNodes()
|
||||
const positionUpdates = computeClampedPositionUpdates(nodes, blocks, allNodes)
|
||||
collaborativeBatchUpdatePositions(positionUpdates, {
|
||||
previousPositions: multiNodeDragStartRef.current,
|
||||
})
|
||||
|
||||
collaborativeBatchUpdatePositions(positionUpdates)
|
||||
multiNodeDragStartRef.current.clear()
|
||||
},
|
||||
[blocks, getNodes, collaborativeBatchUpdatePositions]
|
||||
)
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedEdgeInfo(null)
|
||||
setSelectedEdges(new Map())
|
||||
usePanelEditorStore.getState().clearCurrentBlock()
|
||||
}, [])
|
||||
|
||||
/** Handles edge selection with container context tracking. */
|
||||
/**
|
||||
* Handles node click to select the node in ReactFlow.
|
||||
* This ensures clicking anywhere on a block (not just the drag handle)
|
||||
* selects it for delete/backspace and multi-select operations.
|
||||
*/
|
||||
const handleNodeClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node) => {
|
||||
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
|
||||
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => ({
|
||||
...n,
|
||||
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id,
|
||||
}))
|
||||
)
|
||||
},
|
||||
[setNodes]
|
||||
)
|
||||
|
||||
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
|
||||
const onEdgeClick = useCallback(
|
||||
(event: React.MouseEvent, edge: any) => {
|
||||
event.stopPropagation() // Prevent bubbling
|
||||
@@ -2652,11 +2634,21 @@ const WorkflowContent = React.memo(() => {
|
||||
// Create a unique identifier that combines edge ID and parent context
|
||||
const contextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}`
|
||||
|
||||
setSelectedEdgeInfo({
|
||||
id: edge.id,
|
||||
parentLoopId,
|
||||
contextId,
|
||||
})
|
||||
if (event.shiftKey) {
|
||||
// Shift-click: toggle edge in selection
|
||||
setSelectedEdges((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (next.has(contextId)) {
|
||||
next.delete(contextId)
|
||||
} else {
|
||||
next.set(contextId, edge.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
// Normal click: replace selection with this edge
|
||||
setSelectedEdges(new Map([[contextId, edge.id]]))
|
||||
}
|
||||
},
|
||||
[getNodes]
|
||||
)
|
||||
@@ -2665,7 +2657,16 @@ const WorkflowContent = React.memo(() => {
|
||||
const handleEdgeDelete = useCallback(
|
||||
(edgeId: string) => {
|
||||
removeEdge(edgeId)
|
||||
setSelectedEdgeInfo((current) => (current?.id === edgeId ? null : current))
|
||||
// Remove this edge from selection (find by edge ID value)
|
||||
setSelectedEdges((prev) => {
|
||||
const next = new Map(prev)
|
||||
for (const [contextId, id] of next) {
|
||||
if (id === edgeId) {
|
||||
next.delete(contextId)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[removeEdge]
|
||||
)
|
||||
@@ -2685,7 +2686,7 @@ const WorkflowContent = React.memo(() => {
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
isSelected: selectedEdgeInfo?.contextId === edgeContextId,
|
||||
isSelected: selectedEdges.has(edgeContextId),
|
||||
isInsideLoop: Boolean(parentLoopId),
|
||||
parentLoopId,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
@@ -2693,7 +2694,7 @@ const WorkflowContent = React.memo(() => {
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [edgesForDisplay, displayNodes, selectedEdgeInfo?.contextId, handleEdgeDelete])
|
||||
}, [edgesForDisplay, displayNodes, selectedEdges, handleEdgeDelete])
|
||||
|
||||
/** Handles Delete/Backspace to remove selected edges or blocks. */
|
||||
useEffect(() => {
|
||||
@@ -2703,20 +2704,16 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
|
||||
// Ignore when typing/navigating inside editable inputs or editors
|
||||
const activeElement = document.activeElement
|
||||
const isEditableElement =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.hasAttribute('contenteditable')
|
||||
|
||||
if (isEditableElement) {
|
||||
if (isInEditableElement()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle edge deletion first (edges take priority if selected)
|
||||
if (selectedEdgeInfo) {
|
||||
removeEdge(selectedEdgeInfo.id)
|
||||
setSelectedEdgeInfo(null)
|
||||
if (selectedEdges.size > 0) {
|
||||
// Get all selected edge IDs and batch delete them
|
||||
const edgeIds = Array.from(selectedEdges.values())
|
||||
collaborativeBatchRemoveEdges(edgeIds)
|
||||
setSelectedEdges(new Map())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2738,8 +2735,8 @@ const WorkflowContent = React.memo(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [
|
||||
selectedEdgeInfo,
|
||||
removeEdge,
|
||||
selectedEdges,
|
||||
collaborativeBatchRemoveEdges,
|
||||
getNodes,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
effectivePermissions.canEdit,
|
||||
@@ -2796,6 +2793,7 @@ const WorkflowContent = React.memo(() => {
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
onPaneClick={onPaneClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
onNodeClick={handleNodeClick}
|
||||
onPaneContextMenu={handlePaneContextMenu}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onSelectionContextMenu={handleSelectionContextMenu}
|
||||
@@ -2803,10 +2801,11 @@ const WorkflowContent = React.memo(() => {
|
||||
onPointerLeave={handleCanvasPointerLeave}
|
||||
elementsSelectable={true}
|
||||
selectionOnDrag={isShiftPressed || isSelectionDragActive}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
panOnDrag={isShiftPressed || isSelectionDragActive ? false : [0, 1]}
|
||||
onSelectionStart={onSelectionStart}
|
||||
onSelectionEnd={onSelectionEnd}
|
||||
multiSelectionKeyCode={['Meta', 'Control']}
|
||||
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
|
||||
nodesConnectable={effectivePermissions.canEdit}
|
||||
nodesDraggable={effectivePermissions.canEdit}
|
||||
draggable={false}
|
||||
|
||||
@@ -112,7 +112,7 @@ export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }:
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
{brand.name}
|
||||
{isHosted && <>, 80 Langton St, San Francisco, CA 94133, USA</>}
|
||||
{isHosted && <>, 80 Langton St, San Francisco, CA 94103, USA</>}
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
|
||||
@@ -20,8 +20,6 @@ import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/wo
|
||||
|
||||
const logger = createLogger('CollaborativeWorkflow')
|
||||
|
||||
const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
|
||||
|
||||
export function useCollaborativeWorkflow() {
|
||||
const undoRedo = useUndoRedo()
|
||||
const isUndoRedoInProgress = useRef(false)
|
||||
@@ -33,7 +31,7 @@ export function useCollaborativeWorkflow() {
|
||||
const { blockId, before, after } = e.detail || {}
|
||||
if (!blockId || !before || !after) return
|
||||
if (isUndoRedoInProgress.current) return
|
||||
undoRedo.recordMove(blockId, before, after)
|
||||
undoRedo.recordBatchMoveBlocks([{ blockId, before, after }])
|
||||
}
|
||||
|
||||
const parentUpdateHandler = (e: any) => {
|
||||
@@ -290,6 +288,34 @@ export function useCollaborativeWorkflow() {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (target === 'edges') {
|
||||
switch (operation) {
|
||||
case 'batch-remove-edges': {
|
||||
const { ids } = payload
|
||||
if (Array.isArray(ids)) {
|
||||
ids.forEach((id: string) => {
|
||||
workflowStore.removeEdge(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 [wfId, uId] = key.split(':')
|
||||
if (wfId === activeWorkflowId) {
|
||||
undoRedoStore.pruneInvalidEntries(wfId, uId, graph)
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (target === 'subflow') {
|
||||
switch (operation) {
|
||||
case 'update':
|
||||
@@ -722,7 +748,12 @@ export function useCollaborativeWorkflow() {
|
||||
)
|
||||
|
||||
const collaborativeBatchUpdatePositions = useCallback(
|
||||
(updates: Array<{ id: string; position: Position }>) => {
|
||||
(
|
||||
updates: Array<{ id: string; position: Position }>,
|
||||
options?: {
|
||||
previousPositions?: Map<string, { x: number; y: number; parentId?: string }>
|
||||
}
|
||||
) => {
|
||||
if (!isInActiveRoom()) {
|
||||
logger.debug('Skipping batch position update - not in active workflow')
|
||||
return
|
||||
@@ -746,8 +777,31 @@ export function useCollaborativeWorkflow() {
|
||||
updates.forEach(({ id, position }) => {
|
||||
workflowStore.updateBlockPosition(id, position)
|
||||
})
|
||||
|
||||
if (options?.previousPositions && options.previousPositions.size > 0) {
|
||||
const moves = updates
|
||||
.filter((u) => options.previousPositions!.has(u.id))
|
||||
.map((u) => {
|
||||
const prev = options.previousPositions!.get(u.id)!
|
||||
const block = workflowStore.blocks[u.id]
|
||||
return {
|
||||
blockId: u.id,
|
||||
before: prev,
|
||||
after: {
|
||||
x: u.position.x,
|
||||
y: u.position.y,
|
||||
parentId: block?.data?.parentId,
|
||||
},
|
||||
}
|
||||
})
|
||||
.filter((m) => m.before.x !== m.after.x || m.before.y !== m.after.y)
|
||||
|
||||
if (moves.length > 0) {
|
||||
undoRedo.recordBatchMoveBlocks(moves)
|
||||
}
|
||||
}
|
||||
},
|
||||
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore]
|
||||
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
|
||||
)
|
||||
|
||||
const collaborativeUpdateBlockName = useCallback(
|
||||
@@ -822,13 +876,43 @@ export function useCollaborativeWorkflow() {
|
||||
[executeQueuedOperation, workflowStore, addToQueue, activeWorkflowId, session?.user?.id]
|
||||
)
|
||||
|
||||
const collaborativeToggleBlockEnabled = useCallback(
|
||||
(id: string) => {
|
||||
executeQueuedOperation('toggle-enabled', 'block', { id }, () =>
|
||||
const collaborativeBatchToggleBlockEnabled = useCallback(
|
||||
(ids: string[]) => {
|
||||
if (ids.length === 0) return
|
||||
|
||||
const previousStates: Record<string, boolean> = {}
|
||||
const validIds: string[] = []
|
||||
|
||||
for (const id of ids) {
|
||||
const block = workflowStore.blocks[id]
|
||||
if (block) {
|
||||
previousStates[id] = block.enabled
|
||||
validIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (validIds.length === 0) return
|
||||
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
addToQueue({
|
||||
id: operationId,
|
||||
operation: {
|
||||
operation: 'batch-toggle-enabled',
|
||||
target: 'blocks',
|
||||
payload: { blockIds: validIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
for (const id of validIds) {
|
||||
workflowStore.toggleBlockEnabled(id)
|
||||
)
|
||||
}
|
||||
|
||||
undoRedo.recordBatchToggleEnabled(validIds, previousStates)
|
||||
},
|
||||
[executeQueuedOperation, workflowStore]
|
||||
[addToQueue, activeWorkflowId, session?.user?.id, workflowStore, undoRedo]
|
||||
)
|
||||
|
||||
const collaborativeUpdateParentId = useCallback(
|
||||
@@ -888,27 +972,48 @@ export function useCollaborativeWorkflow() {
|
||||
[executeQueuedOperation, workflowStore]
|
||||
)
|
||||
|
||||
const collaborativeToggleBlockHandles = useCallback(
|
||||
(id: string) => {
|
||||
const currentBlock = workflowStore.blocks[id]
|
||||
if (!currentBlock) return
|
||||
const collaborativeBatchToggleBlockHandles = useCallback(
|
||||
(ids: string[]) => {
|
||||
if (ids.length === 0) return
|
||||
|
||||
const newHorizontalHandles = !currentBlock.horizontalHandles
|
||||
const previousStates: Record<string, boolean> = {}
|
||||
const validIds: string[] = []
|
||||
|
||||
executeQueuedOperation(
|
||||
'toggle-handles',
|
||||
'block',
|
||||
{ id, horizontalHandles: newHorizontalHandles },
|
||||
() => workflowStore.toggleBlockHandles(id)
|
||||
)
|
||||
for (const id of ids) {
|
||||
const block = workflowStore.blocks[id]
|
||||
if (block) {
|
||||
previousStates[id] = block.horizontalHandles ?? false
|
||||
validIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (validIds.length === 0) return
|
||||
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
addToQueue({
|
||||
id: operationId,
|
||||
operation: {
|
||||
operation: 'batch-toggle-handles',
|
||||
target: 'blocks',
|
||||
payload: { blockIds: validIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
for (const id of validIds) {
|
||||
workflowStore.toggleBlockHandles(id)
|
||||
}
|
||||
|
||||
undoRedo.recordBatchToggleHandles(validIds, previousStates)
|
||||
},
|
||||
[executeQueuedOperation, workflowStore]
|
||||
[addToQueue, activeWorkflowId, session?.user?.id, workflowStore, undoRedo]
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -920,13 +1025,11 @@ export function useCollaborativeWorkflow() {
|
||||
(edgeId: string) => {
|
||||
const edge = workflowStore.edges.find((e) => e.id === edgeId)
|
||||
|
||||
// Skip if edge doesn't exist (already removed during cascade deletion)
|
||||
if (!edge) {
|
||||
logger.debug('Edge already removed, skipping operation', { edgeId })
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the edge's source and target blocks still exist
|
||||
const sourceExists = workflowStore.blocks[edge.source]
|
||||
const targetExists = workflowStore.blocks[edge.target]
|
||||
|
||||
@@ -939,9 +1042,8 @@ export function useCollaborativeWorkflow() {
|
||||
return
|
||||
}
|
||||
|
||||
// Only record edge removal if it's not part of a parent update operation
|
||||
if (!skipEdgeRecording.current) {
|
||||
undoRedo.recordRemoveEdge(edgeId, edge)
|
||||
undoRedo.recordBatchRemoveEdges([edge])
|
||||
}
|
||||
|
||||
executeQueuedOperation('remove', 'edge', { id: edgeId }, () =>
|
||||
@@ -951,11 +1053,64 @@ export function useCollaborativeWorkflow() {
|
||||
[executeQueuedOperation, workflowStore, undoRedo]
|
||||
)
|
||||
|
||||
const collaborativeBatchRemoveEdges = useCallback(
|
||||
(edgeIds: string[], options?: { skipUndoRedo?: boolean }) => {
|
||||
if (!isInActiveRoom()) {
|
||||
logger.debug('Skipping batch remove edges - not in active workflow')
|
||||
return false
|
||||
}
|
||||
|
||||
if (edgeIds.length === 0) return false
|
||||
|
||||
const edgeSnapshots: Edge[] = []
|
||||
const validEdgeIds: string[] = []
|
||||
|
||||
for (const edgeId of edgeIds) {
|
||||
const edge = workflowStore.edges.find((e) => e.id === edgeId)
|
||||
if (edge) {
|
||||
const sourceExists = workflowStore.blocks[edge.source]
|
||||
const targetExists = workflowStore.blocks[edge.target]
|
||||
if (sourceExists && targetExists) {
|
||||
edgeSnapshots.push(edge)
|
||||
validEdgeIds.push(edgeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validEdgeIds.length === 0) {
|
||||
logger.debug('No valid edges to remove')
|
||||
return false
|
||||
}
|
||||
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
addToQueue({
|
||||
id: operationId,
|
||||
operation: {
|
||||
operation: 'batch-remove-edges',
|
||||
target: 'edges',
|
||||
payload: { ids: validEdgeIds },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
validEdgeIds.forEach((id) => workflowStore.removeEdge(id))
|
||||
|
||||
if (!options?.skipUndoRedo && edgeSnapshots.length > 0) {
|
||||
undoRedo.recordBatchRemoveEdges(edgeSnapshots)
|
||||
}
|
||||
|
||||
logger.info('Batch removed edges', { count: validEdgeIds.length })
|
||||
return true
|
||||
},
|
||||
[isInActiveRoom, workflowStore, addToQueue, activeWorkflowId, session, undoRedo]
|
||||
)
|
||||
|
||||
const collaborativeSetSubblockValue = useCallback(
|
||||
(blockId: string, subblockId: string, value: any, options?: { _visited?: Set<string> }) => {
|
||||
if (isApplyingRemoteChange.current) return
|
||||
|
||||
// Skip socket operations when viewing baseline diff
|
||||
if (isBaselineDiffView) {
|
||||
logger.debug('Skipping collaborative subblock update while viewing baseline diff')
|
||||
return
|
||||
@@ -971,13 +1126,10 @@ export function useCollaborativeWorkflow() {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate operation ID for queue tracking
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
// Get fresh activeWorkflowId from store to avoid stale closure
|
||||
const currentActiveWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
|
||||
// Add to queue for retry mechanism
|
||||
addToQueue({
|
||||
id: operationId,
|
||||
operation: {
|
||||
@@ -989,10 +1141,8 @@ export function useCollaborativeWorkflow() {
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
// Apply locally first (immediate UI feedback)
|
||||
subBlockStore.setValue(blockId, subblockId, value)
|
||||
|
||||
// Declarative clearing: clear sub-blocks that depend on this subblockId
|
||||
try {
|
||||
const visited = options?._visited || new Set<string>()
|
||||
if (visited.has(subblockId)) return
|
||||
@@ -1004,9 +1154,7 @@ export function useCollaborativeWorkflow() {
|
||||
(sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId)
|
||||
)
|
||||
for (const dep of dependents) {
|
||||
// Skip clearing if the dependent is the same field
|
||||
if (!dep?.id || dep.id === subblockId) continue
|
||||
// Cascade using the same collaborative path so it emits and further cascades
|
||||
collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited })
|
||||
}
|
||||
}
|
||||
@@ -1512,15 +1660,16 @@ export function useCollaborativeWorkflow() {
|
||||
// Collaborative operations
|
||||
collaborativeBatchUpdatePositions,
|
||||
collaborativeUpdateBlockName,
|
||||
collaborativeToggleBlockEnabled,
|
||||
collaborativeBatchToggleBlockEnabled,
|
||||
collaborativeUpdateParentId,
|
||||
collaborativeToggleBlockAdvancedMode,
|
||||
collaborativeToggleBlockTriggerMode,
|
||||
collaborativeToggleBlockHandles,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
collaborativeBatchAddBlocks,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeAddEdge,
|
||||
collaborativeRemoveEdge,
|
||||
collaborativeBatchRemoveEdges,
|
||||
collaborativeSetSubblockValue,
|
||||
collaborativeSetTagSelection,
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-o
|
||||
import { useOperationQueue } from '@/stores/operation-queue/store'
|
||||
import {
|
||||
type BatchAddBlocksOperation,
|
||||
type BatchMoveBlocksOperation,
|
||||
type BatchRemoveBlocksOperation,
|
||||
type BatchRemoveEdgesOperation,
|
||||
type BatchToggleEnabledOperation,
|
||||
type BatchToggleHandlesOperation,
|
||||
createOperationEntry,
|
||||
type MoveBlockOperation,
|
||||
type Operation,
|
||||
type RemoveEdgeOperation,
|
||||
runWithUndoRedoRecordingSuspended,
|
||||
type UpdateParentOperation,
|
||||
useUndoRedoStore,
|
||||
@@ -128,6 +130,8 @@ export function useUndoRedo() {
|
||||
(edgeId: string) => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
const edgeSnapshot = workflowStore.edges.find((e) => e.id === edgeId)
|
||||
|
||||
const operation: Operation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'add-edge',
|
||||
@@ -137,15 +141,15 @@ export function useUndoRedo() {
|
||||
data: { edgeId },
|
||||
}
|
||||
|
||||
const inverse: RemoveEdgeOperation = {
|
||||
// Inverse is batch-remove-edges with a single edge
|
||||
const inverse: BatchRemoveEdgesOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'remove-edge',
|
||||
type: 'batch-remove-edges',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: {
|
||||
edgeId,
|
||||
edgeSnapshot: workflowStore.edges.find((e) => e.id === edgeId) || null,
|
||||
edgeSnapshots: edgeSnapshot ? [edgeSnapshot] : [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -157,77 +161,83 @@ export function useUndoRedo() {
|
||||
[activeWorkflowId, userId, workflowStore, undoRedoStore]
|
||||
)
|
||||
|
||||
const recordRemoveEdge = useCallback(
|
||||
(edgeId: string, edgeSnapshot: Edge) => {
|
||||
if (!activeWorkflowId) return
|
||||
const recordBatchRemoveEdges = useCallback(
|
||||
(edgeSnapshots: Edge[]) => {
|
||||
if (!activeWorkflowId || edgeSnapshots.length === 0) return
|
||||
|
||||
const operation: RemoveEdgeOperation = {
|
||||
const operation: BatchRemoveEdgesOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'remove-edge',
|
||||
type: 'batch-remove-edges',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: {
|
||||
edgeId,
|
||||
edgeSnapshot,
|
||||
edgeSnapshots,
|
||||
},
|
||||
}
|
||||
|
||||
const inverse: Operation = {
|
||||
// Inverse is batch-add-edges (using the snapshots to restore)
|
||||
const inverse: BatchRemoveEdgesOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'add-edge',
|
||||
type: 'batch-remove-edges',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: { edgeId },
|
||||
data: {
|
||||
edgeSnapshots,
|
||||
},
|
||||
}
|
||||
|
||||
const entry = createOperationEntry(operation, inverse)
|
||||
undoRedoStore.push(activeWorkflowId, userId, entry)
|
||||
|
||||
logger.debug('Recorded remove edge', { edgeId, workflowId: activeWorkflowId })
|
||||
logger.debug('Recorded batch remove edges', {
|
||||
edgeCount: edgeSnapshots.length,
|
||||
workflowId: activeWorkflowId,
|
||||
})
|
||||
},
|
||||
[activeWorkflowId, userId, undoRedoStore]
|
||||
)
|
||||
|
||||
const recordMove = useCallback(
|
||||
const recordBatchMoveBlocks = useCallback(
|
||||
(
|
||||
blockId: string,
|
||||
before: { x: number; y: number; parentId?: string },
|
||||
after: { x: number; y: number; parentId?: string }
|
||||
moves: Array<{
|
||||
blockId: string
|
||||
before: { x: number; y: number; parentId?: string }
|
||||
after: { x: number; y: number; parentId?: string }
|
||||
}>
|
||||
) => {
|
||||
if (!activeWorkflowId) return
|
||||
if (!activeWorkflowId || moves.length === 0) return
|
||||
|
||||
const operation: MoveBlockOperation = {
|
||||
const operation: BatchMoveBlocksOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'move-block',
|
||||
type: 'batch-move-blocks',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: {
|
||||
blockId,
|
||||
before,
|
||||
after,
|
||||
},
|
||||
data: { moves },
|
||||
}
|
||||
|
||||
const inverse: MoveBlockOperation = {
|
||||
// Inverse swaps before/after for each move
|
||||
const inverse: BatchMoveBlocksOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'move-block',
|
||||
type: 'batch-move-blocks',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: {
|
||||
blockId,
|
||||
before: after,
|
||||
after: before,
|
||||
moves: moves.map((m) => ({
|
||||
blockId: m.blockId,
|
||||
before: m.after,
|
||||
after: m.before,
|
||||
})),
|
||||
},
|
||||
}
|
||||
|
||||
const entry = createOperationEntry(operation, inverse)
|
||||
undoRedoStore.push(activeWorkflowId, userId, entry)
|
||||
|
||||
logger.debug('Recorded move', { blockId, from: before, to: after })
|
||||
logger.debug('Recorded batch move', { blockCount: moves.length })
|
||||
},
|
||||
[activeWorkflowId, userId, undoRedoStore]
|
||||
)
|
||||
@@ -288,6 +298,66 @@ export function useUndoRedo() {
|
||||
[activeWorkflowId, userId, undoRedoStore]
|
||||
)
|
||||
|
||||
const recordBatchToggleEnabled = useCallback(
|
||||
(blockIds: string[], previousStates: Record<string, boolean>) => {
|
||||
if (!activeWorkflowId || blockIds.length === 0) return
|
||||
|
||||
const operation: BatchToggleEnabledOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'batch-toggle-enabled',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: { blockIds, previousStates },
|
||||
}
|
||||
|
||||
const inverse: BatchToggleEnabledOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'batch-toggle-enabled',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: { blockIds, previousStates },
|
||||
}
|
||||
|
||||
const entry = createOperationEntry(operation, inverse)
|
||||
undoRedoStore.push(activeWorkflowId, userId, entry)
|
||||
|
||||
logger.debug('Recorded batch toggle enabled', { blockIds, previousStates })
|
||||
},
|
||||
[activeWorkflowId, userId, undoRedoStore]
|
||||
)
|
||||
|
||||
const recordBatchToggleHandles = useCallback(
|
||||
(blockIds: string[], previousStates: Record<string, boolean>) => {
|
||||
if (!activeWorkflowId || blockIds.length === 0) return
|
||||
|
||||
const operation: BatchToggleHandlesOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'batch-toggle-handles',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: { blockIds, previousStates },
|
||||
}
|
||||
|
||||
const inverse: BatchToggleHandlesOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'batch-toggle-handles',
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: { blockIds, previousStates },
|
||||
}
|
||||
|
||||
const entry = createOperationEntry(operation, inverse)
|
||||
undoRedoStore.push(activeWorkflowId, userId, entry)
|
||||
|
||||
logger.debug('Recorded batch toggle handles', { blockIds, previousStates })
|
||||
},
|
||||
[activeWorkflowId, userId, undoRedoStore]
|
||||
)
|
||||
|
||||
const undo = useCallback(async () => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
@@ -422,92 +492,81 @@ export function useUndoRedo() {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'remove-edge': {
|
||||
const removeEdgeInverse = entry.inverse as RemoveEdgeOperation
|
||||
const { edgeId } = removeEdgeInverse.data
|
||||
if (workflowStore.edges.find((e) => e.id === edgeId)) {
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'remove',
|
||||
target: 'edge',
|
||||
payload: {
|
||||
id: edgeId,
|
||||
isUndo: true,
|
||||
originalOpId: entry.id,
|
||||
case 'batch-remove-edges': {
|
||||
const batchRemoveInverse = entry.inverse as BatchRemoveEdgesOperation
|
||||
const { edgeSnapshots } = batchRemoveInverse.data
|
||||
|
||||
if (entry.operation.type === 'add-edge') {
|
||||
// Undo add-edge: remove the edges that were added
|
||||
const edgesToRemove = edgeSnapshots
|
||||
.filter((e) => workflowStore.edges.find((edge) => edge.id === e.id))
|
||||
.map((e) => e.id)
|
||||
|
||||
if (edgesToRemove.length > 0) {
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'batch-remove-edges',
|
||||
target: 'edges',
|
||||
payload: { ids: edgesToRemove },
|
||||
},
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
workflowStore.removeEdge(edgeId)
|
||||
} else {
|
||||
logger.debug('Undo remove-edge skipped; edge missing', {
|
||||
edgeId,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'add-edge': {
|
||||
const originalOp = entry.operation as RemoveEdgeOperation
|
||||
const { edgeSnapshot } = originalOp.data
|
||||
// Skip if snapshot missing or already exists
|
||||
if (!edgeSnapshot || workflowStore.edges.find((e) => e.id === edgeSnapshot.id)) {
|
||||
logger.debug('Undo add-edge skipped', {
|
||||
hasSnapshot: Boolean(edgeSnapshot),
|
||||
})
|
||||
break
|
||||
}
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'edge',
|
||||
payload: { ...edgeSnapshot, isUndo: true, originalOpId: entry.id },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
workflowStore.addEdge(edgeSnapshot)
|
||||
break
|
||||
}
|
||||
case 'move-block': {
|
||||
const moveOp = entry.inverse as MoveBlockOperation
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
if (currentBlocks[moveOp.data.blockId]) {
|
||||
// Apply the inverse's target as the undo result (inverse.after)
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'update-position',
|
||||
target: 'block',
|
||||
payload: {
|
||||
id: moveOp.data.blockId,
|
||||
position: { x: moveOp.data.after.x, y: moveOp.data.after.y },
|
||||
parentId: moveOp.data.after.parentId,
|
||||
commit: true,
|
||||
isUndo: true,
|
||||
originalOpId: entry.id,
|
||||
},
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
// Use the store from the hook context for React re-renders
|
||||
workflowStore.updateBlockPosition(moveOp.data.blockId, {
|
||||
x: moveOp.data.after.x,
|
||||
y: moveOp.data.after.y,
|
||||
})
|
||||
if (moveOp.data.after.parentId !== moveOp.data.before.parentId) {
|
||||
workflowStore.updateParentId(
|
||||
moveOp.data.blockId,
|
||||
moveOp.data.after.parentId || '',
|
||||
'parent'
|
||||
)
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
edgesToRemove.forEach((id) => workflowStore.removeEdge(id))
|
||||
}
|
||||
logger.debug('Undid add-edge', { edgeCount: edgesToRemove.length })
|
||||
} else {
|
||||
logger.debug('Undo move-block skipped; block missing', {
|
||||
blockId: moveOp.data.blockId,
|
||||
// Undo batch-remove-edges: add edges back
|
||||
const edgesToAdd = edgeSnapshots.filter(
|
||||
(e) => !workflowStore.edges.find((edge) => edge.id === e.id)
|
||||
)
|
||||
|
||||
if (edgesToAdd.length > 0) {
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'batch-add-edges',
|
||||
target: 'edges',
|
||||
payload: { edges: edgesToAdd },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
edgesToAdd.forEach((edge) => workflowStore.addEdge(edge))
|
||||
}
|
||||
logger.debug('Undid batch-remove-edges', { edgeCount: edgesToAdd.length })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'batch-move-blocks': {
|
||||
const batchMoveOp = entry.inverse as BatchMoveBlocksOperation
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
const positionUpdates: Array<{ id: string; position: { x: number; y: number } }> = []
|
||||
|
||||
for (const move of batchMoveOp.data.moves) {
|
||||
if (currentBlocks[move.blockId]) {
|
||||
positionUpdates.push({
|
||||
id: move.blockId,
|
||||
position: { x: move.after.x, y: move.after.y },
|
||||
})
|
||||
workflowStore.updateBlockPosition(move.blockId, {
|
||||
x: move.after.x,
|
||||
y: move.after.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (positionUpdates.length > 0) {
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'batch-update-positions',
|
||||
target: 'blocks',
|
||||
payload: { updates: positionUpdates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
break
|
||||
@@ -520,21 +579,22 @@ export function useUndoRedo() {
|
||||
if (workflowStore.blocks[blockId]) {
|
||||
// If we're moving back INTO a subflow, restore edges first
|
||||
if (newParentId && affectedEdges && affectedEdges.length > 0) {
|
||||
affectedEdges.forEach((edge) => {
|
||||
if (!workflowStore.edges.find((e) => e.id === edge.id)) {
|
||||
workflowStore.addEdge(edge)
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'edge',
|
||||
payload: { ...edge, isUndo: true },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
})
|
||||
const edgesToAdd = affectedEdges.filter(
|
||||
(e) => !workflowStore.edges.find((edge) => edge.id === e.id)
|
||||
)
|
||||
if (edgesToAdd.length > 0) {
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'batch-add-edges',
|
||||
target: 'edges',
|
||||
payload: { edges: edgesToAdd },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
edgesToAdd.forEach((edge) => workflowStore.addEdge(edge))
|
||||
}
|
||||
}
|
||||
|
||||
// Send position update to server
|
||||
@@ -602,8 +662,65 @@ export function useUndoRedo() {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'batch-toggle-enabled': {
|
||||
const toggleOp = entry.inverse as BatchToggleEnabledOperation
|
||||
const { blockIds, previousStates } = toggleOp.data
|
||||
|
||||
const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id])
|
||||
if (validBlockIds.length === 0) {
|
||||
logger.debug('Undo batch-toggle-enabled skipped; no blocks exist')
|
||||
break
|
||||
}
|
||||
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'batch-toggle-enabled',
|
||||
target: 'blocks',
|
||||
payload: { blockIds: validBlockIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
validBlockIds.forEach((blockId) => {
|
||||
const targetState = previousStates[blockId]
|
||||
if (workflowStore.blocks[blockId].enabled !== targetState) {
|
||||
workflowStore.toggleBlockEnabled(blockId)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'batch-toggle-handles': {
|
||||
const toggleOp = entry.inverse as BatchToggleHandlesOperation
|
||||
const { blockIds, previousStates } = toggleOp.data
|
||||
|
||||
const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id])
|
||||
if (validBlockIds.length === 0) {
|
||||
logger.debug('Undo batch-toggle-handles skipped; no blocks exist')
|
||||
break
|
||||
}
|
||||
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'batch-toggle-handles',
|
||||
target: 'blocks',
|
||||
payload: { blockIds: validBlockIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
validBlockIds.forEach((blockId) => {
|
||||
const targetState = previousStates[blockId]
|
||||
if (workflowStore.blocks[blockId].horizontalHandles !== targetState) {
|
||||
workflowStore.toggleBlockHandles(blockId)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'apply-diff': {
|
||||
// Undo apply-diff means clearing the diff and restoring baseline
|
||||
const applyDiffInverse = entry.inverse as any
|
||||
const { baselineSnapshot } = applyDiffInverse.data
|
||||
|
||||
@@ -667,7 +784,6 @@ export function useUndoRedo() {
|
||||
const acceptDiffInverse = entry.inverse as any
|
||||
const acceptDiffOp = entry.operation as any
|
||||
const { beforeAccept, diffAnalysis } = acceptDiffInverse.data
|
||||
const { baselineSnapshot } = acceptDiffOp.data
|
||||
const { useWorkflowDiffStore } = await import('@/stores/workflow-diff/store')
|
||||
const diffStore = useWorkflowDiffStore.getState()
|
||||
|
||||
@@ -725,7 +841,6 @@ export function useUndoRedo() {
|
||||
case 'reject-diff': {
|
||||
// Undo reject-diff means restoring diff view with markers
|
||||
const rejectDiffInverse = entry.inverse as any
|
||||
const rejectDiffOp = entry.operation as any
|
||||
const { beforeReject, diffAnalysis, baselineSnapshot } = rejectDiffInverse.data
|
||||
const { useWorkflowDiffStore } = await import('@/stores/workflow-diff/store')
|
||||
const { useWorkflowStore } = await import('@/stores/workflows/workflow/store')
|
||||
@@ -886,84 +1001,83 @@ export function useUndoRedo() {
|
||||
break
|
||||
}
|
||||
case 'add-edge': {
|
||||
// Use snapshot from inverse
|
||||
const inv = entry.inverse as RemoveEdgeOperation
|
||||
const snap = inv.data.edgeSnapshot
|
||||
if (!snap || workflowStore.edges.find((e) => e.id === snap.id)) {
|
||||
logger.debug('Redo add-edge skipped', { hasSnapshot: Boolean(snap) })
|
||||
break
|
||||
}
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'edge',
|
||||
payload: { ...snap, isRedo: true, originalOpId: entry.id },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
workflowStore.addEdge(snap)
|
||||
break
|
||||
}
|
||||
case 'remove-edge': {
|
||||
const { edgeId } = entry.operation.data
|
||||
if (workflowStore.edges.find((e) => e.id === edgeId)) {
|
||||
const inv = entry.inverse as BatchRemoveEdgesOperation
|
||||
const edgeSnapshots = inv.data.edgeSnapshots
|
||||
|
||||
const edgesToAdd = edgeSnapshots.filter(
|
||||
(e) => !workflowStore.edges.find((edge) => edge.id === e.id)
|
||||
)
|
||||
|
||||
if (edgesToAdd.length > 0) {
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'remove',
|
||||
target: 'edge',
|
||||
payload: { id: edgeId, isRedo: true, originalOpId: entry.id },
|
||||
operation: 'batch-add-edges',
|
||||
target: 'edges',
|
||||
payload: { edges: edgesToAdd },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
workflowStore.removeEdge(edgeId)
|
||||
} else {
|
||||
logger.debug('Redo remove-edge skipped; edge missing', {
|
||||
edgeId,
|
||||
})
|
||||
edgesToAdd.forEach((edge) => workflowStore.addEdge(edge))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'move-block': {
|
||||
const moveOp = entry.operation as MoveBlockOperation
|
||||
case 'batch-remove-edges': {
|
||||
// Redo batch-remove-edges: remove all edges again
|
||||
const batchRemoveOp = entry.operation as BatchRemoveEdgesOperation
|
||||
const { edgeSnapshots } = batchRemoveOp.data
|
||||
|
||||
const edgesToRemove = edgeSnapshots
|
||||
.filter((e) => workflowStore.edges.find((edge) => edge.id === e.id))
|
||||
.map((e) => e.id)
|
||||
|
||||
if (edgesToRemove.length > 0) {
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'batch-remove-edges',
|
||||
target: 'edges',
|
||||
payload: { ids: edgesToRemove },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
edgesToRemove.forEach((id) => workflowStore.removeEdge(id))
|
||||
}
|
||||
|
||||
logger.debug('Redid batch-remove-edges', { edgeCount: edgesToRemove.length })
|
||||
break
|
||||
}
|
||||
case 'batch-move-blocks': {
|
||||
const batchMoveOp = entry.operation as BatchMoveBlocksOperation
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
if (currentBlocks[moveOp.data.blockId]) {
|
||||
const positionUpdates: Array<{ id: string; position: { x: number; y: number } }> = []
|
||||
|
||||
for (const move of batchMoveOp.data.moves) {
|
||||
if (currentBlocks[move.blockId]) {
|
||||
positionUpdates.push({
|
||||
id: move.blockId,
|
||||
position: { x: move.after.x, y: move.after.y },
|
||||
})
|
||||
workflowStore.updateBlockPosition(move.blockId, {
|
||||
x: move.after.x,
|
||||
y: move.after.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (positionUpdates.length > 0) {
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'update-position',
|
||||
target: 'block',
|
||||
payload: {
|
||||
id: moveOp.data.blockId,
|
||||
position: { x: moveOp.data.after.x, y: moveOp.data.after.y },
|
||||
parentId: moveOp.data.after.parentId,
|
||||
commit: true,
|
||||
isRedo: true,
|
||||
originalOpId: entry.id,
|
||||
},
|
||||
operation: 'batch-update-positions',
|
||||
target: 'blocks',
|
||||
payload: { updates: positionUpdates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
// Use the store from the hook context for React re-renders
|
||||
workflowStore.updateBlockPosition(moveOp.data.blockId, {
|
||||
x: moveOp.data.after.x,
|
||||
y: moveOp.data.after.y,
|
||||
})
|
||||
if (moveOp.data.after.parentId !== moveOp.data.before.parentId) {
|
||||
workflowStore.updateParentId(
|
||||
moveOp.data.blockId,
|
||||
moveOp.data.after.parentId || '',
|
||||
'parent'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.debug('Redo move-block skipped; block missing', {
|
||||
blockId: moveOp.data.blockId,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -1036,27 +1150,86 @@ export function useUndoRedo() {
|
||||
|
||||
// If we're adding TO a subflow, restore edges after
|
||||
if (newParentId && affectedEdges && affectedEdges.length > 0) {
|
||||
affectedEdges.forEach((edge) => {
|
||||
if (!workflowStore.edges.find((e) => e.id === edge.id)) {
|
||||
workflowStore.addEdge(edge)
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'add',
|
||||
target: 'edge',
|
||||
payload: { ...edge, isRedo: true },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
})
|
||||
const edgesToAdd = affectedEdges.filter(
|
||||
(e) => !workflowStore.edges.find((edge) => edge.id === e.id)
|
||||
)
|
||||
if (edgesToAdd.length > 0) {
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
operation: {
|
||||
operation: 'batch-add-edges',
|
||||
target: 'edges',
|
||||
payload: { edges: edgesToAdd },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
edgesToAdd.forEach((edge) => workflowStore.addEdge(edge))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug('Redo update-parent skipped; block missing', { blockId })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'batch-toggle-enabled': {
|
||||
const toggleOp = entry.operation as BatchToggleEnabledOperation
|
||||
const { blockIds, previousStates } = toggleOp.data
|
||||
|
||||
const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id])
|
||||
if (validBlockIds.length === 0) {
|
||||
logger.debug('Redo batch-toggle-enabled skipped; no blocks exist')
|
||||
break
|
||||
}
|
||||
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'batch-toggle-enabled',
|
||||
target: 'blocks',
|
||||
payload: { blockIds: validBlockIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
validBlockIds.forEach((blockId) => {
|
||||
const targetState = !previousStates[blockId]
|
||||
if (workflowStore.blocks[blockId].enabled !== targetState) {
|
||||
workflowStore.toggleBlockEnabled(blockId)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'batch-toggle-handles': {
|
||||
const toggleOp = entry.operation as BatchToggleHandlesOperation
|
||||
const { blockIds, previousStates } = toggleOp.data
|
||||
|
||||
const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id])
|
||||
if (validBlockIds.length === 0) {
|
||||
logger.debug('Redo batch-toggle-handles skipped; no blocks exist')
|
||||
break
|
||||
}
|
||||
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: 'batch-toggle-handles',
|
||||
target: 'blocks',
|
||||
payload: { blockIds: validBlockIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
validBlockIds.forEach((blockId) => {
|
||||
const targetState = !previousStates[blockId]
|
||||
if (workflowStore.blocks[blockId].horizontalHandles !== targetState) {
|
||||
workflowStore.toggleBlockHandles(blockId)
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'apply-diff': {
|
||||
// Redo apply-diff means re-applying the proposed state with diff markers
|
||||
const applyDiffOp = entry.operation as any
|
||||
@@ -1372,9 +1545,11 @@ export function useUndoRedo() {
|
||||
recordBatchAddBlocks,
|
||||
recordBatchRemoveBlocks,
|
||||
recordAddEdge,
|
||||
recordRemoveEdge,
|
||||
recordMove,
|
||||
recordBatchRemoveEdges,
|
||||
recordBatchMoveBlocks,
|
||||
recordUpdateParent,
|
||||
recordBatchToggleEnabled,
|
||||
recordBatchToggleHandles,
|
||||
recordApplyDiff,
|
||||
recordAcceptDiff,
|
||||
recordRejectDiff,
|
||||
|
||||
@@ -179,6 +179,9 @@ export async function persistWorkflowOperation(workflowId: string, operation: an
|
||||
case 'edge':
|
||||
await handleEdgeOperationTx(tx, workflowId, op, payload)
|
||||
break
|
||||
case 'edges':
|
||||
await handleEdgesOperationTx(tx, workflowId, op, payload)
|
||||
break
|
||||
case 'subflow':
|
||||
await handleSubflowOperationTx(tx, workflowId, op, payload)
|
||||
break
|
||||
@@ -690,6 +693,68 @@ async function handleBlocksOperationTx(
|
||||
break
|
||||
}
|
||||
|
||||
case 'batch-toggle-enabled': {
|
||||
const { blockIds } = payload
|
||||
if (!Array.isArray(blockIds) || blockIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}`
|
||||
)
|
||||
|
||||
for (const blockId of blockIds) {
|
||||
const block = await tx
|
||||
.select({ enabled: workflowBlocks.enabled })
|
||||
.from(workflowBlocks)
|
||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||
.limit(1)
|
||||
|
||||
if (block.length > 0) {
|
||||
await tx
|
||||
.update(workflowBlocks)
|
||||
.set({
|
||||
enabled: !block[0].enabled,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Batch toggled enabled state for ${blockIds.length} blocks`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'batch-toggle-handles': {
|
||||
const { blockIds } = payload
|
||||
if (!Array.isArray(blockIds) || blockIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`)
|
||||
|
||||
for (const blockId of blockIds) {
|
||||
const block = await tx
|
||||
.select({ horizontalHandles: workflowBlocks.horizontalHandles })
|
||||
.from(workflowBlocks)
|
||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||
.limit(1)
|
||||
|
||||
if (block.length > 0) {
|
||||
await tx
|
||||
.update(workflowBlocks)
|
||||
.set({
|
||||
horizontalHandles: !block[0].horizontalHandles,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Batch toggled handles for ${blockIds.length} blocks`)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported blocks operation: ${operation}`)
|
||||
}
|
||||
@@ -740,6 +805,60 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdgesOperationTx(
|
||||
tx: any,
|
||||
workflowId: string,
|
||||
operation: string,
|
||||
payload: any
|
||||
) {
|
||||
switch (operation) {
|
||||
case 'batch-remove-edges': {
|
||||
const { ids } = payload
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
logger.debug('No edge IDs provided for batch remove')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Batch removing ${ids.length} edges from workflow ${workflowId}`)
|
||||
|
||||
await tx
|
||||
.delete(workflowEdges)
|
||||
.where(and(eq(workflowEdges.workflowId, workflowId), inArray(workflowEdges.id, ids)))
|
||||
|
||||
logger.debug(`Batch removed ${ids.length} edges from workflow ${workflowId}`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'batch-add-edges': {
|
||||
const { edges } = payload
|
||||
if (!Array.isArray(edges) || edges.length === 0) {
|
||||
logger.debug('No edges provided for batch add')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Batch adding ${edges.length} edges to workflow ${workflowId}`)
|
||||
|
||||
const edgeValues = edges.map((edge: Record<string, unknown>) => ({
|
||||
id: edge.id as string,
|
||||
workflowId,
|
||||
sourceBlockId: edge.source as string,
|
||||
targetBlockId: edge.target as string,
|
||||
sourceHandle: (edge.sourceHandle as string | null) || null,
|
||||
targetHandle: (edge.targetHandle as string | null) || null,
|
||||
}))
|
||||
|
||||
await tx.insert(workflowEdges).values(edgeValues)
|
||||
|
||||
logger.debug(`Batch added ${edges.length} edges to workflow ${workflowId}`)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn(`Unknown edges operation: ${operation}`)
|
||||
throw new Error(`Unsupported edges operation: ${operation}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubflowOperationTx(
|
||||
tx: any,
|
||||
workflowId: string,
|
||||
|
||||
@@ -317,6 +317,122 @@ export function setupOperationsHandlers(
|
||||
return
|
||||
}
|
||||
|
||||
if (target === 'edges' && operation === 'batch-remove-edges') {
|
||||
await persistWorkflowOperation(workflowId, {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
userId: session.userId,
|
||||
})
|
||||
|
||||
room.lastModified = Date.now()
|
||||
|
||||
socket.to(workflowId).emit('workflow-operation', {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
senderId: socket.id,
|
||||
userId: session.userId,
|
||||
userName: session.userName,
|
||||
metadata: { workflowId, operationId: crypto.randomUUID() },
|
||||
})
|
||||
|
||||
if (operationId) {
|
||||
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (target === 'blocks' && operation === 'batch-toggle-enabled') {
|
||||
await persistWorkflowOperation(workflowId, {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
userId: session.userId,
|
||||
})
|
||||
|
||||
room.lastModified = Date.now()
|
||||
|
||||
socket.to(workflowId).emit('workflow-operation', {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
senderId: socket.id,
|
||||
userId: session.userId,
|
||||
userName: session.userName,
|
||||
metadata: { workflowId, operationId: crypto.randomUUID() },
|
||||
})
|
||||
|
||||
if (operationId) {
|
||||
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (target === 'blocks' && operation === 'batch-toggle-handles') {
|
||||
await persistWorkflowOperation(workflowId, {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
userId: session.userId,
|
||||
})
|
||||
|
||||
room.lastModified = Date.now()
|
||||
|
||||
socket.to(workflowId).emit('workflow-operation', {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
senderId: socket.id,
|
||||
userId: session.userId,
|
||||
userName: session.userName,
|
||||
metadata: { workflowId, operationId: crypto.randomUUID() },
|
||||
})
|
||||
|
||||
if (operationId) {
|
||||
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (target === 'edges' && operation === 'batch-add-edges') {
|
||||
await persistWorkflowOperation(workflowId, {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
userId: session.userId,
|
||||
})
|
||||
|
||||
room.lastModified = Date.now()
|
||||
|
||||
socket.to(workflowId).emit('workflow-operation', {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
timestamp: operationTimestamp,
|
||||
senderId: socket.id,
|
||||
userId: session.userId,
|
||||
userName: session.userName,
|
||||
metadata: { workflowId, operationId: crypto.randomUUID() },
|
||||
})
|
||||
|
||||
if (operationId) {
|
||||
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// For non-position operations, persist first then broadcast
|
||||
await persistWorkflowOperation(workflowId, {
|
||||
operation,
|
||||
|
||||
@@ -16,6 +16,10 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
'batch-update-positions',
|
||||
'batch-add-blocks',
|
||||
'batch-remove-blocks',
|
||||
'batch-add-edges',
|
||||
'batch-remove-edges',
|
||||
'batch-toggle-enabled',
|
||||
'batch-toggle-handles',
|
||||
'update-name',
|
||||
'toggle-enabled',
|
||||
'update-parent',
|
||||
@@ -33,6 +37,10 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
'batch-update-positions',
|
||||
'batch-add-blocks',
|
||||
'batch-remove-blocks',
|
||||
'batch-add-edges',
|
||||
'batch-remove-edges',
|
||||
'batch-toggle-enabled',
|
||||
'batch-toggle-handles',
|
||||
'update-name',
|
||||
'toggle-enabled',
|
||||
'update-parent',
|
||||
|
||||
@@ -5,7 +5,6 @@ const PositionSchema = z.object({
|
||||
y: z.number(),
|
||||
})
|
||||
|
||||
// Schema for auto-connect edge data
|
||||
const AutoConnectEdgeSchema = z.object({
|
||||
id: z.string(),
|
||||
source: z.string(),
|
||||
@@ -148,12 +147,66 @@ export const BatchRemoveBlocksSchema = z.object({
|
||||
operationId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BatchRemoveEdgesSchema = z.object({
|
||||
operation: z.literal('batch-remove-edges'),
|
||||
target: z.literal('edges'),
|
||||
payload: z.object({
|
||||
ids: z.array(z.string()),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
operationId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BatchAddEdgesSchema = z.object({
|
||||
operation: z.literal('batch-add-edges'),
|
||||
target: z.literal('edges'),
|
||||
payload: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
source: z.string(),
|
||||
target: z.string(),
|
||||
sourceHandle: z.string().nullable().optional(),
|
||||
targetHandle: z.string().nullable().optional(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
operationId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BatchToggleEnabledSchema = z.object({
|
||||
operation: z.literal('batch-toggle-enabled'),
|
||||
target: z.literal('blocks'),
|
||||
payload: z.object({
|
||||
blockIds: z.array(z.string()),
|
||||
previousStates: z.record(z.boolean()),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
operationId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BatchToggleHandlesSchema = z.object({
|
||||
operation: z.literal('batch-toggle-handles'),
|
||||
target: z.literal('blocks'),
|
||||
payload: z.object({
|
||||
blockIds: z.array(z.string()),
|
||||
previousStates: z.record(z.boolean()),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
operationId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const WorkflowOperationSchema = z.union([
|
||||
BlockOperationSchema,
|
||||
BatchPositionUpdateSchema,
|
||||
BatchAddBlocksSchema,
|
||||
BatchRemoveBlocksSchema,
|
||||
BatchToggleEnabledSchema,
|
||||
BatchToggleHandlesSchema,
|
||||
EdgeOperationSchema,
|
||||
BatchAddEdgesSchema,
|
||||
BatchRemoveEdgesSchema,
|
||||
SubflowOperationSchema,
|
||||
VariableOperationSchema,
|
||||
WorkflowStateOperationSchema,
|
||||
|
||||
@@ -751,7 +751,7 @@ describe('useUndoRedoStore', () => {
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(3)
|
||||
|
||||
const moveEntry = undo(workflowId, userId)
|
||||
expect(moveEntry?.operation.type).toBe('move-block')
|
||||
expect(moveEntry?.operation.type).toBe('batch-move-blocks')
|
||||
|
||||
const parentEntry = undo(workflowId, userId)
|
||||
expect(parentEntry?.operation.type).toBe('update-parent')
|
||||
|
||||
@@ -4,11 +4,11 @@ import { create } from 'zustand'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
import type {
|
||||
BatchAddBlocksOperation,
|
||||
BatchMoveBlocksOperation,
|
||||
BatchRemoveBlocksOperation,
|
||||
MoveBlockOperation,
|
||||
BatchRemoveEdgesOperation,
|
||||
Operation,
|
||||
OperationEntry,
|
||||
RemoveEdgeOperation,
|
||||
UndoRedoState,
|
||||
} from '@/stores/undo-redo/types'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
@@ -92,17 +92,17 @@ function isOperationApplicable(
|
||||
const op = operation as BatchAddBlocksOperation
|
||||
return op.data.blockSnapshots.every((block) => !graph.blocksById[block.id])
|
||||
}
|
||||
case 'move-block': {
|
||||
const op = operation as MoveBlockOperation
|
||||
return Boolean(graph.blocksById[op.data.blockId])
|
||||
case 'batch-move-blocks': {
|
||||
const op = operation as BatchMoveBlocksOperation
|
||||
return op.data.moves.every((move) => Boolean(graph.blocksById[move.blockId]))
|
||||
}
|
||||
case 'update-parent': {
|
||||
const blockId = operation.data.blockId
|
||||
return Boolean(graph.blocksById[blockId])
|
||||
}
|
||||
case 'remove-edge': {
|
||||
const op = operation as RemoveEdgeOperation
|
||||
return Boolean(graph.edgesById[op.data.edgeId])
|
||||
case 'batch-remove-edges': {
|
||||
const op = operation as BatchRemoveEdgesOperation
|
||||
return op.data.edgeSnapshots.every((edge) => Boolean(graph.edgesById[edge.id]))
|
||||
}
|
||||
case 'add-edge': {
|
||||
const edgeId = operation.data.edgeId
|
||||
@@ -198,62 +198,82 @@ export const useUndoRedoStore = create<UndoRedoState>()(
|
||||
}
|
||||
}
|
||||
|
||||
// Coalesce consecutive move-block operations for the same block
|
||||
if (entry.operation.type === 'move-block') {
|
||||
const incoming = entry.operation as MoveBlockOperation
|
||||
// Coalesce consecutive batch-move-blocks operations for overlapping blocks
|
||||
if (entry.operation.type === 'batch-move-blocks') {
|
||||
const incoming = entry.operation as BatchMoveBlocksOperation
|
||||
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')
|
||||
// Skip no-op moves (all moves have same before/after)
|
||||
const allNoOp = incoming.data.moves.every((move) => {
|
||||
const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null)
|
||||
return move.before.x === move.after.x && move.before.y === move.after.y && sameParent
|
||||
})
|
||||
if (allNoOp) {
|
||||
logger.debug('Skipped no-op batch 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
|
||||
if (
|
||||
last &&
|
||||
last.operation.type === 'batch-move-blocks' &&
|
||||
last.inverse.type === 'batch-move-blocks'
|
||||
) {
|
||||
const prev = last.operation as BatchMoveBlocksOperation
|
||||
const prevBlockIds = new Set(prev.data.moves.map((m) => m.blockId))
|
||||
const incomingBlockIds = new Set(incoming.data.moves.map((m) => m.blockId))
|
||||
|
||||
const sameAfter =
|
||||
mergedBefore.x === mergedAfter.x &&
|
||||
mergedBefore.y === mergedAfter.y &&
|
||||
(mergedBefore.parentId ?? null) === (mergedAfter.parentId ?? null)
|
||||
// Check if same set of blocks
|
||||
const sameBlocks =
|
||||
prevBlockIds.size === incomingBlockIds.size &&
|
||||
[...prevBlockIds].every((id) => incomingBlockIds.has(id))
|
||||
|
||||
const newUndoCoalesced: OperationEntry[] = sameAfter
|
||||
if (sameBlocks) {
|
||||
// Merge: keep earliest before, latest after for each block
|
||||
const mergedMoves = incoming.data.moves.map((incomingMove) => {
|
||||
const prevMove = prev.data.moves.find((m) => m.blockId === incomingMove.blockId)!
|
||||
return {
|
||||
blockId: incomingMove.blockId,
|
||||
before: prevMove.before,
|
||||
after: incomingMove.after,
|
||||
}
|
||||
})
|
||||
|
||||
// Check if all moves result in same position (net no-op)
|
||||
const allSameAfter = mergedMoves.every((move) => {
|
||||
const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null)
|
||||
return (
|
||||
move.before.x === move.after.x && move.before.y === move.after.y && sameParent
|
||||
)
|
||||
})
|
||||
|
||||
const newUndoCoalesced: OperationEntry[] = allSameAfter
|
||||
? stack.undo.slice(0, -1)
|
||||
: (() => {
|
||||
const op = entry.operation as MoveBlockOperation
|
||||
const inv = entry.inverse as MoveBlockOperation
|
||||
const op = entry.operation as BatchMoveBlocksOperation
|
||||
const inv = entry.inverse as BatchMoveBlocksOperation
|
||||
const newEntry: OperationEntry = {
|
||||
id: entry.id,
|
||||
createdAt: entry.createdAt,
|
||||
operation: {
|
||||
id: op.id,
|
||||
type: 'move-block',
|
||||
type: 'batch-move-blocks',
|
||||
timestamp: op.timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: {
|
||||
blockId: incoming.data.blockId,
|
||||
before: mergedBefore,
|
||||
after: mergedAfter,
|
||||
},
|
||||
data: { moves: mergedMoves },
|
||||
},
|
||||
inverse: {
|
||||
id: inv.id,
|
||||
type: 'move-block',
|
||||
type: 'batch-move-blocks',
|
||||
timestamp: inv.timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: {
|
||||
blockId: incoming.data.blockId,
|
||||
before: mergedAfter,
|
||||
after: mergedBefore,
|
||||
moves: mergedMoves.map((m) => ({
|
||||
blockId: m.blockId,
|
||||
before: m.after,
|
||||
after: m.before,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -268,10 +288,10 @@ export const useUndoRedoStore = create<UndoRedoState>()(
|
||||
|
||||
set({ stacks: currentStacks })
|
||||
|
||||
logger.debug('Coalesced consecutive move operations', {
|
||||
logger.debug('Coalesced consecutive batch move operations', {
|
||||
workflowId,
|
||||
userId,
|
||||
blockId: incoming.data.blockId,
|
||||
blockCount: mergedMoves.length,
|
||||
undoSize: newUndoCoalesced.length,
|
||||
})
|
||||
return
|
||||
|
||||
@@ -5,12 +5,14 @@ export type OperationType =
|
||||
| 'batch-add-blocks'
|
||||
| 'batch-remove-blocks'
|
||||
| 'add-edge'
|
||||
| 'remove-edge'
|
||||
| 'batch-remove-edges'
|
||||
| 'add-subflow'
|
||||
| 'remove-subflow'
|
||||
| 'move-block'
|
||||
| 'batch-move-blocks'
|
||||
| 'move-subflow'
|
||||
| 'update-parent'
|
||||
| 'batch-toggle-enabled'
|
||||
| 'batch-toggle-handles'
|
||||
| 'apply-diff'
|
||||
| 'accept-diff'
|
||||
| 'reject-diff'
|
||||
@@ -48,11 +50,10 @@ export interface AddEdgeOperation extends BaseOperation {
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoveEdgeOperation extends BaseOperation {
|
||||
type: 'remove-edge'
|
||||
export interface BatchRemoveEdgesOperation extends BaseOperation {
|
||||
type: 'batch-remove-edges'
|
||||
data: {
|
||||
edgeId: string
|
||||
edgeSnapshot: Edge | null
|
||||
edgeSnapshots: Edge[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,20 +72,14 @@ export interface RemoveSubflowOperation extends BaseOperation {
|
||||
}
|
||||
}
|
||||
|
||||
export interface MoveBlockOperation extends BaseOperation {
|
||||
type: 'move-block'
|
||||
export interface BatchMoveBlocksOperation extends BaseOperation {
|
||||
type: 'batch-move-blocks'
|
||||
data: {
|
||||
blockId: string
|
||||
before: {
|
||||
x: number
|
||||
y: number
|
||||
parentId?: string
|
||||
}
|
||||
after: {
|
||||
x: number
|
||||
y: number
|
||||
parentId?: string
|
||||
}
|
||||
moves: Array<{
|
||||
blockId: string
|
||||
before: { x: number; y: number; parentId?: string }
|
||||
after: { x: number; y: number; parentId?: string }
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +110,22 @@ export interface UpdateParentOperation extends BaseOperation {
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchToggleEnabledOperation extends BaseOperation {
|
||||
type: 'batch-toggle-enabled'
|
||||
data: {
|
||||
blockIds: string[]
|
||||
previousStates: Record<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchToggleHandlesOperation extends BaseOperation {
|
||||
type: 'batch-toggle-handles'
|
||||
data: {
|
||||
blockIds: string[]
|
||||
previousStates: Record<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApplyDiffOperation extends BaseOperation {
|
||||
type: 'apply-diff'
|
||||
data: {
|
||||
@@ -148,12 +159,14 @@ export type Operation =
|
||||
| BatchAddBlocksOperation
|
||||
| BatchRemoveBlocksOperation
|
||||
| AddEdgeOperation
|
||||
| RemoveEdgeOperation
|
||||
| BatchRemoveEdgesOperation
|
||||
| AddSubflowOperation
|
||||
| RemoveSubflowOperation
|
||||
| MoveBlockOperation
|
||||
| BatchMoveBlocksOperation
|
||||
| MoveSubflowOperation
|
||||
| UpdateParentOperation
|
||||
| BatchToggleEnabledOperation
|
||||
| BatchToggleHandlesOperation
|
||||
| ApplyDiffOperation
|
||||
| AcceptDiffOperation
|
||||
| RejectDiffOperation
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
BatchAddBlocksOperation,
|
||||
BatchMoveBlocksOperation,
|
||||
BatchRemoveBlocksOperation,
|
||||
BatchRemoveEdgesOperation,
|
||||
Operation,
|
||||
OperationEntry,
|
||||
} from '@/stores/undo-redo/types'
|
||||
@@ -43,23 +45,27 @@ export function createInverseOperation(operation: Operation): Operation {
|
||||
}
|
||||
|
||||
case 'add-edge':
|
||||
// Note: add-edge only stores edgeId. The full edge snapshot is stored
|
||||
// in the inverse operation when recording. This function can't create
|
||||
// a complete inverse without the snapshot.
|
||||
return {
|
||||
...operation,
|
||||
type: 'remove-edge',
|
||||
type: 'batch-remove-edges',
|
||||
data: {
|
||||
edgeId: operation.data.edgeId,
|
||||
edgeSnapshot: null,
|
||||
edgeSnapshots: [],
|
||||
},
|
||||
}
|
||||
} as BatchRemoveEdgesOperation
|
||||
|
||||
case 'remove-edge':
|
||||
case 'batch-remove-edges': {
|
||||
const op = operation as BatchRemoveEdgesOperation
|
||||
return {
|
||||
...operation,
|
||||
type: 'add-edge',
|
||||
type: 'batch-remove-edges',
|
||||
data: {
|
||||
edgeId: operation.data.edgeId,
|
||||
edgeSnapshots: op.data.edgeSnapshots,
|
||||
},
|
||||
}
|
||||
} as BatchRemoveEdgesOperation
|
||||
}
|
||||
|
||||
case 'add-subflow':
|
||||
return {
|
||||
@@ -80,15 +86,20 @@ export function createInverseOperation(operation: Operation): Operation {
|
||||
},
|
||||
}
|
||||
|
||||
case 'move-block':
|
||||
case 'batch-move-blocks': {
|
||||
const op = operation as BatchMoveBlocksOperation
|
||||
return {
|
||||
...operation,
|
||||
type: 'batch-move-blocks',
|
||||
data: {
|
||||
blockId: operation.data.blockId,
|
||||
before: operation.data.after,
|
||||
after: operation.data.before,
|
||||
moves: op.data.moves.map((m) => ({
|
||||
blockId: m.blockId,
|
||||
before: m.after,
|
||||
after: m.before,
|
||||
})),
|
||||
},
|
||||
}
|
||||
} as BatchMoveBlocksOperation
|
||||
}
|
||||
|
||||
case 'move-subflow':
|
||||
return {
|
||||
@@ -145,6 +156,24 @@ export function createInverseOperation(operation: Operation): Operation {
|
||||
},
|
||||
}
|
||||
|
||||
case 'batch-toggle-enabled':
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
blockIds: operation.data.blockIds,
|
||||
previousStates: operation.data.previousStates,
|
||||
},
|
||||
}
|
||||
|
||||
case 'batch-toggle-handles':
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
blockIds: operation.data.blockIds,
|
||||
previousStates: operation.data.previousStates,
|
||||
},
|
||||
}
|
||||
|
||||
default: {
|
||||
const exhaustiveCheck: never = operation
|
||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
|
||||
@@ -189,12 +218,14 @@ export function operationToCollaborativePayload(operation: Operation): {
|
||||
payload: { id: operation.data.edgeId },
|
||||
}
|
||||
|
||||
case 'remove-edge':
|
||||
case 'batch-remove-edges': {
|
||||
const op = operation as BatchRemoveEdgesOperation
|
||||
return {
|
||||
operation: 'remove',
|
||||
target: 'edge',
|
||||
payload: { id: operation.data.edgeId },
|
||||
operation: 'batch-remove-edges',
|
||||
target: 'edges',
|
||||
payload: { ids: op.data.edgeSnapshots.map((e) => e.id) },
|
||||
}
|
||||
}
|
||||
|
||||
case 'add-subflow':
|
||||
return {
|
||||
@@ -210,17 +241,21 @@ export function operationToCollaborativePayload(operation: Operation): {
|
||||
payload: { id: operation.data.subflowId },
|
||||
}
|
||||
|
||||
case 'move-block':
|
||||
case 'batch-move-blocks': {
|
||||
const op = operation as BatchMoveBlocksOperation
|
||||
return {
|
||||
operation: 'update-position',
|
||||
target: 'block',
|
||||
operation: 'batch-update-positions',
|
||||
target: 'blocks',
|
||||
payload: {
|
||||
id: operation.data.blockId,
|
||||
x: operation.data.after.x,
|
||||
y: operation.data.after.y,
|
||||
parentId: operation.data.after.parentId,
|
||||
moves: op.data.moves.map((m) => ({
|
||||
id: m.blockId,
|
||||
x: m.after.x,
|
||||
y: m.after.y,
|
||||
parentId: m.after.parentId,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'move-subflow':
|
||||
return {
|
||||
@@ -272,6 +307,26 @@ export function operationToCollaborativePayload(operation: Operation): {
|
||||
},
|
||||
}
|
||||
|
||||
case 'batch-toggle-enabled':
|
||||
return {
|
||||
operation: 'batch-toggle-enabled',
|
||||
target: 'blocks',
|
||||
payload: {
|
||||
blockIds: operation.data.blockIds,
|
||||
previousStates: operation.data.previousStates,
|
||||
},
|
||||
}
|
||||
|
||||
case 'batch-toggle-handles':
|
||||
return {
|
||||
operation: 'batch-toggle-handles',
|
||||
target: 'blocks',
|
||||
payload: {
|
||||
blockIds: operation.data.blockIds,
|
||||
previousStates: operation.data.previousStates,
|
||||
},
|
||||
}
|
||||
|
||||
default: {
|
||||
const exhaustiveCheck: never = operation
|
||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
|
||||
|
||||
@@ -123,6 +123,7 @@ export {
|
||||
type AddEdgeOperation,
|
||||
type BaseOperation,
|
||||
type BatchAddBlocksOperation,
|
||||
type BatchMoveBlocksOperation,
|
||||
type BatchRemoveBlocksOperation,
|
||||
createAddBlockEntry,
|
||||
createAddEdgeEntry,
|
||||
@@ -130,7 +131,6 @@ export {
|
||||
createRemoveBlockEntry,
|
||||
createRemoveEdgeEntry,
|
||||
createUpdateParentEntry,
|
||||
type MoveBlockOperation,
|
||||
type Operation,
|
||||
type OperationEntry,
|
||||
type OperationType,
|
||||
|
||||
@@ -10,7 +10,7 @@ export type OperationType =
|
||||
| 'batch-remove-blocks'
|
||||
| 'add-edge'
|
||||
| 'remove-edge'
|
||||
| 'move-block'
|
||||
| 'batch-move-blocks'
|
||||
| 'update-parent'
|
||||
|
||||
/**
|
||||
@@ -25,14 +25,16 @@ export interface BaseOperation {
|
||||
}
|
||||
|
||||
/**
|
||||
* Move block operation data.
|
||||
* Batch move blocks operation data.
|
||||
*/
|
||||
export interface MoveBlockOperation extends BaseOperation {
|
||||
type: 'move-block'
|
||||
export interface BatchMoveBlocksOperation extends BaseOperation {
|
||||
type: 'batch-move-blocks'
|
||||
data: {
|
||||
blockId: string
|
||||
before: { x: number; y: number; parentId?: string }
|
||||
after: { x: number; y: number; parentId?: string }
|
||||
moves: Array<{
|
||||
blockId: string
|
||||
before: { x: number; y: number; parentId?: string }
|
||||
after: { x: number; y: number; parentId?: string }
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +97,7 @@ export type Operation =
|
||||
| BatchRemoveBlocksOperation
|
||||
| AddEdgeOperation
|
||||
| RemoveEdgeOperation
|
||||
| MoveBlockOperation
|
||||
| BatchMoveBlocksOperation
|
||||
| UpdateParentOperation
|
||||
|
||||
/**
|
||||
@@ -275,7 +277,7 @@ interface MoveBlockOptions extends OperationEntryOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock move-block operation entry.
|
||||
* Creates a mock batch-move-blocks operation entry for a single block.
|
||||
*/
|
||||
export function createMoveBlockEntry(blockId: string, options: MoveBlockOptions = {}): any {
|
||||
const {
|
||||
@@ -293,19 +295,19 @@ export function createMoveBlockEntry(blockId: string, options: MoveBlockOptions
|
||||
createdAt,
|
||||
operation: {
|
||||
id: nanoid(8),
|
||||
type: 'move-block',
|
||||
type: 'batch-move-blocks',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId, before, after },
|
||||
data: { moves: [{ blockId, before, after }] },
|
||||
},
|
||||
inverse: {
|
||||
id: nanoid(8),
|
||||
type: 'move-block',
|
||||
type: 'batch-move-blocks',
|
||||
timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { blockId, before: after, after: before },
|
||||
data: { moves: [{ blockId, before: after, after: before }] },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user