mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 10:25:48 +08:00
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:
@@ -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'))
|
||||||
|
|||||||
@@ -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' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user