Skip to content

Commit 9ef2d67

Browse files
committed
feat(workflow): added context menu for block, pane, and multi-block selection on canvas
1 parent 4301342 commit 9ef2d67

File tree

7 files changed

+833
-21
lines changed

7 files changed

+833
-21
lines changed
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
'use client'
2+
3+
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
4+
import type { BlockContextMenuProps } from './types'
5+
6+
/**
7+
* Context menu for workflow block(s).
8+
* Displays block-specific actions in a popover at right-click position.
9+
* Supports multi-selection - actions apply to all selected blocks.
10+
*/
11+
export function BlockContextMenu({
12+
isOpen,
13+
position,
14+
menuRef,
15+
onClose,
16+
selectedBlocks,
17+
onCopy,
18+
onPaste,
19+
onDuplicate,
20+
onDelete,
21+
onToggleEnabled,
22+
onToggleHandles,
23+
onRemoveFromSubflow,
24+
onOpenPanel,
25+
onOpenLogs,
26+
hasClipboard = false,
27+
showRemoveFromSubflow = false,
28+
disableEdit = false,
29+
}: BlockContextMenuProps) {
30+
const isSingleBlock = selectedBlocks.length === 1
31+
32+
const allEnabled = selectedBlocks.every((b) => b.enabled)
33+
const allDisabled = selectedBlocks.every((b) => !b.enabled)
34+
35+
const hasStarterBlock = selectedBlocks.some(
36+
(b) => b.type === 'starter' || b.type === 'start_trigger'
37+
)
38+
const allNoteBlocks = selectedBlocks.every((b) => b.type === 'note')
39+
40+
const canRemoveFromSubflow =
41+
showRemoveFromSubflow &&
42+
!hasStarterBlock &&
43+
selectedBlocks.some(
44+
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
45+
)
46+
47+
const getToggleEnabledLabel = () => {
48+
if (allEnabled) return 'Disable'
49+
if (allDisabled) return 'Enable'
50+
return 'Toggle Enabled'
51+
}
52+
53+
return (
54+
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
55+
<PopoverAnchor
56+
style={{
57+
position: 'fixed',
58+
left: `${position.x}px`,
59+
top: `${position.y}px`,
60+
width: '1px',
61+
height: '1px',
62+
}}
63+
/>
64+
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
65+
{/* Copy */}
66+
<PopoverItem
67+
className='group'
68+
onClick={() => {
69+
onCopy()
70+
onClose()
71+
}}
72+
>
73+
<span>Copy</span>
74+
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘C</span>
75+
</PopoverItem>
76+
77+
{/* Paste */}
78+
<PopoverItem
79+
className='group'
80+
disabled={disableEdit || !hasClipboard}
81+
onClick={() => {
82+
onPaste()
83+
onClose()
84+
}}
85+
>
86+
<span>Paste</span>
87+
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘V</span>
88+
</PopoverItem>
89+
90+
{/* Duplicate - hide for starter blocks */}
91+
{!hasStarterBlock && (
92+
<PopoverItem
93+
disabled={disableEdit}
94+
onClick={() => {
95+
onDuplicate()
96+
onClose()
97+
}}
98+
>
99+
Duplicate
100+
</PopoverItem>
101+
)}
102+
103+
{/* Delete */}
104+
<PopoverItem
105+
className='group'
106+
disabled={disableEdit}
107+
onClick={() => {
108+
onDelete()
109+
onClose()
110+
}}
111+
>
112+
<span>Delete</span>
113+
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'></span>
114+
</PopoverItem>
115+
116+
{/* Enable/Disable - hide if all blocks are notes */}
117+
{!allNoteBlocks && (
118+
<PopoverItem
119+
disabled={disableEdit}
120+
onClick={() => {
121+
onToggleEnabled()
122+
onClose()
123+
}}
124+
>
125+
{getToggleEnabledLabel()}
126+
</PopoverItem>
127+
)}
128+
129+
{/* Flip Handles - hide if all blocks are notes */}
130+
{!allNoteBlocks && (
131+
<PopoverItem
132+
disabled={disableEdit}
133+
onClick={() => {
134+
onToggleHandles()
135+
onClose()
136+
}}
137+
>
138+
Flip Handles
139+
</PopoverItem>
140+
)}
141+
142+
{/* Remove from Subflow - only show when applicable */}
143+
{canRemoveFromSubflow && (
144+
<PopoverItem
145+
disabled={disableEdit}
146+
onClick={() => {
147+
onRemoveFromSubflow()
148+
onClose()
149+
}}
150+
>
151+
Remove from Subflow
152+
</PopoverItem>
153+
)}
154+
155+
{/* Open Panel - only for single block */}
156+
{isSingleBlock && (
157+
<PopoverItem
158+
onClick={() => {
159+
onOpenPanel()
160+
onClose()
161+
}}
162+
>
163+
Open Panel
164+
</PopoverItem>
165+
)}
166+
167+
{/* Open Logs */}
168+
<PopoverItem
169+
className='group'
170+
onClick={() => {
171+
onOpenLogs()
172+
onClose()
173+
}}
174+
>
175+
<span>Open Logs</span>
176+
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘L</span>
177+
</PopoverItem>
178+
</PopoverContent>
179+
</Popover>
180+
)
181+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export { BlockContextMenu } from './block-context-menu'
2+
export { PaneContextMenu } from './pane-context-menu'
3+
export type {
4+
BlockContextMenuProps,
5+
ContextMenuBlockInfo,
6+
ContextMenuPosition,
7+
PaneContextMenuProps,
8+
} from './types'
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
'use client'
2+
3+
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
4+
import type { PaneContextMenuProps } from './types'
5+
6+
/**
7+
* Context menu for workflow canvas pane.
8+
* Displays canvas-level actions when right-clicking empty space.
9+
*/
10+
export function PaneContextMenu({
11+
isOpen,
12+
position,
13+
menuRef,
14+
onClose,
15+
onUndo,
16+
onRedo,
17+
onPaste,
18+
onAddBlock,
19+
onAutoLayout,
20+
onOpenLogs,
21+
onOpenVariables,
22+
onOpenChat,
23+
hasClipboard = false,
24+
disableEdit = false,
25+
}: PaneContextMenuProps) {
26+
return (
27+
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
28+
<PopoverAnchor
29+
style={{
30+
position: 'fixed',
31+
left: `${position.x}px`,
32+
top: `${position.y}px`,
33+
width: '1px',
34+
height: '1px',
35+
}}
36+
/>
37+
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
38+
{/* Undo */}
39+
<PopoverItem
40+
className='group'
41+
disabled={disableEdit}
42+
onClick={() => {
43+
onUndo()
44+
onClose()
45+
}}
46+
>
47+
<span>Undo</span>
48+
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘Z</span>
49+
</PopoverItem>
50+
51+
{/* Redo */}
52+
<PopoverItem
53+
className='group'
54+
disabled={disableEdit}
55+
onClick={() => {
56+
onRedo()
57+
onClose()
58+
}}
59+
>
60+
<span>Redo</span>
61+
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘⇧Z</span>
62+
</PopoverItem>
63+
64+
{/* Paste */}
65+
<PopoverItem
66+
className='group'
67+
disabled={disableEdit || !hasClipboard}
68+
onClick={() => {
69+
onPaste()
70+
onClose()
71+
}}
72+
>
73+
<span>Paste</span>
74+
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘V</span>
75+
</PopoverItem>
76+
77+
{/* Add Block */}
78+
<PopoverItem
79+
className='group'
80+
disabled={disableEdit}
81+
onClick={() => {
82+
onAddBlock()
83+
onClose()
84+
}}
85+
>
86+
<span>Add Block</span>
87+
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘K</span>
88+
</PopoverItem>
89+
90+
{/* Auto-layout */}
91+
<PopoverItem
92+
className='group'
93+
disabled={disableEdit}
94+
onClick={() => {
95+
onAutoLayout()
96+
onClose()
97+
}}
98+
>
99+
<span>Auto-layout</span>
100+
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⇧L</span>
101+
</PopoverItem>
102+
103+
{/* Open Logs */}
104+
<PopoverItem
105+
className='group'
106+
onClick={() => {
107+
onOpenLogs()
108+
onClose()
109+
}}
110+
>
111+
<span>Open Logs</span>
112+
<span className='ml-auto text-[var(--text-tertiary)] group-hover:text-inherit'>⌘L</span>
113+
</PopoverItem>
114+
115+
{/* Open Variables */}
116+
<PopoverItem
117+
onClick={() => {
118+
onOpenVariables()
119+
onClose()
120+
}}
121+
>
122+
Variables
123+
</PopoverItem>
124+
125+
{/* Open Chat */}
126+
<PopoverItem
127+
onClick={() => {
128+
onOpenChat()
129+
onClose()
130+
}}
131+
>
132+
Open Chat
133+
</PopoverItem>
134+
</PopoverContent>
135+
</Popover>
136+
)
137+
}

0 commit comments

Comments
 (0)