mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-06 21:54:01 -05:00
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:
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { BlockContextMenu } from './block-context-menu'
|
||||
export { PaneContextMenu } from './pane-context-menu'
|
||||
export type {
|
||||
BlockContextMenuProps,
|
||||
ContextMenuBlockInfo,
|
||||
ContextMenuPosition,
|
||||
PaneContextMenuProps,
|
||||
} from './types'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user