mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -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()}
|
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
|
||||||
title='Attach file'
|
title='Attach file'
|
||||||
className={cn(
|
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) &&
|
(!activeWorkflowId || isExecuting || chatFiles.length >= 15) &&
|
||||||
'cursor-not-allowed opacity-50'
|
'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
|
* @returns Editor panel content
|
||||||
*/
|
*/
|
||||||
export function Editor() {
|
export function Editor() {
|
||||||
const { currentBlockId, connectionsHeight, toggleConnectionsCollapsed } = usePanelEditorStore()
|
const {
|
||||||
|
currentBlockId,
|
||||||
|
connectionsHeight,
|
||||||
|
toggleConnectionsCollapsed,
|
||||||
|
shouldFocusRename,
|
||||||
|
setShouldFocusRename,
|
||||||
|
} = usePanelEditorStore()
|
||||||
const currentWorkflow = useCurrentWorkflow()
|
const currentWorkflow = useCurrentWorkflow()
|
||||||
const currentBlock = currentBlockId ? currentWorkflow.getBlockById(currentBlockId) : null
|
const currentBlock = currentBlockId ? currentWorkflow.getBlockById(currentBlockId) : null
|
||||||
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
|
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
|
||||||
@@ -158,6 +164,14 @@ export function Editor() {
|
|||||||
}
|
}
|
||||||
}, [isRenaming])
|
}, [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.
|
* 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 }) {
|
export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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>
|
<ErrorBoundary>{children}</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import {
|
|||||||
SubflowNodeComponent,
|
SubflowNodeComponent,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
|
} 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 { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
|
||||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
||||||
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
|
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
|
||||||
@@ -42,6 +46,7 @@ import {
|
|||||||
useCurrentWorkflow,
|
useCurrentWorkflow,
|
||||||
useNodeUtilities,
|
useNodeUtilities,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||||
|
import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu'
|
||||||
import {
|
import {
|
||||||
clampPositionToContainer,
|
clampPositionToContainer,
|
||||||
estimateBlockDimensions,
|
estimateBlockDimensions,
|
||||||
@@ -52,12 +57,15 @@ import { isAnnotationOnlyBlock } from '@/executor/constants'
|
|||||||
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
|
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
|
||||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||||
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
|
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
|
||||||
|
import { useChatStore } from '@/stores/chat/store'
|
||||||
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
||||||
import { useExecutionStore } from '@/stores/execution/store'
|
import { useExecutionStore } from '@/stores/execution/store'
|
||||||
import { useNotificationStore } from '@/stores/notifications/store'
|
import { useNotificationStore } from '@/stores/notifications/store'
|
||||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||||
import { usePanelEditorStore } from '@/stores/panel/editor/store'
|
import { usePanelEditorStore } from '@/stores/panel/editor/store'
|
||||||
|
import { useSearchModalStore } from '@/stores/search-modal/store'
|
||||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||||
|
import { useVariablesStore } from '@/stores/variables/store'
|
||||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils'
|
import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils'
|
||||||
@@ -93,7 +101,6 @@ function calculatePasteOffset(
|
|||||||
const clipboardBlocks = Object.values(clipboard.blocks)
|
const clipboardBlocks = Object.values(clipboard.blocks)
|
||||||
if (clipboardBlocks.length === 0) return DEFAULT_PASTE_OFFSET
|
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 minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
|
||||||
const maxX = Math.max(
|
const maxX = Math.max(
|
||||||
...clipboardBlocks.map((b) => {
|
...clipboardBlocks.map((b) => {
|
||||||
@@ -154,9 +161,7 @@ const reactFlowStyles = [
|
|||||||
'[&_.react-flow__edge-labels]:!z-[60]',
|
'[&_.react-flow__edge-labels]:!z-[60]',
|
||||||
'[&_.react-flow__pane]:!bg-transparent',
|
'[&_.react-flow__pane]:!bg-transparent',
|
||||||
'[&_.react-flow__renderer]:!bg-transparent',
|
'[&_.react-flow__renderer]:!bg-transparent',
|
||||||
'dark:[&_.react-flow__pane]:!bg-[var(--bg)]',
|
'[&_.react-flow__background]:hidden',
|
||||||
'dark:[&_.react-flow__renderer]:!bg-[var(--bg)]',
|
|
||||||
'dark:[&_.react-flow__background]:hidden',
|
|
||||||
].join(' ')
|
].join(' ')
|
||||||
const reactFlowFitViewOptions = { padding: 0.6 } as const
|
const reactFlowFitViewOptions = { padding: 0.6 } as const
|
||||||
const reactFlowProOptions = { hideAttribution: true } as const
|
const reactFlowProOptions = { hideAttribution: true } as const
|
||||||
@@ -195,7 +200,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { screenToFlowPosition, getNodes, fitView, getIntersectingNodes } = useReactFlow()
|
const { screenToFlowPosition, getNodes, setNodes, fitView, getIntersectingNodes } = useReactFlow()
|
||||||
const { emitCursorUpdate } = useSocket()
|
const { emitCursorUpdate } = useSocket()
|
||||||
|
|
||||||
const workspaceId = params.workspaceId as string
|
const workspaceId = params.workspaceId as string
|
||||||
@@ -237,10 +242,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
|
|
||||||
const copilotCleanup = useCopilotStore((state) => state.cleanup)
|
const copilotCleanup = useCopilotStore((state) => state.cleanup)
|
||||||
|
|
||||||
// Training modal state
|
|
||||||
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
|
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
|
||||||
|
|
||||||
// Snap to grid settings
|
|
||||||
const snapToGridSize = useGeneralStore((state) => state.snapToGridSize)
|
const snapToGridSize = useGeneralStore((state) => state.snapToGridSize)
|
||||||
const snapToGrid = snapToGridSize > 0
|
const snapToGrid = snapToGridSize > 0
|
||||||
const snapGrid: [number, number] = useMemo(
|
const snapGrid: [number, number] = useMemo(
|
||||||
@@ -248,7 +251,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
[snapToGridSize]
|
[snapToGridSize]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handle copilot stream cleanup on page unload and component unmount
|
|
||||||
useStreamCleanup(copilotCleanup)
|
useStreamCleanup(copilotCleanup)
|
||||||
|
|
||||||
const { blocks, edges, isDiffMode, lastSaved } = currentWorkflow
|
const { blocks, edges, isDiffMode, lastSaved } = currentWorkflow
|
||||||
@@ -273,7 +275,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
getBlockDimensions,
|
getBlockDimensions,
|
||||||
} = useNodeUtilities(blocks)
|
} = useNodeUtilities(blocks)
|
||||||
|
|
||||||
/** Triggers immediate subflow resize without delays. */
|
|
||||||
const resizeLoopNodesWrapper = useCallback(() => {
|
const resizeLoopNodesWrapper = useCallback(() => {
|
||||||
return resizeLoopNodes(updateNodeDimensions)
|
return resizeLoopNodes(updateNodeDimensions)
|
||||||
}, [resizeLoopNodes, updateNodeDimensions])
|
}, [resizeLoopNodes, updateNodeDimensions])
|
||||||
@@ -407,6 +408,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
collaborativeUpdateParentId: updateParentId,
|
collaborativeUpdateParentId: updateParentId,
|
||||||
collaborativeBatchAddBlocks,
|
collaborativeBatchAddBlocks,
|
||||||
collaborativeBatchRemoveBlocks,
|
collaborativeBatchRemoveBlocks,
|
||||||
|
collaborativeToggleBlockEnabled,
|
||||||
|
collaborativeToggleBlockHandles,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
} = useCollaborativeWorkflow()
|
} = useCollaborativeWorkflow()
|
||||||
@@ -572,6 +575,186 @@ const WorkflowContent = React.memo(() => {
|
|||||||
return () => clearTimeout(debounceTimer)
|
return () => clearTimeout(debounceTimer)
|
||||||
}, [handleAutoLayout])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let cleanup: (() => void) | null = null
|
let cleanup: (() => void) | null = null
|
||||||
|
|
||||||
@@ -616,7 +799,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
if (effectivePermissions.canEdit && hasClipboard()) {
|
if (effectivePermissions.canEdit && hasClipboard()) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
// Calculate offset to paste blocks at viewport center
|
|
||||||
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
|
const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition)
|
||||||
|
|
||||||
const pasteData = preparePasteData(pasteOffset)
|
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(() => {
|
useEffect(() => {
|
||||||
setDisplayNodes(derivedNodes)
|
setDisplayNodes(derivedNodes)
|
||||||
}, [derivedNodes])
|
}, [derivedNodes])
|
||||||
|
|
||||||
/** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */
|
/** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */
|
||||||
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
||||||
// Apply position changes to local state for smooth rendering
|
|
||||||
setDisplayNodes((nds) => applyNodeChanges(changes, nds))
|
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 (
|
return (
|
||||||
<div className='flex h-full w-full flex-col overflow-hidden bg-[var(--bg)]'>
|
<div className='flex h-full w-full flex-col overflow-hidden'>
|
||||||
<div className='relative h-full w-full flex-1 bg-[var(--bg)]'>
|
<div className='relative h-full w-full flex-1'>
|
||||||
{/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
|
{/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
|
||||||
<div
|
<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'}`}
|
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}
|
connectionLineType={ConnectionLineType.SmoothStep}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
onEdgeClick={onEdgeClick}
|
onEdgeClick={onEdgeClick}
|
||||||
onPaneContextMenu={(e) => e.preventDefault()}
|
onPaneContextMenu={handlePaneContextMenu}
|
||||||
onNodeContextMenu={(e) => e.preventDefault()}
|
onNodeContextMenu={handleNodeContextMenu}
|
||||||
|
onSelectionContextMenu={handleSelectionContextMenu}
|
||||||
onPointerMove={handleCanvasPointerMove}
|
onPointerMove={handleCanvasPointerMove}
|
||||||
onPointerLeave={handleCanvasPointerLeave}
|
onPointerLeave={handleCanvasPointerLeave}
|
||||||
elementsSelectable={true}
|
elementsSelectable={true}
|
||||||
@@ -2640,7 +2829,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
noWheelClassName='allow-scroll'
|
noWheelClassName='allow-scroll'
|
||||||
edgesFocusable={true}
|
edgesFocusable={true}
|
||||||
edgesUpdatable={effectivePermissions.canEdit}
|
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}
|
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
|
||||||
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
|
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
|
||||||
onSelectionDragStop={effectivePermissions.canEdit ? onSelectionDragStop : undefined}
|
onSelectionDragStop={effectivePermissions.canEdit ? onSelectionDragStop : undefined}
|
||||||
@@ -2662,6 +2851,48 @@ const WorkflowContent = React.memo(() => {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<DiffControls />
|
<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)
|
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
|
* Focus the inline list rename input when it becomes active
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ interface PanelEditorState {
|
|||||||
setConnectionsHeight: (height: number) => void
|
setConnectionsHeight: (height: number) => void
|
||||||
/** Toggle connections between collapsed (min height) and expanded (default height) */
|
/** Toggle connections between collapsed (min height) and expanded (default height) */
|
||||||
toggleConnectionsCollapsed: () => void
|
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) => ({
|
(set, get) => ({
|
||||||
currentBlockId: null,
|
currentBlockId: null,
|
||||||
connectionsHeight: EDITOR_CONNECTIONS_HEIGHT.DEFAULT,
|
connectionsHeight: EDITOR_CONNECTIONS_HEIGHT.DEFAULT,
|
||||||
|
shouldFocusRename: false,
|
||||||
|
setShouldFocusRename: (value) => set({ shouldFocusRename: value }),
|
||||||
setCurrentBlockId: (blockId) => {
|
setCurrentBlockId: (blockId) => {
|
||||||
set({ currentBlockId: blockId })
|
set({ currentBlockId: blockId })
|
||||||
|
|
||||||
@@ -79,6 +85,10 @@ export const usePanelEditorStore = create<PanelEditorState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'panel-editor-state',
|
name: 'panel-editor-state',
|
||||||
|
partialize: (state) => ({
|
||||||
|
currentBlockId: state.currentBlockId,
|
||||||
|
connectionsHeight: state.connectionsHeight,
|
||||||
|
}),
|
||||||
onRehydrateStorage: () => (state) => {
|
onRehydrateStorage: () => (state) => {
|
||||||
// Sync CSS variables with stored state after rehydration
|
// Sync CSS variables with stored state after rehydration
|
||||||
if (state && typeof window !== 'undefined') {
|
if (state && typeof window !== 'undefined') {
|
||||||
|
|||||||
Reference in New Issue
Block a user