fix: the menu of multi nodes always display on left top corner (#34120)

Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
非法操作
2026-03-26 15:49:42 +08:00
committed by GitHub
parent 0acabf5f73
commit 3e073404cc
7 changed files with 72 additions and 117 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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