From 3e073404cc52286bfed3d74c00e7b51fb3eae7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Thu, 26 Mar 2026 15:49:42 +0800 Subject: [PATCH] fix: the menu of multi nodes always display on left top corner (#34120) Co-authored-by: yyh --- .../__tests__/selection-contextmenu.spec.tsx | 23 +-- .../__tests__/use-panel-interactions.spec.ts | 2 +- .../use-selection-interactions.spec.ts | 6 +- .../hooks/use-selection-interactions.ts | 6 +- .../workflow/selection-contextmenu.tsx | 146 ++++++------------ .../store/__tests__/workflow-store.spec.ts | 2 +- .../workflow/store/workflow/panel-slice.ts | 4 +- 7 files changed, 72 insertions(+), 117 deletions(-) diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index 247184349d..b153eb8b8a 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -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')) diff --git a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts index 517af513b9..83c21fcb6a 100644 --- a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts @@ -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' }, }, }) diff --git a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts index 31d5d82475..5f584f33d7 100644 --- a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts @@ -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(() => { diff --git a/web/app/components/workflow/hooks/use-selection-interactions.ts b/web/app/components/workflow/hooks/use-selection-interactions.ts index 3c05d64cc4..793897a1af 100644 --- a/web/app/components/workflow/hooks/use-selection-interactions.ts +++ b/web/app/components/workflow/hooks/use-selection-interactions.ts @@ -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]) diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 54e6ea2045..c13d881cc2 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -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 - 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() @@ -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 ( -
{ + if (!open) + handleSelectionContextmenuCancel() }} > - { - if (!open) - handleSelectionContextmenuCancel() - }} + - - - - - {menuSections.map((section, sectionIndex) => ( - - {sectionIndex > 0 && } - - {t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })} - - {section.items.map((item) => { - const Icon = item.icon - return ( - handleAlignNodes(item.alignType)} - > - - {t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })} - - ) - })} - - ))} - - -
+ {menuSections.map((section, sectionIndex) => ( + + {sectionIndex > 0 && } + + {t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })} + + {section.items.map((item) => { + return ( + handleAlignNodes(item.alignType)} + > + + {t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })} + + ) + })} + + ))} + + ) } diff --git a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts index df0288ac09..ee820b22bf 100644 --- a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts @@ -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], diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index bf8b248c3a..83292ff77e 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -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?: {