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:
CodingOnStar 2026-03-24 19:47:18 +08:00
parent 3c58c68b8d
commit af143312f2
6 changed files with 285 additions and 485 deletions

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -264,7 +264,7 @@ const VarReferencePickerTrigger: FC<Props> = ({
</div>
)}
/>
{tooltipPopup && (
{tooltipPopup !== null && tooltipPopup !== undefined && (
<TooltipContent variant="plain">
{tooltipPopup}
</TooltipContent>

View File

@ -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)
}
}
})
}

View File

@ -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>
)
}

View File

@ -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