Compare commits

...

3 Commits
main ... p383

Author SHA1 Message Date
yyh
0715aecd85 fix(workflow): simplify selection context menu positioning 2026-03-26 15:25:45 +08:00
hjlarry
7e6f909d99 fix: correct position 2026-03-26 15:14:58 +08:00
hjlarry
a845e0ae74 fix: the menu of multi nodes always display on left top corner 2026-03-26 14:41:16 +08:00
7 changed files with 72 additions and 117 deletions

View File

@@ -81,7 +81,7 @@ describe('SelectionContextmenu', () => {
expect(screen.queryByText('operator.vertical')).not.toBeInTheDocument()
})
it('should keep the menu inside the workflow container bounds', () => {
it('should render menu items when selectionMenu is present', async () => {
const nodes = [
createNode({ id: 'n1', selected: true, width: 80, height: 40 }),
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
@@ -89,11 +89,12 @@ describe('SelectionContextmenu', () => {
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 780, top: 590 } })
store.setState({ selectionMenu: { clientX: 780, clientY: 590 } })
})
const menu = screen.getByTestId('selection-contextmenu')
expect(menu).toHaveStyle({ left: '540px', top: '210px' })
await waitFor(() => {
expect(screen.getByTestId('selection-contextmenu-item-left')).toBeInTheDocument()
})
})
it('should close itself when only one node is selected', async () => {
@@ -104,7 +105,7 @@ describe('SelectionContextmenu', () => {
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 120, top: 120 } })
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
})
await waitFor(() => {
@@ -129,7 +130,7 @@ describe('SelectionContextmenu', () => {
})
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
store.setState({ selectionMenu: { clientX: 100, clientY: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
@@ -162,7 +163,7 @@ describe('SelectionContextmenu', () => {
})
act(() => {
store.setState({ selectionMenu: { left: 160, top: 120 } })
store.setState({ selectionMenu: { clientX: 160, clientY: 120 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-distributeHorizontal'))
@@ -201,7 +202,7 @@ describe('SelectionContextmenu', () => {
})
act(() => {
store.setState({ selectionMenu: { left: 180, top: 120 } })
store.setState({ selectionMenu: { clientX: 180, clientY: 120 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
@@ -220,7 +221,7 @@ describe('SelectionContextmenu', () => {
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
store.setState({ selectionMenu: { clientX: 100, clientY: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
@@ -238,7 +239,7 @@ describe('SelectionContextmenu', () => {
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
store.setState({ selectionMenu: { clientX: 100, clientY: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
@@ -263,7 +264,7 @@ describe('SelectionContextmenu', () => {
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
store.setState({ selectionMenu: { clientX: 100, clientY: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))

View File

@@ -29,7 +29,7 @@ describe('usePanelInteractions', () => {
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
initialStoreState: {
nodeMenu: { top: 20, left: 40, nodeId: 'n1' },
selectionMenu: { top: 30, left: 50 },
selectionMenu: { clientX: 30, clientY: 50 },
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
},
})

View File

@@ -200,8 +200,8 @@ describe('useSelectionInteractions', () => {
})
expect(store.getState().selectionMenu).toEqual({
top: 150,
left: 200,
clientX: 300,
clientY: 200,
})
expect(store.getState().nodeMenu).toBeUndefined()
expect(store.getState().panelMenu).toBeUndefined()
@@ -210,7 +210,7 @@ describe('useSelectionInteractions', () => {
it('handleSelectionContextmenuCancel should clear selectionMenu', () => {
const { result, store } = renderSelectionInteractions({
selectionMenu: { top: 50, left: 60 },
selectionMenu: { clientX: 50, clientY: 60 },
})
act(() => {

View File

@@ -137,15 +137,13 @@ export const useSelectionInteractions = () => {
return
e.preventDefault()
const container = document.querySelector('#workflow-container')
const { x, y } = container!.getBoundingClientRect()
workflowStore.setState({
nodeMenu: undefined,
panelMenu: undefined,
edgeMenu: undefined,
selectionMenu: {
top: e.clientY - y,
left: e.clientX - x,
clientX: e.clientX,
clientY: e.clientY,
},
})
}, [workflowStore])

View File

@@ -1,13 +1,4 @@
import type { ComponentType } from 'react'
import type { Node } from './types'
import {
RiAlignBottom,
RiAlignCenter,
RiAlignJustify,
RiAlignLeft,
RiAlignRight,
RiAlignTop,
} from '@remixicon/react'
import { produce } from 'immer'
import {
memo,
@@ -24,7 +15,6 @@ import {
ContextMenuGroupLabel,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/app/components/base/ui/context-menu'
import { useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
@@ -44,13 +34,6 @@ const AlignType = {
type AlignTypeValue = (typeof AlignType)[keyof typeof AlignType]
type SelectionMenuPosition = {
left: number
top: number
}
type ContainerRect = Pick<DOMRect, 'width' | 'height'>
type AlignBounds = {
minX: number
maxX: number
@@ -60,7 +43,7 @@ type AlignBounds = {
type MenuItem = {
alignType: AlignTypeValue
icon: ComponentType<{ className?: string }>
icon: string
iconClassName?: string
translationKey: string
}
@@ -70,53 +53,27 @@ type MenuSection = {
items: MenuItem[]
}
const MENU_WIDTH = 240
const MENU_HEIGHT = 380
const menuSections: MenuSection[] = [
{
titleKey: 'operator.vertical',
items: [
{ alignType: AlignType.Top, icon: RiAlignTop, translationKey: 'operator.alignTop' },
{ alignType: AlignType.Middle, icon: RiAlignCenter, iconClassName: 'rotate-90', translationKey: 'operator.alignMiddle' },
{ alignType: AlignType.Bottom, icon: RiAlignBottom, translationKey: 'operator.alignBottom' },
{ alignType: AlignType.DistributeVertical, icon: RiAlignJustify, iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' },
{ alignType: AlignType.Top, icon: 'i-ri-align-top', translationKey: 'operator.alignTop' },
{ alignType: AlignType.Middle, icon: 'i-ri-align-center', iconClassName: 'rotate-90', translationKey: 'operator.alignMiddle' },
{ alignType: AlignType.Bottom, icon: 'i-ri-align-bottom', translationKey: 'operator.alignBottom' },
{ alignType: AlignType.DistributeVertical, icon: 'i-ri-align-justify', iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' },
],
},
{
titleKey: 'operator.horizontal',
items: [
{ alignType: AlignType.Left, icon: RiAlignLeft, translationKey: 'operator.alignLeft' },
{ alignType: AlignType.Center, icon: RiAlignCenter, translationKey: 'operator.alignCenter' },
{ alignType: AlignType.Right, icon: RiAlignRight, translationKey: 'operator.alignRight' },
{ alignType: AlignType.DistributeHorizontal, icon: RiAlignJustify, translationKey: 'operator.distributeHorizontal' },
{ alignType: AlignType.Left, icon: 'i-ri-align-left', translationKey: 'operator.alignLeft' },
{ alignType: AlignType.Center, icon: 'i-ri-align-center', translationKey: 'operator.alignCenter' },
{ alignType: AlignType.Right, icon: 'i-ri-align-right', translationKey: 'operator.alignRight' },
{ alignType: AlignType.DistributeHorizontal, icon: 'i-ri-align-justify', translationKey: 'operator.distributeHorizontal' },
],
},
]
const getMenuPosition = (
selectionMenu: SelectionMenuPosition | undefined,
containerRect?: ContainerRect | null,
) => {
if (!selectionMenu)
return { left: 0, top: 0 }
let { left, top } = selectionMenu
if (containerRect) {
if (left + MENU_WIDTH > containerRect.width)
left = left - MENU_WIDTH
if (top + MENU_HEIGHT > containerRect.height)
top = top - MENU_HEIGHT
left = Math.max(0, left)
top = Math.max(0, top)
}
return { left, top }
}
const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => {
const selectedNodeIds = new Set(selectedNodes.map(node => node.id))
const childNodeIds = new Set<string>()
@@ -275,9 +232,18 @@ const SelectionContextmenu = () => {
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { saveStateToHistory } = useWorkflowHistory()
const menuPosition = useMemo(() => {
const container = document.querySelector('#workflow-container')
return getMenuPosition(selectionMenu, container?.getBoundingClientRect())
const anchor = useMemo(() => {
if (!selectionMenu)
return undefined
return {
getBoundingClientRect: () => DOMRect.fromRect({
width: 0,
height: 0,
x: selectionMenu.clientX,
y: selectionMenu.clientY,
}),
}
}, [selectionMenu])
useEffect(() => {
@@ -352,49 +318,39 @@ const SelectionContextmenu = () => {
return null
return (
<div
className="absolute z-[9]"
data-testid="selection-contextmenu"
style={{
left: menuPosition.left,
top: menuPosition.top,
<ContextMenu
open
onOpenChange={(open) => {
if (!open)
handleSelectionContextmenuCancel()
}}
>
<ContextMenu
open
onOpenChange={(open) => {
if (!open)
handleSelectionContextmenuCancel()
}}
<ContextMenuContent
popupClassName="w-[240px]"
positionerProps={anchor ? { anchor } : undefined}
>
<ContextMenuTrigger>
<span aria-hidden className="block size-px opacity-0" />
</ContextMenuTrigger>
<ContextMenuContent popupClassName="w-[240px]">
{menuSections.map((section, sectionIndex) => (
<ContextMenuGroup key={section.titleKey}>
{sectionIndex > 0 && <ContextMenuSeparator />}
<ContextMenuGroupLabel>
{t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })}
</ContextMenuGroupLabel>
{section.items.map((item) => {
const Icon = item.icon
return (
<ContextMenuItem
key={item.alignType}
data-testid={`selection-contextmenu-item-${item.alignType}`}
onClick={() => handleAlignNodes(item.alignType)}
>
<Icon className={`h-4 w-4 ${item.iconClassName ?? ''}`.trim()} />
{t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })}
</ContextMenuItem>
)
})}
</ContextMenuGroup>
))}
</ContextMenuContent>
</ContextMenu>
</div>
{menuSections.map((section, sectionIndex) => (
<ContextMenuGroup key={section.titleKey}>
{sectionIndex > 0 && <ContextMenuSeparator />}
<ContextMenuGroupLabel>
{t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })}
</ContextMenuGroupLabel>
{section.items.map((item) => {
return (
<ContextMenuItem
key={item.alignType}
data-testid={`selection-contextmenu-item-${item.alignType}`}
onClick={() => handleAlignNodes(item.alignType)}
>
<span aria-hidden className={`${item.icon} h-4 w-4 ${item.iconClassName ?? ''}`.trim()} />
{t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })}
</ContextMenuItem>
)
})}
</ContextMenuGroup>
))}
</ContextMenuContent>
</ContextMenu>
)
}

View File

@@ -96,7 +96,7 @@ describe('createWorkflowStore', () => {
['showInputsPanel', 'setShowInputsPanel', true],
['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true],
['panelMenu', 'setPanelMenu', { top: 10, left: 20 }],
['selectionMenu', 'setSelectionMenu', { top: 50, left: 60 }],
['selectionMenu', 'setSelectionMenu', { clientX: 50, clientY: 60 }],
['edgeMenu', 'setEdgeMenu', { clientX: 320, clientY: 180, edgeId: 'e1' }],
['showVariableInspectPanel', 'setShowVariableInspectPanel', true],
['initShowLastRunTab', 'setInitShowLastRunTab', true],

View File

@@ -16,8 +16,8 @@ export type PanelSliceShape = {
}
setPanelMenu: (panelMenu: PanelSliceShape['panelMenu']) => void
selectionMenu?: {
top: number
left: number
clientX: number
clientY: number
}
setSelectionMenu: (selectionMenu: PanelSliceShape['selectionMenu']) => void
edgeMenu?: {