From 5e1f25204682866e4beb605095226d1af7776d13 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Wed, 25 Mar 2026 22:36:27 +0800 Subject: [PATCH] feat(web): selection context menu style update --- .../__tests__/selection-contextmenu.spec.tsx | 44 +++++ .../workflow/selection-contextmenu.tsx | 175 +++++++++++------- 2 files changed, 155 insertions(+), 64 deletions(-) diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index 08f0f4ed3e..9855c9e9eb 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -10,11 +10,19 @@ import { renderWorkflowFlowComponent } from './workflow-test-env' let latestNodes: Node[] = [] let latestHistoryEvent: string | undefined const mockGetNodesReadOnly = vi.fn() +const mockHandleNodesCopy = vi.fn() +const mockHandleNodesDelete = vi.fn() +const mockHandleNodesDuplicate = vi.fn() vi.mock('../hooks', async () => { const actual = await vi.importActual('../hooks') return { ...actual, + useNodesInteractions: () => ({ + handleNodesCopy: mockHandleNodesCopy, + handleNodesDelete: mockHandleNodesDelete, + handleNodesDuplicate: mockHandleNodesDuplicate, + }), useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly, }), @@ -73,6 +81,9 @@ describe('SelectionContextmenu', () => { latestHistoryEvent = undefined mockGetNodesReadOnly.mockReset() mockGetNodesReadOnly.mockReturnValue(false) + mockHandleNodesCopy.mockReset() + mockHandleNodesDelete.mockReset() + mockHandleNodesDuplicate.mockReset() }) it('should not render when selectionMenu is absent', () => { @@ -162,6 +173,39 @@ describe('SelectionContextmenu', () => { vi.useRealTimers() }) + it('should render selection actions and delegate copy, duplicate, and delete', () => { + 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 }), + ] + + const { store } = renderSelectionMenu({ nodes }) + + act(() => { + store.setState({ selectionMenu: { left: 120, top: 120 } }) + }) + + expect(screen.getByTestId('selection-contextmenu-item-copy')).toHaveTextContent('workflow.common.copy') + expect(screen.getByTestId('selection-contextmenu-item-duplicate')).toHaveTextContent('workflow.common.duplicate') + expect(screen.getByTestId('selection-contextmenu-item-delete')).toHaveTextContent('common.operation.delete') + + fireEvent.click(screen.getByTestId('selection-contextmenu-item-copy')) + + act(() => { + store.setState({ selectionMenu: { left: 120, top: 120 } }) + }) + fireEvent.click(screen.getByTestId('selection-contextmenu-item-duplicate')) + + act(() => { + store.setState({ selectionMenu: { left: 120, top: 120 } }) + }) + fireEvent.click(screen.getByTestId('selection-contextmenu-item-delete')) + + expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1) + expect(mockHandleNodesDuplicate).toHaveBeenCalledTimes(1) + expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1) + }) + it('should distribute selected nodes horizontally', async () => { const nodes = [ createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }), diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 7f3d242eef..435e25b49c 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -1,12 +1,13 @@ import type { ComponentType } from 'react' import type { Node } from './types' import { - RiAlignBottom, - RiAlignCenter, + RiAlignItemBottomLine, + RiAlignItemHorizontalCenterLine, + RiAlignItemLeftLine, + RiAlignItemRightLine, + RiAlignItemTopLine, + RiAlignItemVerticalCenterLine, RiAlignJustify, - RiAlignLeft, - RiAlignRight, - RiAlignTop, } from '@remixicon/react' import { produce } from 'immer' import { @@ -21,15 +22,15 @@ import { useStore as useReactFlowStore, useStoreApi } from 'reactflow' import { ContextMenu, ContextMenuContent, - ContextMenuGroup, - ContextMenuGroupLabel, ContextMenuItem, ContextMenuSeparator, } from '@/app/components/base/ui/context-menu' +import { cn } from '@/utils/classnames' import CreateSnippetDialog from './create-snippet-dialog' -import { useNodesReadOnly, useNodesSyncDraft } from './hooks' +import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks' import { useSelectionInteractions } from './hooks/use-selection-interactions' import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history' +import ShortcutsName from './shortcuts-name' import { useStore, useWorkflowStore } from './store' import { BlockEnum, TRIGGER_NODE_TYPES } from './types' @@ -60,48 +61,32 @@ type AlignBounds = { maxY: number } -type MenuItem = { +type AlignMenuItem = { alignType: AlignTypeValue icon: ComponentType<{ className?: string }> iconClassName?: string translationKey: string } -type MenuSection = { - isolated?: boolean - titleKey: string - items: MenuItem[] +type ActionMenuItem = { + action: 'copy' | 'createSnippet' | 'delete' | 'duplicate' + disabled?: boolean + shortcutKeys?: string[] + translationKey: string } const MENU_WIDTH = 240 -const MENU_HEIGHT = 380 +const MENU_HEIGHT = 240 -const menuSections: MenuSection[] = [ - { - isolated: true, - titleKey: 'snippet.addToSnippet', - items: [ - { alignType: AlignType.Top, icon: RiAlignTop, translationKey: 'snippet.addToSnippet' }, - ], - }, - { - 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 alignMenuItems: AlignMenuItem[] = [ + { alignType: AlignType.Left, icon: RiAlignItemLeftLine, translationKey: 'operator.alignLeft' }, + { alignType: AlignType.Center, icon: RiAlignItemHorizontalCenterLine, translationKey: 'operator.alignCenter' }, + { alignType: AlignType.Right, icon: RiAlignItemRightLine, translationKey: 'operator.alignRight' }, + { alignType: AlignType.Top, icon: RiAlignItemTopLine, translationKey: 'operator.alignTop' }, + { alignType: AlignType.Middle, icon: RiAlignItemVerticalCenterLine, iconClassName: 'rotate-90', translationKey: 'operator.alignMiddle' }, + { alignType: AlignType.Bottom, icon: RiAlignItemBottomLine, translationKey: 'operator.alignBottom' }, + { alignType: AlignType.DistributeHorizontal, icon: RiAlignJustify, translationKey: 'operator.distributeHorizontal' }, + { alignType: AlignType.DistributeVertical, icon: RiAlignJustify, iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' }, ] const getMenuPosition = ( @@ -275,6 +260,7 @@ const distributeNodes = ( const SelectionContextmenu = () => { const { t } = useTranslation() const { getNodesReadOnly } = useNodesReadOnly() + const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions() const { handleSelectionContextmenuCancel } = useSelectionInteractions() const selectionMenu = useStore(s => s.selectionMenu) const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false) @@ -320,7 +306,10 @@ const SelectionContextmenu = () => { const isAddToSnippetDisabled = useMemo(() => { return selectedNodes.some(node => - node.data.type === BlockEnum.Start || TRIGGER_NODE_TYPES.includes(node.data.type as typeof TRIGGER_NODE_TYPES[number])) + node.data.type === BlockEnum.Start + || node.data.type === BlockEnum.End + || node.data.type === BlockEnum.HumanInput + || TRIGGER_NODE_TYPES.includes(node.data.type as typeof TRIGGER_NODE_TYPES[number])) }, [selectedNodes]) const handleOpenCreateSnippetDialog = useCallback(() => { @@ -337,6 +326,55 @@ const SelectionContextmenu = () => { setSelectedNodeIdsSnapshot([]) }, []) + const menuActions = useMemo(() => [ + { + action: 'createSnippet', + disabled: isAddToSnippetDisabled, + translationKey: 'snippet.addToSnippet', + }, + { + action: 'copy', + shortcutKeys: ['ctrl', 'c'], + translationKey: 'common.copy', + }, + { + action: 'duplicate', + shortcutKeys: ['ctrl', 'd'], + translationKey: 'common.duplicate', + }, + { + action: 'delete', + shortcutKeys: ['del'], + translationKey: 'operation.delete', + }, + ], [isAddToSnippetDisabled]) + + const getActionLabel = useCallback((translationKey: string) => { + if (translationKey === 'operation.delete') + return t(translationKey, { ns: 'common', defaultValue: translationKey }) + + return t(translationKey, { ns: 'workflow', defaultValue: translationKey }) + }, [t]) + + const handleMenuAction = useCallback((action: ActionMenuItem['action']) => { + switch (action) { + case 'createSnippet': + handleOpenCreateSnippetDialog() + return + case 'copy': + handleSelectionContextmenuCancel() + handleNodesCopy() + return + case 'duplicate': + handleSelectionContextmenuCancel() + handleNodesDuplicate() + return + case 'delete': + handleSelectionContextmenuCancel() + handleNodesDelete() + } + }, [handleNodesCopy, handleNodesDelete, handleNodesDuplicate, handleOpenCreateSnippetDialog, handleSelectionContextmenuCancel]) + const handleAlignNodes = useCallback((alignType: AlignTypeValue) => { if (getNodesReadOnly() || selectedNodes.length <= 1) { handleSelectionContextmenuCancel() @@ -414,40 +452,49 @@ const SelectionContextmenu = () => { > - {menuSections.map((section, sectionIndex) => ( - - {sectionIndex > 0 && } - {!section.isolated && ( - - {t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })} - - )} - {!section.isolated && section.items.map((item) => { +
+ {menuActions.map(item => ( + handleMenuAction(item.action)} + > + {getActionLabel(item.translationKey)} + {item.shortcutKeys && ( + + )} + + ))} +
+ +
+
+ {alignMenuItems.map((item) => { const Icon = item.icon return ( handleAlignNodes(item.alignType)} > - - {t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })} + ) })} - {section.isolated && ( - - {t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })} - - )} - - ))} +
+
{isCreateSnippetDialogOpen && (