mirror of
https://github.com/langgenius/dify.git
synced 2026-03-26 10:16:53 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0715aecd85 | ||
|
|
7e6f909d99 | ||
|
|
a845e0ae74 |
@@ -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'))
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
Reference in New Issue
Block a user