feat(workflow): added context menu for block, pane, and multi-block selection on canvas (#2656)

* feat(workflow): added context menu for block, pane, and multi-block selection on canvas

* added more

* ack PR comments
This commit is contained in:
Waleed
2025-12-31 14:42:33 -08:00
committed by GitHub
parent 4301342ffb
commit 0c8d05fc98
11 changed files with 872 additions and 23 deletions

View File

@@ -1049,7 +1049,7 @@ export function Chat() {
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
title='Attach file'
className={cn(
'!bg-transparent cursor-pointer rounded-[6px] p-[0px]',
'!bg-transparent !border-0 cursor-pointer rounded-[6px] p-[0px]',
(!activeWorkflowId || isExecuting || chatFiles.length >= 15) &&
'cursor-not-allowed opacity-50'
)}

View File

@@ -0,0 +1,179 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import type { BlockContextMenuProps } from './types'
/**
* Context menu for workflow block(s).
* Displays block-specific actions in a popover at right-click position.
* Supports multi-selection - actions apply to all selected blocks.
*/
export function BlockContextMenu({
isOpen,
position,
menuRef,
onClose,
selectedBlocks,
onCopy,
onPaste,
onDuplicate,
onDelete,
onToggleEnabled,
onToggleHandles,
onRemoveFromSubflow,
onOpenEditor,
onRename,
hasClipboard = false,
showRemoveFromSubflow = false,
disableEdit = false,
}: BlockContextMenuProps) {
const isSingleBlock = selectedBlocks.length === 1
const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled)
const hasStarterBlock = selectedBlocks.some(
(b) => b.type === 'starter' || b.type === 'start_trigger'
)
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
const isSubflow =
isSingleBlock && (selectedBlocks[0]?.type === 'loop' || selectedBlocks[0]?.type === 'parallel')
const canRemoveFromSubflow = showRemoveFromSubflow && !hasStarterBlock
const getToggleEnabledLabel = () => {
if (allEnabled) return 'Disable'
if (allDisabled) return 'Enable'
return 'Toggle Enabled'
}
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Copy */}
<PopoverItem
className='group'
onClick={() => {
onCopy()
onClose()
}}
>
<span>Copy</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>C</span>
</PopoverItem>
{/* Paste */}
<PopoverItem
className='group'
disabled={disableEdit || !hasClipboard}
onClick={() => {
onPaste()
onClose()
}}
>
<span>Paste</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>V</span>
</PopoverItem>
{/* Duplicate - hide for starter blocks */}
{!hasStarterBlock && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onDuplicate()
onClose()
}}
>
Duplicate
</PopoverItem>
)}
{/* Delete */}
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onDelete()
onClose()
}}
>
<span>Delete</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'></span>
</PopoverItem>
{/* Enable/Disable - hide if all blocks are notes */}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onToggleEnabled()
onClose()
}}
>
{getToggleEnabledLabel()}
</PopoverItem>
)}
{/* Flip Handles - hide if all blocks are notes */}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onToggleHandles()
onClose()
}}
>
Flip Handles
</PopoverItem>
)}
{/* Remove from Subflow - only show when applicable */}
{canRemoveFromSubflow && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onRemoveFromSubflow()
onClose()
}}
>
Remove from Subflow
</PopoverItem>
)}
{/* Rename - only for single block, not subflows */}
{isSingleBlock && !isSubflow && (
<PopoverItem
disabled={disableEdit}
onClick={() => {
onRename()
onClose()
}}
>
Rename
</PopoverItem>
)}
{/* Open Editor - only for single block */}
{isSingleBlock && (
<PopoverItem
onClick={() => {
onOpenEditor()
onClose()
}}
>
Open Editor
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,8 @@
export { BlockContextMenu } from './block-context-menu'
export { PaneContextMenu } from './pane-context-menu'
export type {
BlockContextMenuProps,
ContextMenuBlockInfo,
ContextMenuPosition,
PaneContextMenuProps,
} from './types'

View File

@@ -0,0 +1,150 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
import type { PaneContextMenuProps } from './types'
/**
* Context menu for workflow canvas pane.
* Displays canvas-level actions when right-clicking empty space.
*/
export function PaneContextMenu({
isOpen,
position,
menuRef,
onClose,
onUndo,
onRedo,
onPaste,
onAddBlock,
onAutoLayout,
onOpenLogs,
onOpenVariables,
onOpenChat,
onInvite,
hasClipboard = false,
disableEdit = false,
disableAdmin = false,
}: PaneContextMenuProps) {
return (
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Undo */}
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onUndo()
onClose()
}}
>
<span>Undo</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>Z</span>
</PopoverItem>
{/* Redo */}
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onRedo()
onClose()
}}
>
<span>Redo</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>Z</span>
</PopoverItem>
{/* Paste */}
<PopoverItem
className='group'
disabled={disableEdit || !hasClipboard}
onClick={() => {
onPaste()
onClose()
}}
>
<span>Paste</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>V</span>
</PopoverItem>
{/* Add Block */}
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onAddBlock()
onClose()
}}
>
<span>Add Block</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>K</span>
</PopoverItem>
{/* Auto-layout */}
<PopoverItem
className='group'
disabled={disableEdit}
onClick={() => {
onAutoLayout()
onClose()
}}
>
<span>Auto-layout</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>L</span>
</PopoverItem>
{/* Open Logs */}
<PopoverItem
className='group'
onClick={() => {
onOpenLogs()
onClose()
}}
>
<span>Open Logs</span>
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>L</span>
</PopoverItem>
{/* Open Variables */}
<PopoverItem
onClick={() => {
onOpenVariables()
onClose()
}}
>
Variables
</PopoverItem>
{/* Open Chat */}
<PopoverItem
onClick={() => {
onOpenChat()
onClose()
}}
>
Open Chat
</PopoverItem>
{/* Invite to Workspace - admin only */}
<PopoverItem
disabled={disableAdmin}
onClick={() => {
onInvite()
onClose()
}}
>
Invite to Workspace
</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,89 @@
import type { RefObject } from 'react'
/**
* Position for context menu placement
*/
export interface ContextMenuPosition {
x: number
y: number
}
/**
* Block information passed to context menu for action handling
*/
export interface ContextMenuBlockInfo {
/** Block ID */
id: string
/** Block type (e.g., 'agent', 'function', 'loop') */
type: string
/** Whether block is enabled */
enabled: boolean
/** Whether block uses horizontal handles */
horizontalHandles: boolean
/** Parent subflow ID if nested in loop/parallel */
parentId?: string
/** Parent type ('loop' | 'parallel') if nested */
parentType?: string
}
/**
* Props for BlockContextMenu component
*/
export interface BlockContextMenuProps {
/** Whether the context menu is open */
isOpen: boolean
/** Position of the context menu */
position: ContextMenuPosition
/** Ref for the menu element (for click-outside detection) */
menuRef: RefObject<HTMLDivElement | null>
/** Callback when menu should close */
onClose: () => void
/** Selected block(s) info */
selectedBlocks: ContextMenuBlockInfo[]
/** Callbacks for menu actions */
onCopy: () => void
onPaste: () => void
onDuplicate: () => void
onDelete: () => void
onToggleEnabled: () => void
onToggleHandles: () => void
onRemoveFromSubflow: () => void
onOpenEditor: () => void
onRename: () => void
/** Whether clipboard has content for pasting */
hasClipboard?: boolean
/** Whether remove from subflow option should be shown */
showRemoveFromSubflow?: boolean
/** Whether edit actions are disabled (no permission) */
disableEdit?: boolean
}
/**
* Props for PaneContextMenu component
*/
export interface PaneContextMenuProps {
/** Whether the context menu is open */
isOpen: boolean
/** Position of the context menu */
position: ContextMenuPosition
/** Ref for the menu element */
menuRef: RefObject<HTMLDivElement | null>
/** Callback when menu should close */
onClose: () => void
/** Callbacks for menu actions */
onUndo: () => void
onRedo: () => void
onPaste: () => void
onAddBlock: () => void
onAutoLayout: () => void
onOpenLogs: () => void
onOpenVariables: () => void
onOpenChat: () => void
onInvite: () => void
/** Whether clipboard has content for pasting */
hasClipboard?: boolean
/** Whether edit actions are disabled (no permission) */
disableEdit?: boolean
/** Whether admin actions are disabled (no admin permission) */
disableAdmin?: boolean
}

