mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 21:28:25 +08:00
refactor(workflow): consolidate selection context menu helpers into component
- Moved selection context menu helper functions directly into the SelectionContextmenu component for improved encapsulation and organization. - Removed the separate helpers file and associated tests, streamlining the codebase. - Updated the component to maintain existing functionality while enhancing readability and maintainability.
This commit is contained in:
parent
3c58c68b8d
commit
af143312f2
@ -1,136 +0,0 @@
|
||||
import {
|
||||
alignNodePosition,
|
||||
AlignType,
|
||||
distributeNodes,
|
||||
getAlignableNodes,
|
||||
getAlignBounds,
|
||||
getMenuPosition,
|
||||
} from '../selection-contextmenu.helpers'
|
||||
import { createNode } from './fixtures'
|
||||
|
||||
describe('selection-contextmenu helpers', () => {
|
||||
it('should keep the menu inside the workflow container bounds', () => {
|
||||
expect(getMenuPosition(undefined, { width: 800, height: 600 })).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
})
|
||||
expect(getMenuPosition({ left: 780, top: 590 }, { width: 800, height: 600 })).toEqual({
|
||||
left: 540,
|
||||
top: 210,
|
||||
})
|
||||
expect(getMenuPosition({ left: -10, top: -20 }, { width: 800, height: 600 })).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should exclude child nodes when their container node is selected', () => {
|
||||
const container = createNode({
|
||||
id: 'container',
|
||||
selected: true,
|
||||
data: {
|
||||
_children: [{ nodeId: 'child', nodeType: 'code' as never }],
|
||||
},
|
||||
})
|
||||
const child = createNode({ id: 'child', selected: true })
|
||||
const other = createNode({ id: 'other', selected: true })
|
||||
|
||||
expect(getAlignableNodes([container, child, other], [container, child, other]).map(node => node.id)).toEqual([
|
||||
'container',
|
||||
'other',
|
||||
])
|
||||
})
|
||||
|
||||
it('should calculate bounds and align nodes by type', () => {
|
||||
const leftNode = createNode({
|
||||
id: 'left',
|
||||
position: { x: 10, y: 30 },
|
||||
positionAbsolute: { x: 10, y: 30 },
|
||||
width: 40,
|
||||
height: 20,
|
||||
})
|
||||
const rightNode = createNode({
|
||||
id: 'right',
|
||||
position: { x: 100, y: 70 },
|
||||
positionAbsolute: { x: 100, y: 70 },
|
||||
width: 60,
|
||||
height: 40,
|
||||
})
|
||||
const bounds = getAlignBounds([leftNode, rightNode])
|
||||
|
||||
expect(bounds).toEqual({
|
||||
minX: 10,
|
||||
maxX: 160,
|
||||
minY: 30,
|
||||
maxY: 110,
|
||||
})
|
||||
|
||||
alignNodePosition(rightNode, rightNode, AlignType.Left, bounds!)
|
||||
expect(rightNode.position.x).toBe(10)
|
||||
|
||||
alignNodePosition(rightNode, rightNode, AlignType.Center, bounds!)
|
||||
expect(rightNode.position.x).toBe(55)
|
||||
|
||||
alignNodePosition(rightNode, rightNode, AlignType.Right, bounds!)
|
||||
expect(rightNode.position.x).toBe(100)
|
||||
|
||||
alignNodePosition(leftNode, leftNode, AlignType.Top, bounds!)
|
||||
expect(leftNode.position.y).toBe(30)
|
||||
|
||||
alignNodePosition(leftNode, leftNode, AlignType.Middle, bounds!)
|
||||
expect(leftNode.position.y).toBe(60)
|
||||
expect(leftNode.positionAbsolute?.y).toBe(60)
|
||||
|
||||
alignNodePosition(leftNode, leftNode, AlignType.Bottom, bounds!)
|
||||
expect(leftNode.position.y).toBe(90)
|
||||
expect(leftNode.positionAbsolute?.y).toBe(90)
|
||||
})
|
||||
|
||||
it('should distribute nodes horizontally and vertically', () => {
|
||||
const first = createNode({ id: 'first', position: { x: 0, y: 0 }, width: 20, height: 20 })
|
||||
const middle = createNode({ id: 'middle', position: { x: 100, y: 80 }, width: 20, height: 20 })
|
||||
const last = createNode({ id: 'last', position: { x: 300, y: 200 }, width: 20, height: 20 })
|
||||
|
||||
const horizontal = distributeNodes([first, middle, last], [first, middle, last], AlignType.DistributeHorizontal)
|
||||
expect(horizontal?.find(node => node.id === 'middle')?.position.x).toBe(150)
|
||||
|
||||
const vertical = distributeNodes([first, middle, last], [first, middle, last], AlignType.DistributeVertical)
|
||||
expect(vertical?.find(node => node.id === 'middle')?.position.y).toBe(100)
|
||||
})
|
||||
|
||||
it('should return null when nodes cannot be evenly distributed', () => {
|
||||
const first = createNode({ id: 'first', position: { x: 0, y: 0 }, width: 100, height: 100 })
|
||||
const middle = createNode({ id: 'middle', position: { x: 50, y: 50 }, width: 100, height: 100 })
|
||||
const last = createNode({ id: 'last', position: { x: 120, y: 120 }, width: 100, height: 100 })
|
||||
|
||||
expect(distributeNodes([first, middle, last], [first, middle, last], AlignType.DistributeHorizontal)).toBeNull()
|
||||
expect(distributeNodes([first, middle], [first, middle], AlignType.DistributeVertical)).toBeNull()
|
||||
expect(getAlignBounds([first])).toBeNull()
|
||||
})
|
||||
|
||||
it('should skip missing draft nodes and keep absolute positions in vertical distribution', () => {
|
||||
const first = createNode({ id: 'first', position: { x: 0, y: 0 }, width: 20, height: 20 })
|
||||
const middle = createNode({
|
||||
id: 'middle',
|
||||
position: { x: 0, y: 60 },
|
||||
positionAbsolute: { x: 0, y: 60 },
|
||||
width: 20,
|
||||
height: 20,
|
||||
})
|
||||
const last = createNode({ id: 'last', position: { x: 0, y: 180 }, width: 20, height: 20 })
|
||||
|
||||
const distributed = distributeNodes(
|
||||
[first, middle, last],
|
||||
[first, last],
|
||||
AlignType.DistributeVertical,
|
||||
)
|
||||
expect(distributed).toEqual([first, last])
|
||||
|
||||
const distributedWithMiddle = distributeNodes(
|
||||
[first, middle, last],
|
||||
[first, middle, last],
|
||||
AlignType.DistributeVertical,
|
||||
)
|
||||
expect(distributedWithMiddle?.find(node => node.id === 'middle')?.positionAbsolute?.y).toBe(90)
|
||||
})
|
||||
})
|
||||
@ -3,16 +3,12 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { useEffect } from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import SelectionContextmenu from '../selection-contextmenu'
|
||||
import { AlignType } from '../selection-contextmenu.helpers'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
import { createEdge, createNode } from './fixtures'
|
||||
import { renderWorkflowFlowComponent } from './workflow-test-env'
|
||||
|
||||
let latestNodes: Node[] = []
|
||||
let latestHistoryEvent: string | undefined
|
||||
const mockGetAlignBounds = vi.fn()
|
||||
const mockAlignNodePosition = vi.fn()
|
||||
const mockGetAlignableNodes = vi.fn()
|
||||
const mockGetNodesReadOnly = vi.fn()
|
||||
|
||||
vi.mock('../hooks', async () => {
|
||||
@ -25,28 +21,6 @@ vi.mock('../hooks', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../selection-contextmenu.helpers', async () => {
|
||||
const actual = await vi.importActual<typeof import('../selection-contextmenu.helpers')>('../selection-contextmenu.helpers')
|
||||
return {
|
||||
...actual,
|
||||
alignNodePosition: (...args: Parameters<typeof actual.alignNodePosition>) => {
|
||||
if (mockAlignNodePosition.getMockImplementation())
|
||||
return mockAlignNodePosition(...args)
|
||||
return actual.alignNodePosition(...args)
|
||||
},
|
||||
getAlignableNodes: (...args: Parameters<typeof actual.getAlignableNodes>) => {
|
||||
if (mockGetAlignableNodes.getMockImplementation())
|
||||
return mockGetAlignableNodes(...args)
|
||||
return actual.getAlignableNodes(...args)
|
||||
},
|
||||
getAlignBounds: (...args: Parameters<typeof actual.getAlignBounds>) => {
|
||||
if (mockGetAlignBounds.getMockImplementation())
|
||||
return mockGetAlignBounds(...args)
|
||||
return actual.getAlignBounds(...args)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const RuntimeProbe = () => {
|
||||
latestNodes = useNodes() as Node[]
|
||||
const { store } = useWorkflowHistoryStore()
|
||||
@ -97,9 +71,6 @@ describe('SelectionContextmenu', () => {
|
||||
vi.clearAllMocks()
|
||||
latestNodes = []
|
||||
latestHistoryEvent = undefined
|
||||
mockGetAlignBounds.mockReset()
|
||||
mockAlignNodePosition.mockReset()
|
||||
mockGetAlignableNodes.mockReset()
|
||||
mockGetNodesReadOnly.mockReset()
|
||||
mockGetNodesReadOnly.mockReturnValue(false)
|
||||
})
|
||||
@ -241,10 +212,9 @@ describe('SelectionContextmenu', () => {
|
||||
})
|
||||
|
||||
it('should cancel when align bounds cannot be resolved', () => {
|
||||
mockGetAlignBounds.mockReturnValue(null)
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', selected: true, width: 40, height: 20 }),
|
||||
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
|
||||
createNode({ id: 'n1', selected: true }),
|
||||
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 } }),
|
||||
]
|
||||
|
||||
const { store } = renderSelectionMenu({ nodes })
|
||||
@ -280,10 +250,15 @@ describe('SelectionContextmenu', () => {
|
||||
|
||||
it('should cancel when alignable nodes shrink to one item', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', selected: true, width: 40, height: 20 }),
|
||||
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
|
||||
createNode({
|
||||
id: 'container',
|
||||
selected: true,
|
||||
width: 40,
|
||||
height: 20,
|
||||
data: { _children: [{ nodeId: 'child', nodeType: 'code' as never }] },
|
||||
}),
|
||||
createNode({ id: 'child', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
|
||||
]
|
||||
mockGetAlignableNodes.mockImplementation((allNodes: Node[]) => [allNodes[0]])
|
||||
|
||||
const { store } = renderSelectionMenu({ nodes })
|
||||
|
||||
@ -294,29 +269,7 @@ describe('SelectionContextmenu', () => {
|
||||
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
|
||||
|
||||
expect(store.getState().selectionMenu).toBeUndefined()
|
||||
expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(0)
|
||||
expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(80)
|
||||
})
|
||||
|
||||
it('should skip align updates when a selected node is not found in the draft', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', selected: true, position: { x: 20, y: 40 }, width: 40, height: 20 }),
|
||||
createNode({ id: 'n2', selected: true, position: { x: 140, y: 90 }, width: 60, height: 30 }),
|
||||
]
|
||||
mockGetAlignableNodes.mockImplementation((allNodes: Node[]) => [
|
||||
allNodes[0],
|
||||
{ ...allNodes[1], id: 'missing-node' },
|
||||
])
|
||||
|
||||
const { store } = renderSelectionMenu({ nodes })
|
||||
|
||||
act(() => {
|
||||
store.setState({ selectionMenu: { left: 100, top: 100 } })
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId(`selection-contextmenu-item-${AlignType.Right}`))
|
||||
|
||||
expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(160)
|
||||
expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(140)
|
||||
expect(latestNodes.find(node => node.id === 'container')?.position.x).toBe(0)
|
||||
expect(latestNodes.find(node => node.id === 'child')?.position.x).toBe(80)
|
||||
})
|
||||
})
|
||||
|
||||
@ -264,7 +264,7 @@ const VarReferencePickerTrigger: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{tooltipPopup && (
|
||||
{tooltipPopup !== null && tooltipPopup !== undefined && (
|
||||
<TooltipContent variant="plain">
|
||||
{tooltipPopup}
|
||||
</TooltipContent>
|
||||
|
||||
@ -1,242 +0,0 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { Node } from './types'
|
||||
import {
|
||||
RiAlignBottom,
|
||||
RiAlignCenter,
|
||||
RiAlignJustify,
|
||||
RiAlignLeft,
|
||||
RiAlignRight,
|
||||
RiAlignTop,
|
||||
} from '@remixicon/react'
|
||||
import { produce } from 'immer'
|
||||
|
||||
export const AlignType = {
|
||||
Bottom: 'bottom',
|
||||
Center: 'center',
|
||||
DistributeHorizontal: 'distributeHorizontal',
|
||||
DistributeVertical: 'distributeVertical',
|
||||
Left: 'left',
|
||||
Middle: 'middle',
|
||||
Right: 'right',
|
||||
Top: 'top',
|
||||
} as const
|
||||
|
||||
export 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
|
||||
minY: number
|
||||
maxY: number
|
||||
}
|
||||
|
||||
type MenuItem = {
|
||||
alignType: AlignTypeValue
|
||||
icon: ComponentType<{ className?: string }>
|
||||
iconClassName?: string
|
||||
translationKey: string
|
||||
}
|
||||
|
||||
export type MenuSection = {
|
||||
titleKey: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
const MENU_WIDTH = 240
|
||||
const MENU_HEIGHT = 380
|
||||
|
||||
export const MENU_SECTIONS: 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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export 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 }
|
||||
}
|
||||
|
||||
export const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => {
|
||||
const selectedNodeIds = new Set(selectedNodes.map(node => node.id))
|
||||
const childNodeIds = new Set<string>()
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (!node.data._children?.length || !selectedNodeIds.has(node.id))
|
||||
return
|
||||
|
||||
node.data._children.forEach((child) => {
|
||||
childNodeIds.add(child.nodeId)
|
||||
})
|
||||
})
|
||||
|
||||
return nodes.filter(node => selectedNodeIds.has(node.id) && !childNodeIds.has(node.id))
|
||||
}
|
||||
|
||||
export const getAlignBounds = (nodes: Node[]): AlignBounds | null => {
|
||||
const validNodes = nodes.filter(node => node.width && node.height)
|
||||
if (validNodes.length <= 1)
|
||||
return null
|
||||
|
||||
return validNodes.reduce<AlignBounds>((bounds, node) => {
|
||||
const width = node.width!
|
||||
const height = node.height!
|
||||
|
||||
return {
|
||||
minX: Math.min(bounds.minX, node.position.x),
|
||||
maxX: Math.max(bounds.maxX, node.position.x + width),
|
||||
minY: Math.min(bounds.minY, node.position.y),
|
||||
maxY: Math.max(bounds.maxY, node.position.y + height),
|
||||
}
|
||||
}, {
|
||||
minX: Number.MAX_SAFE_INTEGER,
|
||||
maxX: Number.MIN_SAFE_INTEGER,
|
||||
minY: Number.MAX_SAFE_INTEGER,
|
||||
maxY: Number.MIN_SAFE_INTEGER,
|
||||
})
|
||||
}
|
||||
|
||||
export const alignNodePosition = (
|
||||
currentNode: Node,
|
||||
nodeToAlign: Node,
|
||||
alignType: AlignTypeValue,
|
||||
bounds: AlignBounds,
|
||||
) => {
|
||||
const width = nodeToAlign.width ?? 0
|
||||
const height = nodeToAlign.height ?? 0
|
||||
|
||||
switch (alignType) {
|
||||
case AlignType.Left:
|
||||
currentNode.position.x = bounds.minX
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.x = bounds.minX
|
||||
break
|
||||
case AlignType.Center: {
|
||||
const centerX = bounds.minX + (bounds.maxX - bounds.minX) / 2 - width / 2
|
||||
currentNode.position.x = centerX
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.x = centerX
|
||||
break
|
||||
}
|
||||
case AlignType.Right: {
|
||||
const rightX = bounds.maxX - width
|
||||
currentNode.position.x = rightX
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.x = rightX
|
||||
break
|
||||
}
|
||||
case AlignType.Top:
|
||||
currentNode.position.y = bounds.minY
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.y = bounds.minY
|
||||
break
|
||||
case AlignType.Middle: {
|
||||
const middleY = bounds.minY + (bounds.maxY - bounds.minY) / 2 - height / 2
|
||||
currentNode.position.y = middleY
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.y = middleY
|
||||
break
|
||||
}
|
||||
case AlignType.Bottom: {
|
||||
const bottomY = Math.round(bounds.maxY - height)
|
||||
currentNode.position.y = bottomY
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.y = bottomY
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const distributeNodes = (
|
||||
nodesToAlign: Node[],
|
||||
nodes: Node[],
|
||||
alignType: AlignTypeValue,
|
||||
) => {
|
||||
const isHorizontal = alignType === AlignType.DistributeHorizontal
|
||||
const sortedNodes = [...nodesToAlign].sort((a, b) =>
|
||||
isHorizontal ? a.position.x - b.position.x : a.position.y - b.position.y)
|
||||
|
||||
if (sortedNodes.length < 3)
|
||||
return null
|
||||
|
||||
const firstNode = sortedNodes[0]
|
||||
const lastNode = sortedNodes[sortedNodes.length - 1]
|
||||
|
||||
const totalGap = isHorizontal
|
||||
? lastNode.position.x + (lastNode.width || 0) - firstNode.position.x
|
||||
: lastNode.position.y + (lastNode.height || 0) - firstNode.position.y
|
||||
|
||||
const fixedSpace = sortedNodes.reduce((sum, node) =>
|
||||
sum + (isHorizontal ? (node.width || 0) : (node.height || 0)), 0)
|
||||
|
||||
const spacing = (totalGap - fixedSpace) / (sortedNodes.length - 1)
|
||||
if (spacing <= 0)
|
||||
return null
|
||||
|
||||
return produce(nodes, (draft) => {
|
||||
let currentPosition = isHorizontal
|
||||
? firstNode.position.x + (firstNode.width || 0)
|
||||
: firstNode.position.y + (firstNode.height || 0)
|
||||
|
||||
for (let index = 1; index < sortedNodes.length - 1; index++) {
|
||||
const nodeToAlign = sortedNodes[index]
|
||||
const currentNode = draft.find(node => node.id === nodeToAlign.id)
|
||||
if (!currentNode)
|
||||
continue
|
||||
|
||||
if (isHorizontal) {
|
||||
const nextX = currentPosition + spacing
|
||||
currentNode.position.x = nextX
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.x = nextX
|
||||
currentPosition = nextX + (nodeToAlign.width || 0)
|
||||
}
|
||||
else {
|
||||
const nextY = currentPosition + spacing
|
||||
currentNode.position.y = nextY
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.y = nextY
|
||||
currentPosition = nextY + (nodeToAlign.height || 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,59 +1,285 @@
|
||||
import type { AlignTypeValue } from './selection-contextmenu.helpers'
|
||||
import { useClickAway } from 'ahooks'
|
||||
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,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuGroupLabel,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@/app/components/base/ui/context-menu'
|
||||
import { useNodesReadOnly, useNodesSyncDraft } from './hooks'
|
||||
import { useSelectionInteractions } from './hooks/use-selection-interactions'
|
||||
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
|
||||
import {
|
||||
alignNodePosition,
|
||||
AlignType,
|
||||
distributeNodes,
|
||||
getAlignableNodes,
|
||||
getAlignBounds,
|
||||
getMenuPosition,
|
||||
MENU_SECTIONS,
|
||||
} from './selection-contextmenu.helpers'
|
||||
import { useStore, useWorkflowStore } from './store'
|
||||
|
||||
const AlignType = {
|
||||
Bottom: 'bottom',
|
||||
Center: 'center',
|
||||
DistributeHorizontal: 'distributeHorizontal',
|
||||
DistributeVertical: 'distributeVertical',
|
||||
Left: 'left',
|
||||
Middle: 'middle',
|
||||
Right: 'right',
|
||||
Top: 'top',
|
||||
} as const
|
||||
|
||||
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
|
||||
minY: number
|
||||
maxY: number
|
||||
}
|
||||
|
||||
type MenuItem = {
|
||||
alignType: AlignTypeValue
|
||||
icon: ComponentType<{ className?: string }>
|
||||
iconClassName?: string
|
||||
translationKey: string
|
||||
}
|
||||
|
||||
type MenuSection = {
|
||||
titleKey: string
|
||||
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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
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>()
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (!node.data._children?.length || !selectedNodeIds.has(node.id))
|
||||
return
|
||||
|
||||
node.data._children.forEach((child) => {
|
||||
childNodeIds.add(child.nodeId)
|
||||
})
|
||||
})
|
||||
|
||||
return nodes.filter(node => selectedNodeIds.has(node.id) && !childNodeIds.has(node.id))
|
||||
}
|
||||
|
||||
const getAlignBounds = (nodes: Node[]): AlignBounds | null => {
|
||||
const validNodes = nodes.filter(node => node.width && node.height)
|
||||
if (validNodes.length <= 1)
|
||||
return null
|
||||
|
||||
return validNodes.reduce<AlignBounds>((bounds, node) => {
|
||||
const width = node.width!
|
||||
const height = node.height!
|
||||
|
||||
return {
|
||||
minX: Math.min(bounds.minX, node.position.x),
|
||||
maxX: Math.max(bounds.maxX, node.position.x + width),
|
||||
minY: Math.min(bounds.minY, node.position.y),
|
||||
maxY: Math.max(bounds.maxY, node.position.y + height),
|
||||
}
|
||||
}, {
|
||||
minX: Number.MAX_SAFE_INTEGER,
|
||||
maxX: Number.MIN_SAFE_INTEGER,
|
||||
minY: Number.MAX_SAFE_INTEGER,
|
||||
maxY: Number.MIN_SAFE_INTEGER,
|
||||
})
|
||||
}
|
||||
|
||||
const alignNodePosition = (
|
||||
currentNode: Node,
|
||||
nodeToAlign: Node,
|
||||
alignType: AlignTypeValue,
|
||||
bounds: AlignBounds,
|
||||
) => {
|
||||
const width = nodeToAlign.width ?? 0
|
||||
const height = nodeToAlign.height ?? 0
|
||||
|
||||
switch (alignType) {
|
||||
case AlignType.Left:
|
||||
currentNode.position.x = bounds.minX
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.x = bounds.minX
|
||||
break
|
||||
case AlignType.Center: {
|
||||
const centerX = bounds.minX + (bounds.maxX - bounds.minX) / 2 - width / 2
|
||||
currentNode.position.x = centerX
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.x = centerX
|
||||
break
|
||||
}
|
||||
case AlignType.Right: {
|
||||
const rightX = bounds.maxX - width
|
||||
currentNode.position.x = rightX
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.x = rightX
|
||||
break
|
||||
}
|
||||
case AlignType.Top:
|
||||
currentNode.position.y = bounds.minY
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.y = bounds.minY
|
||||
break
|
||||
case AlignType.Middle: {
|
||||
const middleY = bounds.minY + (bounds.maxY - bounds.minY) / 2 - height / 2
|
||||
currentNode.position.y = middleY
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.y = middleY
|
||||
break
|
||||
}
|
||||
case AlignType.Bottom: {
|
||||
const bottomY = Math.round(bounds.maxY - height)
|
||||
currentNode.position.y = bottomY
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.y = bottomY
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const distributeNodes = (
|
||||
nodesToAlign: Node[],
|
||||
nodes: Node[],
|
||||
alignType: AlignTypeValue,
|
||||
) => {
|
||||
const isHorizontal = alignType === AlignType.DistributeHorizontal
|
||||
const sortedNodes = [...nodesToAlign].sort((a, b) =>
|
||||
isHorizontal ? a.position.x - b.position.x : a.position.y - b.position.y)
|
||||
|
||||
if (sortedNodes.length < 3)
|
||||
return null
|
||||
|
||||
const firstNode = sortedNodes[0]
|
||||
const lastNode = sortedNodes[sortedNodes.length - 1]
|
||||
|
||||
const totalGap = isHorizontal
|
||||
? lastNode.position.x + (lastNode.width || 0) - firstNode.position.x
|
||||
: lastNode.position.y + (lastNode.height || 0) - firstNode.position.y
|
||||
|
||||
const fixedSpace = sortedNodes.reduce((sum, node) =>
|
||||
sum + (isHorizontal ? (node.width || 0) : (node.height || 0)), 0)
|
||||
|
||||
const spacing = (totalGap - fixedSpace) / (sortedNodes.length - 1)
|
||||
if (spacing <= 0)
|
||||
return null
|
||||
|
||||
return produce(nodes, (draft) => {
|
||||
let currentPosition = isHorizontal
|
||||
? firstNode.position.x + (firstNode.width || 0)
|
||||
: firstNode.position.y + (firstNode.height || 0)
|
||||
|
||||
for (let index = 1; index < sortedNodes.length - 1; index++) {
|
||||
const nodeToAlign = sortedNodes[index]
|
||||
const currentNode = draft.find(node => node.id === nodeToAlign.id)
|
||||
if (!currentNode)
|
||||
continue
|
||||
|
||||
if (isHorizontal) {
|
||||
const nextX = currentPosition + spacing
|
||||
currentNode.position.x = nextX
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.x = nextX
|
||||
currentPosition = nextX + (nodeToAlign.width || 0)
|
||||
}
|
||||
else {
|
||||
const nextY = currentPosition + spacing
|
||||
currentNode.position.y = nextY
|
||||
if (currentNode.positionAbsolute)
|
||||
currentNode.positionAbsolute.y = nextY
|
||||
currentPosition = nextY + (nodeToAlign.height || 0)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const SelectionContextmenu = () => {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef(null)
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { handleSelectionContextmenuCancel } = useSelectionInteractions()
|
||||
const selectionMenu = useStore(s => s.selectionMenu)
|
||||
|
||||
// Access React Flow methods
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
// Get selected nodes for alignment logic
|
||||
const selectedNodes = useReactFlowStore(state =>
|
||||
state.getNodes().filter(node => node.selected),
|
||||
)
|
||||
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const menuPosition = useMemo(() => {
|
||||
const container = document.querySelector('#workflow-container')
|
||||
return getMenuPosition(selectionMenu, container?.getBoundingClientRect())
|
||||
}, [selectionMenu])
|
||||
|
||||
useClickAway(() => {
|
||||
handleSelectionContextmenuCancel()
|
||||
}, ref)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionMenu && selectedNodes.length <= 1)
|
||||
handleSelectionContextmenuCancel()
|
||||
@ -65,10 +291,8 @@ const SelectionContextmenu = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Disable node animation state - same as handleNodeDragStart
|
||||
workflowStore.setState({ nodeAnimation: false })
|
||||
|
||||
// Get all current nodes
|
||||
const nodes = store.getState().getNodes()
|
||||
const nodesToAlign = getAlignableNodes(nodes, selectedNodes)
|
||||
|
||||
@ -95,7 +319,6 @@ const SelectionContextmenu = () => {
|
||||
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -136,34 +359,41 @@ const SelectionContextmenu = () => {
|
||||
left: menuPosition.left,
|
||||
top: menuPosition.top,
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<div ref={menuRef} className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
|
||||
{MENU_SECTIONS.map((section, sectionIndex) => (
|
||||
<div key={section.titleKey}>
|
||||
{sectionIndex > 0 && <div className="h-px bg-divider-regular"></div>}
|
||||
<div className="p-1">
|
||||
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
|
||||
<ContextMenu
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
handleSelectionContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
<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' })}
|
||||
</div>
|
||||
</ContextMenuGroupLabel>
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div
|
||||
<ContextMenuItem
|
||||
key={item.alignType}
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
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' })}
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ContextMenuGroup>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -9260,11 +9260,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/workflow/selection-contextmenu.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/shortcuts-name.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user