From af143312f21b550303a24d922efe624deeb427df Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 24 Mar 2026 19:47:18 +0800 Subject: [PATCH] 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. --- .../selection-contextmenu.helpers.spec.ts | 136 -------- .../__tests__/selection-contextmenu.spec.tsx | 71 +--- .../variable/var-reference-picker.trigger.tsx | 2 +- .../workflow/selection-contextmenu.helpers.ts | 242 -------------- .../workflow/selection-contextmenu.tsx | 314 +++++++++++++++--- web/eslint-suppressions.json | 5 - 6 files changed, 285 insertions(+), 485 deletions(-) delete mode 100644 web/app/components/workflow/__tests__/selection-contextmenu.helpers.spec.ts delete mode 100644 web/app/components/workflow/selection-contextmenu.helpers.ts diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.helpers.spec.ts b/web/app/components/workflow/__tests__/selection-contextmenu.helpers.spec.ts deleted file mode 100644 index dd2cbf124a..0000000000 --- a/web/app/components/workflow/__tests__/selection-contextmenu.helpers.spec.ts +++ /dev/null @@ -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) - }) -}) diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index e2e001c5b4..247184349d 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -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('../selection-contextmenu.helpers') - return { - ...actual, - alignNodePosition: (...args: Parameters) => { - if (mockAlignNodePosition.getMockImplementation()) - return mockAlignNodePosition(...args) - return actual.alignNodePosition(...args) - }, - getAlignableNodes: (...args: Parameters) => { - if (mockGetAlignableNodes.getMockImplementation()) - return mockGetAlignableNodes(...args) - return actual.getAlignableNodes(...args) - }, - getAlignBounds: (...args: Parameters) => { - 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) }) }) diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx index 669dd21be5..8e8523c51a 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.trigger.tsx @@ -264,7 +264,7 @@ const VarReferencePickerTrigger: FC = ({ )} /> - {tooltipPopup && ( + {tooltipPopup !== null && tooltipPopup !== undefined && ( {tooltipPopup} diff --git a/web/app/components/workflow/selection-contextmenu.helpers.ts b/web/app/components/workflow/selection-contextmenu.helpers.ts deleted file mode 100644 index 7e30c9f996..0000000000 --- a/web/app/components/workflow/selection-contextmenu.helpers.ts +++ /dev/null @@ -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 - -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() - - 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((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) - } - } - }) -} diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 6da314c216..54e6ea2045 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -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 + +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() + + 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((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(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} > -
- {MENU_SECTIONS.map((section, sectionIndex) => ( -
- {sectionIndex > 0 &&
} -
-
+ { + if (!open) + handleSelectionContextmenuCancel() + }} + > + + + + + {menuSections.map((section, sectionIndex) => ( + + {sectionIndex > 0 && } + {t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })} -
+ {section.items.map((item) => { const Icon = item.icon return ( -
handleAlignNodes(item.alignType)} > {t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })} -
+ ) })} -
-
- ))} -
+ + ))} + + ) } diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 0c7e02b0f5..ffce581afd 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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