View File

@@ -42,7 +42,13 @@ const IconComponent = ({ icon: Icon, className }: { icon: any; className?: strin
* @returns Editor panel content
*/
export function Editor() {
const { currentBlockId, connectionsHeight, toggleConnectionsCollapsed } = usePanelEditorStore()
const {
currentBlockId,
connectionsHeight,
toggleConnectionsCollapsed,
shouldFocusRename,
setShouldFocusRename,
} = usePanelEditorStore()
const currentWorkflow = useCurrentWorkflow()
const currentBlock = currentBlockId ? currentWorkflow.getBlockById(currentBlockId) : null
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
@@ -158,6 +164,14 @@ export function Editor() {
}
}, [isRenaming])
// Trigger rename mode when signaled from context menu
useEffect(() => {
if (shouldFocusRename && currentBlock && !isSubflow) {
handleStartRename()
setShouldFocusRename(false)
}
}, [shouldFocusRename, currentBlock, isSubflow, handleStartRename, setShouldFocusRename])
/**
* Handles opening documentation link in a new secure tab.
*/

View File

@@ -0,0 +1,161 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Node } from 'reactflow'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { ContextMenuBlockInfo, ContextMenuPosition } from '../components/context-menu/types'
type MenuType = 'block' | 'pane' | null
interface UseCanvasContextMenuProps {
/** Current blocks from workflow store */
blocks: Record<string, BlockState>
/** Function to get nodes from ReactFlow */
getNodes: () => Node[]
}
/**
* Hook for managing workflow canvas context menus.
*
* Handles:
* - Right-click event handling for blocks and pane
* - Menu open/close state for both menu types
* - Click-outside detection to close menus
* - Selected block info extraction for multi-selection support
*/
export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuProps) {
const [activeMenu, setActiveMenu] = useState<MenuType>(null)
const [position, setPosition] = useState<ContextMenuPosition>({ x: 0, y: 0 })
const [selectedBlocks, setSelectedBlocks] = useState<ContextMenuBlockInfo[]>([])
const menuRef = useRef<HTMLDivElement>(null)
/** Converts nodes to block info for context menu */
const nodesToBlockInfos = useCallback(
(nodes: Node[]): ContextMenuBlockInfo[] =>
nodes.map((n) => {
const block = blocks[n.id]
const parentId = block?.data?.parentId
const parentType = parentId ? blocks[parentId]?.type : undefined
return {
id: n.id,
type: block?.type || '',
enabled: block?.enabled ?? true,
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType,
}
}),
[blocks]
)
/**
* Handle right-click on a node (block)
*/
const handleNodeContextMenu = useCallback(
(event: React.MouseEvent, node: Node) => {
event.preventDefault()
event.stopPropagation()
const selectedNodes = getNodes().filter((n) => n.selected)
const nodesToUse = selectedNodes.some((n) => n.id === node.id) ? selectedNodes : [node]
setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks(nodesToBlockInfos(nodesToUse))
setActiveMenu('block')
},
[getNodes, nodesToBlockInfos]
)
/**
* Handle right-click on the pane (empty canvas area)
*/
const handlePaneContextMenu = useCallback((event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks([])
setActiveMenu('pane')
}, [])
/**
* Handle right-click on a selection (multiple selected nodes)
*/
const handleSelectionContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const selectedNodes = getNodes().filter((n) => n.selected)
setPosition({ x: event.clientX, y: event.clientY })
setSelectedBlocks(nodesToBlockInfos(selectedNodes))
setActiveMenu('block')
},
[getNodes, nodesToBlockInfos]
)
/**
* Close the active context menu
*/
const closeMenu = useCallback(() => {
setActiveMenu(null)
}, [])
/**
* Handle clicks outside the menu to close it
*/
useEffect(() => {
if (!activeMenu) return
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as globalThis.Node)) {
closeMenu()
}
}
const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside)
}, 0)
return () => {
clearTimeout(timeoutId)
document.removeEventListener('click', handleClickOutside)
}
}, [activeMenu, closeMenu])
/**
* Close menu on scroll or zoom to prevent menu from being positioned incorrectly
*/
useEffect(() => {
if (!activeMenu) return
const handleScroll = () => closeMenu()
window.addEventListener('wheel', handleScroll, { passive: true })
return () => {
window.removeEventListener('wheel', handleScroll)
}
}, [activeMenu, closeMenu])
return {
/** Whether the block context menu is open */
isBlockMenuOpen: activeMenu === 'block',
/** Whether the pane context menu is open */
isPaneMenuOpen: activeMenu === 'pane',
/** Position for the context menu */
position,
/** Ref for the menu element */
menuRef,
/** Selected blocks info for multi-selection actions */
selectedBlocks,
/** Handler for ReactFlow onNodeContextMenu */
handleNodeContextMenu,
/** Handler for ReactFlow onPaneContextMenu */
handlePaneContextMenu,
/** Handler for ReactFlow onSelectionContextMenu */
handleSelectionContextMenu,
/** Close the active context menu */
closeMenu,
}
}

