From 0c8d05fc98bf53d5bfc74e97b511f2f70e6bb759 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 31 Dec 2025 14:42:33 -0800 Subject: [PATCH] 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 --- .../w/[workflowId]/components/chat/chat.tsx | 2 +- .../context-menu/block-context-menu.tsx | 179 ++++++++++++ .../components/context-menu/index.ts | 8 + .../context-menu/pane-context-menu.tsx | 150 ++++++++++ .../components/context-menu/types.ts | 89 ++++++ .../panel/components/editor/editor.tsx | 16 +- .../hooks/use-canvas-context-menu.ts | 161 +++++++++++ .../[workspaceId]/w/[workflowId]/layout.tsx | 2 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 271 ++++++++++++++++-- .../workspace-header/workspace-header.tsx | 7 + apps/sim/stores/panel/editor/store.ts | 10 + 11 files changed, 872 insertions(+), 23 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/pane-context-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index c71eba7ee..0ad5e42f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -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' )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx new file mode 100644 index 000000000..337ff4855 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx @@ -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 ( + + + + {/* Copy */} + { + onCopy() + onClose() + }} + > + Copy + ⌘C + + + {/* Paste */} + { + onPaste() + onClose() + }} + > + Paste + ⌘V + + + {/* Duplicate - hide for starter blocks */} + {!hasStarterBlock && ( + { + onDuplicate() + onClose() + }} + > + Duplicate + + )} + + {/* Delete */} + { + onDelete() + onClose() + }} + > + Delete + + + + {/* Enable/Disable - hide if all blocks are notes */} + {!allNoteBlocks && ( + { + onToggleEnabled() + onClose() + }} + > + {getToggleEnabledLabel()} + + )} + + {/* Flip Handles - hide if all blocks are notes */} + {!allNoteBlocks && ( + { + onToggleHandles() + onClose() + }} + > + Flip Handles + + )} + + {/* Remove from Subflow - only show when applicable */} + {canRemoveFromSubflow && ( + { + onRemoveFromSubflow() + onClose() + }} + > + Remove from Subflow + + )} + + {/* Rename - only for single block, not subflows */} + {isSingleBlock && !isSubflow && ( + { + onRename() + onClose() + }} + > + Rename + + )} + + {/* Open Editor - only for single block */} + {isSingleBlock && ( + { + onOpenEditor() + onClose() + }} + > + Open Editor + + )} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/index.ts new file mode 100644 index 000000000..8f46b9fe5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/index.ts @@ -0,0 +1,8 @@ +export { BlockContextMenu } from './block-context-menu' +export { PaneContextMenu } from './pane-context-menu' +export type { + BlockContextMenuProps, + ContextMenuBlockInfo, + ContextMenuPosition, + PaneContextMenuProps, +} from './types' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/pane-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/pane-context-menu.tsx new file mode 100644 index 000000000..e95c07e6a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/pane-context-menu.tsx @@ -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 ( + + + + {/* Undo */} + { + onUndo() + onClose() + }} + > + Undo + ⌘Z + + + {/* Redo */} + { + onRedo() + onClose() + }} + > + Redo + ⌘⇧Z + + + {/* Paste */} + { + onPaste() + onClose() + }} + > + Paste + ⌘V + + + {/* Add Block */} + { + onAddBlock() + onClose() + }} + > + Add Block + ⌘K + + + {/* Auto-layout */} + { + onAutoLayout() + onClose() + }} + > + Auto-layout + ⇧L + + + {/* Open Logs */} + { + onOpenLogs() + onClose() + }} + > + Open Logs + ⌘L + + + {/* Open Variables */} + { + onOpenVariables() + onClose() + }} + > + Variables + + + {/* Open Chat */} + { + onOpenChat() + onClose() + }} + > + Open Chat + + + {/* Invite to Workspace - admin only */} + { + onInvite() + onClose() + }} + > + Invite to Workspace + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts new file mode 100644 index 000000000..9c75c74e7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/types.ts @@ -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 + /** 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 + /** 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 +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index 181732e95..1392cfdaf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -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. */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts new file mode 100644 index 000000000..be4bc6bdf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts @@ -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 + /** 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(null) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const [selectedBlocks, setSelectedBlocks] = useState([]) + + const menuRef = useRef(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, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx index 122a26ef9..73078feb7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx @@ -2,7 +2,7 @@ import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp export default function WorkflowLayout({ children }: { children: React.ReactNode }) { return ( -
+
{children}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 7724172f7..268d21ba2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -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 ( -
-
+
+
{/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
{ 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(() => { + + {/* Context Menus */} + b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel') + )} + disableEdit={!effectivePermissions.canEdit} + /> + + )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index b42cf9649..598804496 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -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 */ diff --git a/apps/sim/stores/panel/editor/store.ts b/apps/sim/stores/panel/editor/store.ts index 0e3ff198a..c716f6166 100644 --- a/apps/sim/stores/panel/editor/store.ts +++ b/apps/sim/stores/panel/editor/store.ts @@ -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()( (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()( }), { 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') {