View File

@@ -2,7 +2,7 @@ import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
return (
<main className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
<main className='flex h-full flex-1 flex-col overflow-hidden'>
<ErrorBoundary>{children}</ErrorBoundary>
</main>
)

View File

@@ -30,6 +30,10 @@ import {
SubflowNodeComponent,
Terminal,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import {
BlockContextMenu,
PaneContextMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu'
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
@@ -42,6 +46,7 @@ import {
useCurrentWorkflow,
useNodeUtilities,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu'
import {
clampPositionToContainer,
estimateBlockDimensions,
@@ -52,12 +57,15 @@ import { isAnnotationOnlyBlock } from '@/executor/constants'
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useChatStore } from '@/stores/chat/store'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
import { useExecutionStore } from '@/stores/execution/store'
import { useNotificationStore } from '@/stores/notifications/store'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { usePanelEditorStore } from '@/stores/panel/editor/store'
import { useSearchModalStore } from '@/stores/search-modal/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { useVariablesStore } from '@/stores/variables/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils'
@@ -93,7 +101,6 @@ function calculatePasteOffset(
const clipboardBlocks = Object.values(clipboard.blocks)
if (clipboardBlocks.length === 0) return DEFAULT_PASTE_OFFSET
// Calculate bounding box using proper dimensions
const minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
const maxX = Math.max(
...clipboardBlocks.map((b) => {
@@ -154,9 +161,7 @@ const reactFlowStyles = [
'[&_.react-flow__edge-labels]:!z-[60]',
'[&_.react-flow__pane]:!bg-transparent',
'[&_.react-flow__renderer]:!bg-transparent',
'dark:[&_.react-flow__pane]:!bg-[var(--bg)]',
'dark:[&_.react-flow__renderer]:!bg-[var(--bg)]',
'dark:[&_.react-flow__background]:hidden',
'[&_.react-flow__background]:hidden',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6 } as const
const reactFlowProOptions = { hideAttribution: true } as const
@@ -195,7 +200,7 @@ const WorkflowContent = React.memo(() => {
const params = useParams()
const router = useRouter()
const { screenToFlowPosition, getNodes, fitView, getIntersectingNodes } = useReactFlow()
const { screenToFlowPosition, getNodes, setNodes, fitView, getIntersectingNodes } = useReactFlow()
const { emitCursorUpdate } = useSocket()
const workspaceId = params.workspaceId as string
@@ -237,10 +242,8 @@ const WorkflowContent = React.memo(() => {
const copilotCleanup = useCopilotStore((state) => state.cleanup)
// Training modal state
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
// Snap to grid settings
const snapToGridSize = useGeneralStore((state) => state.snapToGridSize)
const snapToGrid = snapToGridSize > 0
const snapGrid: [number, number] = useMemo(
@@ -248,7 +251,6 @@ const WorkflowContent = React.memo(() => {
[snapToGridSize]
)
// Handle copilot stream cleanup on page unload and component unmount
useStreamCleanup(copilotCleanup)
const { blocks, edges, isDiffMode, lastSaved } = currentWorkflow
@@ -273,7 +275,6 @@ const WorkflowContent = React.memo(() => {
getBlockDimensions,
} = useNodeUtilities(blocks)
/** Triggers immediate subflow resize without delays. */
const resizeLoopNodesWrapper = useCallback(() => {
return resizeLoopNodes(updateNodeDimensions)
}, [resizeLoopNodes, updateNodeDimensions])
@@ -407,6 +408,8 @@ const WorkflowContent = React.memo(() => {
collaborativeUpdateParentId: updateParentId,
collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks,
collaborativeToggleBlockEnabled,
collaborativeToggleBlockHandles,
undo,
redo,
} = useCollaborativeWorkflow()
@@ -572,6 +575,186 @@ const WorkflowContent = React.memo(() => {
return () => clearTimeout(debounceTimer)
}, [handleAutoLayout])
const {
isBlockMenuOpen,
isPaneMenuOpen,
position: contextMenuPosition,
menuRef: contextMenuRef,
selectedBlocks: contextMenuBlocks,
handleNodeContextMenu,
handlePaneContextMenu,
handleSelectionContextMenu,
closeMenu: closeContextMenu,
} = useCanvasContextMenu({ blocks, getNodes })
const handleContextCopy = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id)
copyBlocks(blockIds)
}, [contextMenuBlocks, copyBlocks])
const handleContextPaste = useCallback(() => {
if (!hasClipboard()) return
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
const pasteData = preparePasteData(pasteOffset)
if (!pasteData) return
const {
blocks: pastedBlocks,
edges: pastedEdges,
loops: pastedLoops,
parallels: pastedParallels,
subBlockValues: pastedSubBlockValues,
} = pasteData
const pastedBlocksArray = Object.values(pastedBlocks)
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
}
}
}
collaborativeBatchAddBlocks(
pastedBlocksArray,
pastedEdges,
pastedLoops,
pastedParallels,
pastedSubBlockValues
)
}, [
hasClipboard,
clipboard,
screenToFlowPosition,
preparePasteData,
blocks,
activeWorkflowId,
addNotification,
collaborativeBatchAddBlocks,
])
const handleContextDuplicate = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id)
copyBlocks(blockIds)
const pasteData = preparePasteData(DEFAULT_PASTE_OFFSET)
if (!pasteData) return
const {
blocks: pastedBlocks,
edges: pastedEdges,
loops: pastedLoops,
parallels: pastedParallels,
subBlockValues: pastedSubBlockValues,
} = pasteData
const pastedBlocksArray = Object.values(pastedBlocks)
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
}
}
}
collaborativeBatchAddBlocks(
pastedBlocksArray,
pastedEdges,
pastedLoops,
pastedParallels,
pastedSubBlockValues
)
}, [
contextMenuBlocks,
copyBlocks,
preparePasteData,
blocks,
activeWorkflowId,
addNotification,
collaborativeBatchAddBlocks,
])
const handleContextDelete = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id)
collaborativeBatchRemoveBlocks(blockIds)
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks])
const handleContextToggleEnabled = useCallback(() => {
contextMenuBlocks.forEach((block) => {
collaborativeToggleBlockEnabled(block.id)
})
}, [contextMenuBlocks, collaborativeToggleBlockEnabled])
const handleContextToggleHandles = useCallback(() => {
contextMenuBlocks.forEach((block) => {
collaborativeToggleBlockHandles(block.id)
})
}, [contextMenuBlocks, collaborativeToggleBlockHandles])
const handleContextRemoveFromSubflow = useCallback(() => {
contextMenuBlocks.forEach((block) => {
if (block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockId: block.id } })
)
}
})
}, [contextMenuBlocks])
const handleContextOpenEditor = useCallback(() => {
if (contextMenuBlocks.length === 1) {
usePanelEditorStore.getState().setCurrentBlockId(contextMenuBlocks[0].id)
}
}, [contextMenuBlocks])
const handleContextRename = useCallback(() => {
if (contextMenuBlocks.length === 1) {
usePanelEditorStore.getState().setCurrentBlockId(contextMenuBlocks[0].id)
usePanelEditorStore.getState().setShouldFocusRename(true)
}
}, [contextMenuBlocks])
const handleContextAddBlock = useCallback(() => {
useSearchModalStore.getState().open()
}, [])
const handleContextOpenLogs = useCallback(() => {
router.push(`/workspace/${workspaceId}/logs?workflowIds=${workflowIdParam}`)
}, [router, workspaceId, workflowIdParam])
const handleContextOpenVariables = useCallback(() => {
useVariablesStore.getState().setIsOpen(true)
}, [])
const handleContextOpenChat = useCallback(() => {
useChatStore.getState().setIsChatOpen(true)
}, [])
const handleContextInvite = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-invite-modal'))
}, [])
useEffect(() => {
let cleanup: (() => void) | null = null
@@ -616,7 +799,6 @@ const WorkflowContent = React.memo(() => {
if (effectivePermissions.canEdit && hasClipboard()) {
event.preventDefault()
// Calculate offset to paste blocks at viewport center
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
const pasteData = preparePasteData(pasteOffset)
@@ -1755,18 +1937,24 @@ const WorkflowContent = React.memo(() => {
}
}, [])
// Sync derived nodes to display nodes when structure changes
useEffect(() => {
if (isShiftPressed) {
document.body.style.userSelect = 'none'
} else {
document.body.style.userSelect = ''
}
return () => {
document.body.style.userSelect = ''
}
}, [isShiftPressed])
useEffect(() => {
setDisplayNodes(derivedNodes)
}, [derivedNodes])
/** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */
const onNodesChange = useCallback((changes: NodeChange[]) => {
// Apply position changes to local state for smooth rendering
setDisplayNodes((nds) => applyNodeChanges(changes, nds))
// Don't sync to store during drag - that's handled in onNodeDragStop
// Only sync non-position changes (like selection) to store if needed
}, [])
/**
@@ -2574,8 +2762,8 @@ const WorkflowContent = React.memo(() => {
])
return (
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
<div className='relative h-full w-full flex-1 bg-[var(--bg)]'>
<div className='flex h-full w-full flex-col overflow-hidden'>
<div className='relative h-full w-full flex-1'>
{/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
<div
className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
@@ -2624,8 +2812,9 @@ const WorkflowContent = React.memo(() => {
connectionLineType={ConnectionLineType.SmoothStep}
onPaneClick={onPaneClick}
onEdgeClick={onEdgeClick}
onPaneContextMenu={(e) => e.preventDefault()}
onNodeContextMenu={(e) => e.preventDefault()}
onPaneContextMenu={handlePaneContextMenu}
onNodeContextMenu={handleNodeContextMenu}
onSelectionContextMenu={handleSelectionContextMenu}
onPointerMove={handleCanvasPointerMove}
onPointerLeave={handleCanvasPointerLeave}
elementsSelectable={true}
@@ -2640,7 +2829,7 @@ const WorkflowContent = React.memo(() => {
noWheelClassName='allow-scroll'
edgesFocusable={true}
edgesUpdatable={effectivePermissions.canEdit}
className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`}
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`}
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
onSelectionDragStop={effectivePermissions.canEdit ? onSelectionDragStop : undefined}
@@ -2662,6 +2851,48 @@ const WorkflowContent = React.memo(() => {
</Suspense>
<DiffControls />
{/* Context Menus */}
<BlockContextMenu
isOpen={isBlockMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
onClose={closeContextMenu}
selectedBlocks={contextMenuBlocks}
onCopy={handleContextCopy}
onPaste={handleContextPaste}
onDuplicate={handleContextDuplicate}
onDelete={handleContextDelete}
onToggleEnabled={handleContextToggleEnabled}
onToggleHandles={handleContextToggleHandles}
onRemoveFromSubflow={handleContextRemoveFromSubflow}
onOpenEditor={handleContextOpenEditor}
onRename={handleContextRename}
hasClipboard={hasClipboard()}
showRemoveFromSubflow={contextMenuBlocks.some(
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
)}
disableEdit={!effectivePermissions.canEdit}
/>
<PaneContextMenu
isOpen={isPaneMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
onClose={closeContextMenu}
onUndo={undo}
onRedo={redo}
onPaste={handleContextPaste}
onAddBlock={handleContextAddBlock}
onAutoLayout={handleAutoLayout}
onOpenLogs={handleContextOpenLogs}
onOpenVariables={handleContextOpenVariables}
onOpenChat={handleContextOpenChat}
onInvite={handleContextInvite}
hasClipboard={hasClipboard()}
disableEdit={!effectivePermissions.canEdit}
disableAdmin={!effectivePermissions.canAdmin}
/>
</>
)}

View File

@@ -151,6 +151,13 @@ export function WorkspaceHeader({
setIsMounted(true)
}, [])
// Listen for open-invite-modal event from context menu
useEffect(() => {
const handleOpenInvite = () => setIsInviteModalOpen(true)
window.addEventListener('open-invite-modal', handleOpenInvite)
return () => window.removeEventListener('open-invite-modal', handleOpenInvite)
}, [])
/**
* Focus the inline list rename input when it becomes active
*/

View File

@@ -22,6 +22,10 @@ interface PanelEditorState {
setConnectionsHeight: (height: number) => void
/** Toggle connections between collapsed (min height) and expanded (default height) */
toggleConnectionsCollapsed: () => void
/** Flag to signal the editor to focus the rename input */
shouldFocusRename: boolean
/** Sets the shouldFocusRename flag */
setShouldFocusRename: (value: boolean) => void
}
/**
@@ -33,6 +37,8 @@ export const usePanelEditorStore = create<PanelEditorState>()(
(set, get) => ({
currentBlockId: null,
connectionsHeight: EDITOR_CONNECTIONS_HEIGHT.DEFAULT,
shouldFocusRename: false,
setShouldFocusRename: (value) => set({ shouldFocusRename: value }),
setCurrentBlockId: (blockId) => {
set({ currentBlockId: blockId })
@@ -79,6 +85,10 @@ export const usePanelEditorStore = create<PanelEditorState>()(
}),
{
name: 'panel-editor-state',
partialize: (state) => ({
currentBlockId: state.currentBlockId,
connectionsHeight: state.connectionsHeight,
}),
onRehydrateStorage: () => (state) => {
// Sync CSS variables with stored state after rehydration
if (state && typeof window !== 'undefined') {