diff --git a/packages/dify-ui/src/context-menu/index.tsx b/packages/dify-ui/src/context-menu/index.tsx index e33a94f03f..422dff6b62 100644 --- a/packages/dify-ui/src/context-menu/index.tsx +++ b/packages/dify-ui/src/context-menu/index.tsx @@ -6,7 +6,6 @@ import type { Placement } from '../placement' import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu' import { cn } from '../cn' import { - overlayBackdropClassName, overlayDestructiveClassName, overlayIndicatorClassName, overlayLabelClassName, @@ -24,6 +23,8 @@ export const ContextMenuTrigger = BaseContextMenu.Trigger export const ContextMenuSub = BaseContextMenu.SubmenuRoot export const ContextMenuGroup = BaseContextMenu.Group export const ContextMenuRadioGroup = BaseContextMenu.RadioGroup +export type ContextMenuActions = BaseContextMenu.Root.Actions +// Intentionally no public Backdrop export; Base UI handles context-menu modal dismissal internally. type ContextMenuContentProps = { children: ReactNode @@ -50,7 +51,6 @@ type ContextMenuPopupRenderProps = Required - {withBackdrop && ( - - )} { > remove-e1 - + + + ) } @@ -176,13 +179,18 @@ describe('EdgeContextmenu', () => { latestEdges = [] }) - it('should not render when edgeMenu is absent', () => { - renderWorkflowFlowComponent(, { - nodes: createFlowNodes(), - edges: createFlowEdges(), - hooksStoreProps, - reactFlowProps: { fitView: false }, - }) + it('should not render when edge context menu target is absent', () => { + renderWorkflowFlowComponent( + + + , + { + nodes: createFlowNodes(), + edges: createFlowEdges(), + hooksStoreProps, + reactFlowProps: { fitView: false }, + }, + ) expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) @@ -209,11 +217,7 @@ describe('EdgeContextmenu', () => { }), ], initialStoreState: { - edgeMenu: { - clientX: 320, - clientY: 180, - edgeId: 'e2', - }, + contextMenuTarget: { type: 'edge', edgeId: 'e2' }, }, }) @@ -225,33 +229,32 @@ describe('EdgeContextmenu', () => { expect(latestEdges).toHaveLength(1) expect(latestEdges[0]!.id).toBe('e1') expect(latestEdges[0]!.selected).toBe(true) - expect(store.getState().edgeMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') }) it('should not render the menu when the referenced edge no longer exists', () => { - renderWorkflowFlowComponent(, { - nodes: createFlowNodes(), - edges: createFlowEdges(), - initialStoreState: { - edgeMenu: { - clientX: 320, - clientY: 180, - edgeId: 'missing-edge', + renderWorkflowFlowComponent( + + + , + { + nodes: createFlowNodes(), + edges: createFlowEdges(), + initialStoreState: { + contextMenuTarget: { type: 'edge', edgeId: 'missing-edge' }, }, + hooksStoreProps, + reactFlowProps: { fitView: false }, }, - hooksStoreProps, - reactFlowProps: { fitView: false }, - }) + ) expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) - it('should open the edge menu at the right-click position', async () => { - const fromRectSpy = vi.spyOn(DOMRect, 'fromRect') - + it('should open the edge menu for the right-clicked edge', async () => { renderEdgeMenu() fireEvent.contextMenu(screen.getByRole('button', { name: 'Right-click edge e2' }), { @@ -261,12 +264,6 @@ describe('EdgeContextmenu', () => { expect(await screen.findByRole('menu'))!.toBeInTheDocument() expect(screen.getByRole('menuitem', { name: /common:operation\.delete/i }))!.toBeInTheDocument() - expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({ - x: 320, - y: 180, - width: 0, - height: 0, - })) }) it('should delete the right-clicked edge and close the menu when delete is clicked', async () => { @@ -362,8 +359,6 @@ describe('EdgeContextmenu', () => { }) it('should retarget the menu and selected edge when right-clicking a different edge', async () => { - const fromRectSpy = vi.spyOn(DOMRect, 'fromRect') - renderEdgeMenu() const edgeOneButton = screen.getByLabelText('Right-click edge e1') const edgeTwoButton = screen.getByLabelText('Right-click edge e2') @@ -381,10 +376,6 @@ describe('EdgeContextmenu', () => { await waitFor(() => { expect(screen.getAllByRole('menu')).toHaveLength(1) - expect(fromRectSpy).toHaveBeenLastCalledWith(expect.objectContaining({ - x: 360, - y: 240, - })) expect(latestEdges.find(edge => edge.id === 'e1')?.selected).toBe(false) expect(latestEdges.find(edge => edge.id === 'e2')?.selected).toBe(true) expect(latestEdges.every(edge => !getEdgeRuntimeState(edge)._isBundled)).toBe(true) diff --git a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx index a34e655652..10b9437f7d 100644 --- a/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/node-contextmenu.spec.tsx @@ -1,65 +1,33 @@ import type { Node } from '../types' +import { ContextMenu } from '@langgenius/dify-ui/context-menu' import { fireEvent, render, screen } from '@testing-library/react' import { NodeContextmenu } from '../node-contextmenu' const mockUseNodes = vi.hoisted(() => vi.fn()) -const mockUsePanelInteractions = vi.hoisted(() => vi.fn()) const mockUseStore = vi.hoisted(() => vi.fn()) -const mockNodeActionsContextMenuContent = vi.hoisted(() => vi.fn()) -const mockContextMenuContent = vi.hoisted(() => vi.fn()) - -vi.mock('@langgenius/dify-ui/context-menu', () => ({ - ContextMenu: ({ children, onOpenChange }: { children: React.ReactNode, onOpenChange: (open: boolean) => void }) => ( -
- {children} - -
- ), - ContextMenuContent: ({ children, positionerProps, popupClassName }: { children: React.ReactNode, positionerProps?: { anchor?: unknown }, popupClassName?: string }) => { - mockContextMenuContent({ positionerProps, popupClassName }) - return
{children}
- }, -})) +const mockUseNodeActionsMenuModel = vi.hoisted(() => vi.fn()) vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ __esModule: true, default: () => mockUseNodes(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - usePanelInteractions: () => mockUsePanelInteractions(), -})) - vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: { nodeMenu?: { nodeId: string, clientX: number, clientY: number } }) => unknown) => mockUseStore(selector), + useStore: (selector: (state: { contextMenuTarget?: { type: 'node', nodeId: string } }) => unknown) => mockUseStore(selector), })) -vi.mock('@/app/components/workflow/node-actions-menu/context-menu-content', () => ({ - NodeActionsContextMenuContent: (props: { - id: string - data: Node['data'] - showHelpLink: boolean - onClose: () => void - }) => { - mockNodeActionsContextMenuContent(props) - return ( - - ) - }, +vi.mock('@/app/components/workflow/node-actions-menu/use-node-actions-menu-model', () => ({ + useNodeActionsMenuModel: (props: unknown) => mockUseNodeActionsMenuModel(props), })) describe('NodeContextmenu', () => { - const mockHandleNodeContextmenuCancel = vi.fn() - let nodeMenu: { nodeId: string, clientX: number, clientY: number } | undefined + const mockClose = vi.fn() + let contextMenuTarget: { type: 'node', nodeId: string } | undefined let nodes: Node[] beforeEach(() => { vi.clearAllMocks() - nodeMenu = undefined + contextMenuTarget = undefined nodes = [{ id: 'node-1', type: 'custom', @@ -72,49 +40,64 @@ describe('NodeContextmenu', () => { } as Node] mockUseNodes.mockImplementation(() => nodes) - mockUsePanelInteractions.mockReturnValue({ - handleNodeContextmenuCancel: mockHandleNodeContextmenuCancel, - }) - mockUseStore.mockImplementation((selector: (state: { nodeMenu?: { nodeId: string, clientX: number, clientY: number } }) => unknown) => selector({ nodeMenu })) + mockUseStore.mockImplementation((selector: (state: { contextMenuTarget?: { type: 'node', nodeId: string } }) => unknown) => selector({ contextMenuTarget })) + mockUseNodeActionsMenuModel.mockImplementation((props: { id: string, data: Node['data'], onClose: () => void }) => ({ + about: { + author: 'Dify', + description: 'Node actions', + }, + canChangeBlock: false, + canRun: false, + data: props.data, + handleCopy: props.onClose, + handleDelete: props.onClose, + handleDuplicate: props.onClose, + handleRun: props.onClose, + helpLinkUri: undefined, + id: props.id, + isSingleton: false, + isUndeletable: false, + nodesReadOnly: false, + sourceHandle: 'source', + workflowAppHref: undefined, + })) }) + const renderNodeContextmenu = () => render( + + + , + ) + it('should stay hidden when the node menu is absent', () => { - render() + renderNodeContextmenu() expect(screen.queryByRole('button')).not.toBeInTheDocument() - expect(mockNodeActionsContextMenuContent).not.toHaveBeenCalled() + expect(mockUseNodeActionsMenuModel).not.toHaveBeenCalled() }) it('should stay hidden when the referenced node cannot be found', () => { - nodeMenu = { nodeId: 'missing-node', clientX: 80, clientY: 120 } + contextMenuTarget = { type: 'node', nodeId: 'missing-node' } - render() + renderNodeContextmenu() expect(screen.queryByRole('button')).not.toBeInTheDocument() - expect(mockNodeActionsContextMenuContent).not.toHaveBeenCalled() + expect(mockUseNodeActionsMenuModel).not.toHaveBeenCalled() }) - it('should render the context menu at the stored pointer position and close on content/root actions', () => { - nodeMenu = { nodeId: 'node-1', clientX: 80, clientY: 120 } - render() + it('should render the node actions and close from content actions', () => { + contextMenuTarget = { type: 'node', nodeId: 'node-1' } + renderNodeContextmenu() - expect(screen.getByText('node-1:Node 1')).toBeInTheDocument() - expect(mockNodeActionsContextMenuContent).toHaveBeenCalledWith(expect.objectContaining({ + expect(screen.getByText('WORKFLOW.PANEL.ABOUT')).toBeInTheDocument() + expect(mockUseNodeActionsMenuModel).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1', data: expect.objectContaining({ title: 'Node 1' }), showHelpLink: true, })) - expect(mockContextMenuContent).toHaveBeenCalledWith(expect.objectContaining({ - popupClassName: 'w-[240px] rounded-lg', - })) - const anchor = mockContextMenuContent.mock.calls[0]![0].positionerProps.anchor as { getBoundingClientRect: () => DOMRect } - const rect = anchor.getBoundingClientRect() - expect(rect.x).toBe(80) - expect(rect.y).toBe(120) - fireEvent.click(screen.getByText('node-1:Node 1')) - fireEvent.click(screen.getByText('close-context-menu')) + fireEvent.click(screen.getByRole('menuitem', { name: /workflow\.common\.copy/i })) - expect(mockHandleNodeContextmenuCancel).toHaveBeenCalledTimes(2) + expect(mockClose).toHaveBeenCalledTimes(1) }) }) diff --git a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx index 6250ba45bd..1f3ff385da 100644 --- a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx @@ -1,5 +1,6 @@ +import { ContextMenu } from '@langgenius/dify-ui/context-menu' import { fireEvent, screen, waitFor } from '@testing-library/react' -import PanelContextmenu from '../panel-contextmenu' +import { PanelContextmenu } from '../panel-contextmenu' import { BlockEnum } from '../types' import { createNode } from './fixtures' import { renderWorkflowFlowComponent } from './workflow-test-env' @@ -38,7 +39,7 @@ vi.mock('@/app/components/workflow/operator/hooks', () => ({ describe('PanelContextmenu', () => { const mockHandleNodesPaste = vi.fn() - const mockHandlePaneContextmenuCancel = vi.fn() + const mockClose = vi.fn() const mockHandleStartWorkflowRun = vi.fn() const mockHandleWorkflowStartRunInChatflow = vi.fn() const mockHandleAddNote = vi.fn() @@ -61,9 +62,7 @@ describe('PanelContextmenu', () => { mockUseNodesInteractions.mockReturnValue({ handleNodesPaste: mockHandleNodesPaste, }) - mockUsePanelInteractions.mockReturnValue({ - handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, - }) + mockUsePanelInteractions.mockReturnValue({}) mockUseWorkflowStartRun.mockReturnValue({ handleStartWorkflowRun: mockHandleStartWorkflowRun, handleWorkflowStartRunInChatflow: mockHandleWorkflowStartRunInChatflow, @@ -89,16 +88,25 @@ describe('PanelContextmenu', () => { mockUseIsChatMode.mockReturnValue(false) }) + const renderPanelContextmenu = (options?: Parameters[1]) => { + return renderWorkflowFlowComponent( + + + , + options, + ) + } + it('should stay hidden when the panel menu is absent', () => { - renderWorkflowFlowComponent() + renderPanelContextmenu() expect(screen.queryByText('common.addBlock')).not.toBeInTheDocument() }) it('should keep paste disabled when the clipboard is empty', async () => { - renderWorkflowFlowComponent(, { + renderPanelContextmenu({ initialStoreState: { - panelMenu: { clientX: 24, clientY: 48 }, + contextMenuTarget: { type: 'panel' }, }, hooksStoreProps: {}, }) @@ -107,13 +115,13 @@ describe('PanelContextmenu', () => { fireEvent.click(screen.getByText('common.pasteHere')) expect(mockHandleNodesPaste).not.toHaveBeenCalled() - expect(mockHandlePaneContextmenuCancel).not.toHaveBeenCalled() + expect(mockClose).not.toHaveBeenCalled() }) it('should render actions and execute enabled actions', async () => { - const { store } = renderWorkflowFlowComponent(, { + const { store } = renderPanelContextmenu({ initialStoreState: { - panelMenu: { clientX: 24, clientY: 48 }, + contextMenuTarget: { type: 'panel' }, clipboardElements: [createNode({ id: 'copied-node' })], }, hooksStoreProps: {}, @@ -141,9 +149,9 @@ describe('PanelContextmenu', () => { it('should render preview action in chat mode', async () => { mockUseIsChatMode.mockReturnValue(true) - renderWorkflowFlowComponent(, { + renderPanelContextmenu({ initialStoreState: { - panelMenu: { clientX: 24, clientY: 48 }, + contextMenuTarget: { type: 'panel' }, }, hooksStoreProps: {}, }) @@ -156,7 +164,7 @@ describe('PanelContextmenu', () => { await waitFor(() => { expect(mockHandleWorkflowStartRunInChatflow).toHaveBeenCalledTimes(1) expect(mockHandleStartWorkflowRun).not.toHaveBeenCalled() - expect(mockHandlePaneContextmenuCancel).toHaveBeenCalled() + expect(mockClose).toHaveBeenCalled() }) }) }) diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index 1d1f375412..6cb87cfcfb 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -1,8 +1,10 @@ import type { Edge, Node } from '../types' +import { ContextMenu } from '@langgenius/dify-ui/context-menu' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { useEffect } from 'react' import { useNodes } from 'reactflow' -import SelectionContextmenu from '../selection-contextmenu' +import { SelectionContextmenu } from '../selection-contextmenu' +import { useWorkflowStore } from '../store' import { useWorkflowHistoryStore } from '../workflow-history-store' import { createEdge, createNode } from './fixtures' import { renderWorkflowFlowComponent } from './workflow-test-env' @@ -47,6 +49,18 @@ const hooksStoreProps = { doSyncWorkflowDraft: vi.fn().mockResolvedValue(undefined), } +const SelectionMenuHarness = () => { + const workflowStore = useWorkflowStore() + + return ( + + workflowStore.getState().setContextMenuTarget(undefined)} + /> + + ) +} + const renderSelectionMenu = (options?: { nodes?: Node[] edges?: Edge[] @@ -61,7 +75,7 @@ const renderSelectionMenu = (options?: { return renderWorkflowFlowComponent(
- +
, { nodes, @@ -86,13 +100,13 @@ describe('SelectionContextmenu', () => { mockHandleNodesDelete.mockReset() }) - it('should not render when selectionMenu is absent', () => { + it('should not render when selection context menu target is absent', () => { renderSelectionMenu() expect(screen.queryByText('operator.vertical')).not.toBeInTheDocument() }) - it('should render menu items when selectionMenu is present', async () => { + it('should render menu items when selection context menu target is present', async () => { const nodes = [ createNode({ id: 'n1', selected: true, width: 80, height: 40 }), createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }), @@ -100,7 +114,7 @@ describe('SelectionContextmenu', () => { const { store } = renderSelectionMenu({ nodes }) act(() => { - store.setState({ selectionMenu: { clientX: 780, clientY: 590 } }) + store.setState({ contextMenuTarget: { type: 'selection' } }) }) await waitFor(() => { @@ -116,7 +130,7 @@ describe('SelectionContextmenu', () => { const { store } = renderSelectionMenu({ nodes }) act(() => { - store.setState({ selectionMenu: { clientX: 120, clientY: 120 } }) + store.setState({ contextMenuTarget: { type: 'selection' } }) }) await waitFor(() => { @@ -125,24 +139,24 @@ describe('SelectionContextmenu', () => { fireEvent.click(screen.getByRole('menuitem', { name: /common.copy/ })) expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1) - expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() act(() => { - store.setState({ selectionMenu: { clientX: 120, clientY: 120 } }) + store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByRole('menuitem', { name: /common.duplicate/ })) expect(mockHandleNodesDuplicate).toHaveBeenCalledTimes(1) - expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() act(() => { - store.setState({ selectionMenu: { clientX: 120, clientY: 120 } }) + store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByRole('menuitem', { name: /operation.delete/ })) expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1) - expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() }) - it('should close itself when only one node is selected', async () => { + it('should stay hidden when only one node is selected', async () => { const nodes = [ createNode({ id: 'n1', selected: true, width: 80, height: 40 }), ] @@ -150,11 +164,11 @@ describe('SelectionContextmenu', () => { const { store } = renderSelectionMenu({ nodes }) act(() => { - store.setState({ selectionMenu: { clientX: 120, clientY: 120 } }) + store.setState({ contextMenuTarget: { type: 'selection' } }) }) await waitFor(() => { - expect(store.getState().selectionMenu).toBeUndefined() + expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) }) @@ -175,14 +189,14 @@ describe('SelectionContextmenu', () => { }) act(() => { - store.setState({ selectionMenu: { clientX: 100, clientY: 100 } }) + store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(20) expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(20) - expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() expect(store.getState().helpLineHorizontal).toBeUndefined() expect(store.getState().helpLineVertical).toBeUndefined() @@ -208,7 +222,7 @@ describe('SelectionContextmenu', () => { }) act(() => { - store.setState({ selectionMenu: { clientX: 160, clientY: 120 } }) + store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByTestId('selection-contextmenu-item-distributeHorizontal')) @@ -247,7 +261,7 @@ describe('SelectionContextmenu', () => { }) act(() => { - store.setState({ selectionMenu: { clientX: 180, clientY: 120 } }) + store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) @@ -266,12 +280,12 @@ describe('SelectionContextmenu', () => { const { store } = renderSelectionMenu({ nodes }) act(() => { - store.setState({ selectionMenu: { clientX: 100, clientY: 100 } }) + store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) - expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() }) it('should cancel without aligning when nodes are read only', () => { @@ -284,12 +298,12 @@ describe('SelectionContextmenu', () => { const { store } = renderSelectionMenu({ nodes }) act(() => { - store.setState({ selectionMenu: { clientX: 100, clientY: 100 } }) + store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) - expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() expect(latestNodes.find(node => node.id === 'n1')?.position.x).toBe(0) expect(latestNodes.find(node => node.id === 'n2')?.position.x).toBe(80) }) @@ -309,12 +323,12 @@ describe('SelectionContextmenu', () => { const { store } = renderSelectionMenu({ nodes }) act(() => { - store.setState({ selectionMenu: { clientX: 100, clientY: 100 } }) + store.setState({ contextMenuTarget: { type: 'selection' } }) }) fireEvent.click(screen.getByTestId('selection-contextmenu-item-left')) - expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() 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/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index 2bbe12f1c0..f0b358efff 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -302,14 +302,6 @@ vi.mock('../help-line', () => ({ default: () => null, })) -vi.mock('../edge-contextmenu', () => ({ - default: () => null, -})) - -vi.mock('../node-contextmenu', () => ({ - NodeContextmenu: () => null, -})) - vi.mock('../nodes', () => ({ default: ({ id }: { id: string }) => React.createElement('div', { 'data-testid': `workflow-node-${id}` }, `Workflow node ${id}`), })) @@ -338,14 +330,6 @@ vi.mock('../operator/control', () => ({ default: () => null, })) -vi.mock('../panel-contextmenu', () => ({ - default: () => null, -})) - -vi.mock('../selection-contextmenu', () => ({ - default: () => null, -})) - vi.mock('../simple-node', () => ({ default: () => null, })) @@ -367,6 +351,10 @@ vi.mock('../hooks', () => ({ handleEdgeContextMenu: workflowHookMocks.handleEdgeContextMenu, }), useNodesInteractions: () => ({ + handleNodesCopy: vi.fn(), + handleNodesDelete: vi.fn(), + handleNodesDuplicate: vi.fn(), + handleNodesPaste: vi.fn(), handleNodeDragStart: workflowHookMocks.handleNodeDragStart, handleNodeDrag: workflowHookMocks.handleNodeDrag, handleNodeDragStop: workflowHookMocks.handleNodeDragStop, @@ -390,8 +378,11 @@ vi.mock('../hooks', () => ({ }), usePanelInteractions: () => ({ handlePaneContextMenu: workflowHookMocks.handlePaneContextMenu, - handleEdgeContextmenuCancel: vi.fn(), }), + useDSL: () => ({ + exportCheck: vi.fn(), + }), + useIsChatMode: () => false, useSelectionInteractions: () => ({ handleSelectionStart: workflowHookMocks.handleSelectionStart, handleSelectionChange: workflowHookMocks.handleSelectionChange, @@ -408,9 +399,16 @@ vi.mock('../hooks', () => ({ useWorkflowReadOnly: () => ({ workflowReadOnly: false, }), + useWorkflowMoveMode: () => ({ + isCommentModeAvailable: false, + }), useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: vi.fn(), }), + useWorkflowStartRun: () => ({ + handleStartWorkflowRun: vi.fn(), + handleWorkflowStartRunInChatflow: vi.fn(), + }), })) vi.mock('../hooks/use-workflow-search', () => ({ @@ -551,14 +549,10 @@ describe('Workflow edge event wiring', () => { ])) }) - it('should clear edgeMenu when workflow data updates remove the current edge', () => { + it('should clear context menu target when workflow data updates', () => { const { store } = renderSubject({ initialStoreState: { - edgeMenu: { - clientX: 320, - clientY: 180, - edgeId: 'edge-1', - }, + contextMenuTarget: { type: 'edge', edgeId: 'edge-1' }, }, }) @@ -572,7 +566,7 @@ describe('Workflow edge event wiring', () => { }) }) - expect(store.getState().edgeMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() }) it('should render confirm description and clear showConfirm when cancelled', async () => { diff --git a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx index 8dc1e81379..dedf64c56e 100644 --- a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx @@ -1,3 +1,4 @@ +import type { ButtonHTMLAttributes } from 'react' import type { NodeDefault } from '../../types' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -63,7 +64,10 @@ describe('NodeSelector', () => { />, ) - await user.click(screen.getByRole('button', { name: 'selector-closed' })) + const trigger = screen.getByRole('button', { name: 'selector-closed' }) + expect(trigger.closest('[aria-haspopup="dialog"]')).toBe(trigger) + + await user.click(trigger) const searchInput = screen.getByPlaceholderText('workflow.tabs.searchBlock') expect(screen.getByText('LLM')).toBeInTheDocument() @@ -112,13 +116,12 @@ describe('NodeSelector', () => { expect(screen.queryByPlaceholderText('workflow.tabs.searchBlock')).not.toBeInTheDocument() }) - it('preserves the child trigger click handler when rendered as child', async () => { + it('preserves the custom trigger click handler', async () => { const user = userEvent.setup() const onTriggerClick = vi.fn() renderWorkflowComponent( { expect(onTriggerClick).toHaveBeenCalledTimes(1) expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() }) + + it('opens when a custom component trigger does not forward props', async () => { + const user = userEvent.setup() + + function TriggerShell() { + return ( + + open-from-shell + + ) + } + + renderWorkflowComponent( + } + />, + ) + + await user.click(screen.getByRole('button', { name: 'open-from-shell' })) + + expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() + }) + + it('can render a prop-forwarding button component as the popover root', async () => { + const user = userEvent.setup() + + function ForwardingButtonTrigger(props: ButtonHTMLAttributes) { + return ( + + ) + } + + renderWorkflowComponent( + } + />, + ) + + const trigger = screen.getByTestId('selector-root-trigger') + await user.click(trigger) + + expect(trigger.closest('[aria-haspopup="dialog"]')).toBe(trigger) + expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() + }) }) diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 34f1798583..335d329690 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -3,9 +3,7 @@ import type { Placement, } from '@floating-ui/react' import type { - FC, MouseEventHandler, - MouseEvent as ReactMouseEvent, } from 'react' import type { CommonNodeType, @@ -48,8 +46,8 @@ export type NodeSelectorProps = { triggerStyle?: React.CSSProperties triggerClassName?: (open: boolean) => string triggerInnerClassName?: string + renderTriggerAsButtonRoot?: boolean popupClassName?: string - asChild?: boolean availableBlocksTypes?: BlockEnum[] disabled?: boolean blocks?: NodeDefault[] @@ -63,7 +61,7 @@ export type NodeSelectorProps = { forceEnableStartTab?: boolean // Force enabling Start tab regardless of existing trigger/user input nodes (e.g., when changing Start node type). allowUserInputSelection?: boolean // Override user-input availability; default logic blocks it when triggers exist. } -const NodeSelector: FC = ({ +function NodeSelector({ open: openFromProps, onOpenChange, onSelect, @@ -72,9 +70,9 @@ const NodeSelector: FC = ({ offset = 6, triggerClassName, triggerInnerClassName, + renderTriggerAsButtonRoot = false, triggerStyle, popupClassName, - asChild, availableBlocksTypes, disabled, blocks = [], @@ -87,7 +85,7 @@ const NodeSelector: FC = ({ ignoreNodeIds = [], forceEnableStartTab = false, allowUserInputSelection, -}) => { +}: NodeSelectorProps) { const { t } = useTranslation() const nodes = useNodes() const [searchText, setSearchText] = useState('') @@ -191,36 +189,19 @@ const NodeSelector: FC = ({ ) const triggerElement = trigger?.(open) - const shouldRenderTriggerElementAsRoot = React.isValidElement(triggerElement) - && (asChild || triggerElement.type === 'button') - const triggerElementProps = React.isValidElement(triggerElement) - ? (triggerElement.props as { - onClick?: MouseEventHandler - }) - : null - const resolvedTriggerElement = shouldRenderTriggerElementAsRoot - ? React.cloneElement( - triggerElement as React.ReactElement<{ - onClick?: MouseEventHandler - }>, - { - onClick: (e: ReactMouseEvent) => { - handleTrigger(e) - if (typeof triggerElementProps?.onClick === 'function') - triggerElementProps.onClick(e) - }, - }, - ) + const isValidTriggerElement = React.isValidElement(triggerElement) + const isNativeButtonTrigger = isValidTriggerElement && triggerElement.type === 'button' + const shouldRenderTriggerAsButtonRoot = isValidTriggerElement && (renderTriggerAsButtonRoot || isNativeButtonTrigger) + const resolvedTriggerElement = shouldRenderTriggerAsButtonRoot + ? triggerElement : ( -
+
{triggerElement}
) const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset const sideOffset = typeof offset === 'number' ? offset : (resolvedOffset?.mainAxis ?? 0) const alignOffset = typeof offset === 'number' ? 0 : (resolvedOffset?.crossAxis ?? 0) - const nativeButton = shouldRenderTriggerElementAsRoot - && (typeof triggerElement.type !== 'string' || triggerElement.type === 'button') return ( = ({ onOpenChange={handleOpenChange} > {trigger - ? + ? ( + + ) : defaultTriggerElement} 'hover:scale-150 transition-all'} diff --git a/web/app/components/workflow/edge-contextmenu.tsx b/web/app/components/workflow/edge-contextmenu.tsx index e4f8ef95e0..f333b99cc8 100644 --- a/web/app/components/workflow/edge-contextmenu.tsx +++ b/web/app/components/workflow/edge-contextmenu.tsx @@ -1,63 +1,44 @@ import { - ContextMenu, ContextMenuContent, ContextMenuItem, } from '@langgenius/dify-ui/context-menu' -import { - memo, - useMemo, -} from 'react' import { useTranslation } from 'react-i18next' import { useEdges } from 'reactflow' -import { useEdgesInteractions, usePanelInteractions } from './hooks' +import { useEdgesInteractions } from './hooks' import { ShortcutKbd } from './shortcuts/shortcut-kbd' import { useStore } from './store' -const EdgeContextmenu = () => { +export function EdgeContextmenu({ + onClose, +}: { + onClose: () => void +}) { const { t } = useTranslation() - const edgeMenu = useStore(s => s.edgeMenu) + const contextMenuTarget = useStore(s => s.contextMenuTarget) + const edgeId = contextMenuTarget?.type === 'edge' ? contextMenuTarget.edgeId : undefined const { handleEdgeDeleteById } = useEdgesInteractions() - const { handleEdgeContextmenuCancel } = usePanelInteractions() const edges = useEdges() - const currentEdgeExists = !edgeMenu || edges.some(edge => edge.id === edgeMenu.edgeId) + const currentEdgeExists = !edgeId || edges.some(edge => edge.id === edgeId) - const anchor = useMemo(() => { - if (!edgeMenu || !currentEdgeExists) - return null - - return { - getBoundingClientRect: () => DOMRect.fromRect({ - width: 0, - height: 0, - x: edgeMenu.clientX, - y: edgeMenu.clientY, - }), - } - }, [currentEdgeExists, edgeMenu]) - - if (!edgeMenu || !currentEdgeExists || !anchor) + if (!edgeId || !currentEdgeExists) return null return ( - !open && handleEdgeContextmenuCancel()} + - { + handleEdgeDeleteById(edgeId) + onClose() + }} > - handleEdgeDeleteById(edgeMenu.edgeId)} - > - {t('common:operation.delete')} - - - - + {t('common:operation.delete')} + + + ) } - -export default memo(EdgeContextmenu) diff --git a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.helpers.spec.ts b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.helpers.spec.ts index 1608cc18ce..136c467023 100644 --- a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.helpers.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.helpers.spec.ts @@ -45,12 +45,12 @@ describe('use-edges-interactions.helpers', () => { it('clearEdgeMenuIfNeeded should return true only when the open menu belongs to a removed edge', () => { expect(clearEdgeMenuIfNeeded({ - edgeMenu: { edgeId: 'edge-1' }, + contextMenuTarget: { type: 'edge', edgeId: 'edge-1' }, edgeIds: ['edge-1', 'edge-2'], })).toBe(true) expect(clearEdgeMenuIfNeeded({ - edgeMenu: { edgeId: 'edge-3' }, + contextMenuTarget: { type: 'edge', edgeId: 'edge-3' }, edgeIds: ['edge-1', 'edge-2'], })).toBe(false) diff --git a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts index 0d88a2b0c3..3d46370694 100644 --- a/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-edges-interactions.spec.ts @@ -146,7 +146,7 @@ describe('useEdgesInteractions', () => { }) }) - it('handleEdgeContextMenu should select the clicked edge and open edgeMenu', async () => { + it('handleEdgeContextMenu should select the clicked edge and set the edge context menu target', async () => { const preventDefault = vi.fn() const { result, store } = renderEdgesInteractions({ nodes: [ @@ -196,14 +196,7 @@ describe('useEdgesInteractions', () => { expect(result.current.nodes.every(node => !getNodeRuntimeState(node).selected && !node.selected && !getNodeRuntimeState(node)._isBundled)).toBe(true) }) - expect(store.getState().edgeMenu).toEqual({ - clientX: 320, - clientY: 180, - edgeId: 'e2', - }) - expect(store.getState().nodeMenu).toBeUndefined() - expect(store.getState().panelMenu).toBeUndefined() - expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toEqual({ type: 'edge', edgeId: 'e2' }) }) it('handleEdgeDelete should remove selected edge and trigger sync + history', async () => { @@ -226,7 +219,7 @@ describe('useEdgesInteractions', () => { }), ], initialStoreState: { - edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' }, + contextMenuTarget: { type: 'edge', edgeId: 'e1' }, }, }) @@ -239,7 +232,7 @@ describe('useEdgesInteractions', () => { expect(result.current.edges[0]?.id).toBe('e2') }) - expect(store.getState().edgeMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') }) @@ -273,7 +266,7 @@ describe('useEdgesInteractions', () => { }), ], initialStoreState: { - edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e2' }, + contextMenuTarget: { type: 'edge', edgeId: 'e2' }, }, }) @@ -287,7 +280,7 @@ describe('useEdgesInteractions', () => { expect(result.current.edges[0]?.selected).toBe(true) }) - expect(store.getState().edgeMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDelete') }) @@ -305,7 +298,7 @@ describe('useEdgesInteractions', () => { it('handleEdgeDeleteByDeleteBranch should remove edges for the given branch', async () => { const { result, store } = renderEdgesInteractions({ initialStoreState: { - edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' }, + contextMenuTarget: { type: 'edge', edgeId: 'e1' }, }, }) @@ -318,7 +311,7 @@ describe('useEdgesInteractions', () => { expect(result.current.edges[0]?.id).toBe('e2') }) - expect(store.getState().edgeMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeDeleteByDeleteBranch') }) @@ -346,7 +339,7 @@ describe('useEdgesInteractions', () => { }) }) - it('handleEdgeSourceHandleChange should clear edgeMenu and save history for affected edges', async () => { + it('handleEdgeSourceHandleChange should clear the context menu target and save history for affected edges', async () => { const { result, store } = renderEdgesInteractions({ edges: [ createEdge({ @@ -359,7 +352,7 @@ describe('useEdgesInteractions', () => { }), ], initialStoreState: { - edgeMenu: { clientX: 120, clientY: 60, edgeId: 'n1-old-handle-n2-target' }, + contextMenuTarget: { type: 'edge', edgeId: 'n1-old-handle-n2-target' }, }, }) @@ -371,7 +364,7 @@ describe('useEdgesInteractions', () => { expect(result.current.edges[0]?.sourceHandle).toBe('new-handle') }) - expect(store.getState().edgeMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() expect(mockSaveStateToHistory).toHaveBeenCalledWith('EdgeSourceHandleChange') }) @@ -445,13 +438,14 @@ describe('useEdgesInteractions', () => { act(() => { result.current.handleEdgeContextMenu({ preventDefault: vi.fn(), + stopPropagation: vi.fn(), clientX: 200, clientY: 120, } as never, result.current.edges[0] as never) }) expect(result.current.edges.every(edge => !edge.selected)).toBe(true) - expect(store.getState().edgeMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() }) it('handleEdgeDeleteByDeleteBranch should do nothing', () => { diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts index ec961c3530..220b73ea7c 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts @@ -177,9 +177,7 @@ describe('useNodesInteractions', () => { const { result, store } = renderWorkflowHook(() => useNodesInteractions(), { initialStoreState: { - edgeMenu: { - id: 'edge-1', - } as never, + contextMenuTarget: { type: 'edge', edgeId: 'edge-1' }, }, historyStore: { nodes: historyNodes, @@ -194,7 +192,7 @@ describe('useNodesInteractions', () => { expect(mockUndo).toHaveBeenCalledTimes(1) expect(rfState.setNodes).toHaveBeenCalledWith(historyNodes) expect(rfState.setEdges).toHaveBeenCalledWith(historyEdges) - expect(store.getState().edgeMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() }) it('skips undo and redo when the workflow is read-only', () => { diff --git a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts index 8452087b7c..45f0bbb92f 100644 --- a/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-panel-interactions.spec.ts @@ -36,12 +36,10 @@ describe('usePanelInteractions', () => { container.remove() }) - it('handlePaneContextMenu should set panelMenu with viewport coordinates', () => { + it('handlePaneContextMenu should set the panel context menu target', () => { const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { initialStoreState: { - nodeMenu: { clientX: 40, clientY: 20, nodeId: 'n1' }, - selectionMenu: { clientX: 30, clientY: 50 }, - edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' }, + contextMenuTarget: { type: 'node', nodeId: 'n1' }, }, }) const preventDefault = vi.fn() @@ -53,13 +51,7 @@ describe('usePanelInteractions', () => { } as unknown as React.MouseEvent) expect(preventDefault).toHaveBeenCalled() - expect(store.getState().panelMenu).toEqual({ - clientX: 350, - clientY: 250, - }) - expect(store.getState().nodeMenu).toBeUndefined() - expect(store.getState().selectionMenu).toBeUndefined() - expect(store.getState().edgeMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toEqual({ type: 'panel' }) }) it('handlePaneContextMenu should sync clipboard from navigator clipboard', async () => { @@ -90,33 +82,13 @@ describe('usePanelInteractions', () => { }) }) - it('handlePaneContextmenuCancel should clear panelMenu', () => { + it('handlePaneContextmenuCancel should clear the context menu target', () => { const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { - initialStoreState: { panelMenu: { clientX: 20, clientY: 10 } }, + initialStoreState: { contextMenuTarget: { type: 'panel' } }, }) result.current.handlePaneContextmenuCancel() - expect(store.getState().panelMenu).toBeUndefined() - }) - - it('handleNodeContextmenuCancel should clear nodeMenu', () => { - const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { - initialStoreState: { nodeMenu: { clientX: 20, clientY: 10, nodeId: 'n1' } }, - }) - - result.current.handleNodeContextmenuCancel() - - expect(store.getState().nodeMenu).toBeUndefined() - }) - - it('handleEdgeContextmenuCancel should clear edgeMenu', () => { - const { result, store } = renderWorkflowHook(() => usePanelInteractions(), { - initialStoreState: { edgeMenu: { clientX: 300, clientY: 200, edgeId: 'e1' } }, - }) - - result.current.handleEdgeContextmenuCancel() - - expect(store.getState().edgeMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toBeUndefined() }) }) diff --git a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts index 894c40c4f6..c899a0634a 100644 --- a/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts @@ -173,9 +173,7 @@ describe('useSelectionInteractions', () => { it('handleSelectionContextMenu should set menu only when clicking on selection rect', () => { const { result, store } = renderSelectionInteractions({ - nodeMenu: { clientX: 20, clientY: 10, nodeId: 'n1' }, - panelMenu: { clientX: 40, clientY: 30 }, - edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' }, + contextMenuTarget: { type: 'node', nodeId: 'n1' }, }) const wrongTarget = document.createElement('div') @@ -190,7 +188,7 @@ describe('useSelectionInteractions', () => { } as unknown as React.MouseEvent) }) - expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toEqual({ type: 'node', nodeId: 'n1' }) const correctTarget = document.createElement('div') correctTarget.classList.add('react-flow__nodesselection-rect') @@ -204,24 +202,6 @@ describe('useSelectionInteractions', () => { } as unknown as React.MouseEvent) }) - expect(store.getState().selectionMenu).toEqual({ - clientX: 300, - clientY: 200, - }) - expect(store.getState().nodeMenu).toBeUndefined() - expect(store.getState().panelMenu).toBeUndefined() - expect(store.getState().edgeMenu).toBeUndefined() - }) - - it('handleSelectionContextmenuCancel should clear selectionMenu', () => { - const { result, store } = renderSelectionInteractions({ - selectionMenu: { clientX: 50, clientY: 60 }, - }) - - act(() => { - result.current.handleSelectionContextmenuCancel() - }) - - expect(store.getState().selectionMenu).toBeUndefined() + expect(store.getState().contextMenuTarget).toEqual({ type: 'selection' }) }) }) diff --git a/web/app/components/workflow/hooks/use-edges-interactions.helpers.ts b/web/app/components/workflow/hooks/use-edges-interactions.helpers.ts index a19380b58b..fad92a59c0 100644 --- a/web/app/components/workflow/hooks/use-edges-interactions.helpers.ts +++ b/web/app/components/workflow/hooks/use-edges-interactions.helpers.ts @@ -1,4 +1,5 @@ import type { Edge, EdgeChange } from 'reactflow' +import type { WorkflowContextMenuTarget } from '../store/workflow/panel-slice' import type { Node } from '../types' import { produce } from 'immer' import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils' @@ -22,15 +23,13 @@ export const applyConnectedHandleNodeData = ( } export const clearEdgeMenuIfNeeded = ({ - edgeMenu, + contextMenuTarget, edgeIds, }: { - edgeMenu?: { - edgeId: string - } + contextMenuTarget?: WorkflowContextMenuTarget edgeIds: string[] }) => { - return !!(edgeMenu && edgeIds.includes(edgeMenu.edgeId)) + return !!(contextMenuTarget?.type === 'edge' && edgeIds.includes(contextMenuTarget.edgeId)) } export const updateEdgeHoverState = ( diff --git a/web/app/components/workflow/hooks/use-edges-interactions.ts b/web/app/components/workflow/hooks/use-edges-interactions.ts index 4f24c36a16..70a1189caf 100644 --- a/web/app/components/workflow/hooks/use-edges-interactions.ts +++ b/web/app/components/workflow/hooks/use-edges-interactions.ts @@ -45,8 +45,8 @@ export const useEdgesInteractions = () => { draft.splice(currentEdgeIndex, 1) }) setEdges(newEdges) - if (clearEdgeMenuIfNeeded({ edgeMenu: workflowStore.getState().edgeMenu, edgeIds: [currentEdge!.id] })) - workflowStore.setState({ edgeMenu: undefined }) + if (clearEdgeMenuIfNeeded({ contextMenuTarget: workflowStore.getState().contextMenuTarget, edgeIds: [currentEdge!.id] })) + workflowStore.setState({ contextMenuTarget: undefined }) handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.EdgeDelete) }, [collaborativeWorkflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory]) @@ -92,10 +92,10 @@ export const useEdgesInteractions = () => { }) setEdges(newEdges) if (clearEdgeMenuIfNeeded({ - edgeMenu: workflowStore.getState().edgeMenu, + contextMenuTarget: workflowStore.getState().contextMenuTarget, edgeIds: edgeWillBeDeleted.map(edge => edge.id), })) { - workflowStore.setState({ edgeMenu: undefined }) + workflowStore.setState({ contextMenuTarget: undefined }) } handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch) @@ -166,18 +166,20 @@ export const useEdgesInteractions = () => { }) setEdges(newEdges) if (clearEdgeMenuIfNeeded({ - edgeMenu: workflowStore.getState().edgeMenu, + contextMenuTarget: workflowStore.getState().contextMenuTarget, edgeIds: affectedEdges.map(edge => edge.id), })) { - workflowStore.setState({ edgeMenu: undefined }) + workflowStore.setState({ contextMenuTarget: undefined }) } handleSyncWorkflowDraft() saveStateToHistory(WorkflowHistoryEvent.EdgeSourceHandleChange) }, [getNodesReadOnly, collaborativeWorkflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory]) const handleEdgeContextMenu = useCallback((e, edge) => { - if (getNodesReadOnly()) + if (getNodesReadOnly()) { + e.stopPropagation() return + } e.preventDefault() @@ -188,12 +190,8 @@ export const useEdgesInteractions = () => { } workflowStore.setState({ - nodeMenu: undefined, - panelMenu: undefined, - selectionMenu: undefined, - edgeMenu: { - clientX: e.clientX, - clientY: e.clientY, + contextMenuTarget: { + type: 'edge', edgeId: edge.id, }, }) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index fcf7fe9714..0b436c9266 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1685,6 +1685,7 @@ export const useNodesInteractions = () => { node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_ITERATION_START_NODE ) { + e.stopPropagation() return } @@ -1692,17 +1693,14 @@ export const useNodesInteractions = () => { node.type === CUSTOM_NOTE_NODE || node.type === CUSTOM_LOOP_START_NODE ) { + e.stopPropagation() return } e.preventDefault() workflowStore.setState({ - panelMenu: undefined, - selectionMenu: undefined, - edgeMenu: undefined, - nodeMenu: { - clientX: e.clientX, - clientY: e.clientY, + contextMenuTarget: { + type: 'node', nodeId: node.id, }, }) @@ -2474,7 +2472,7 @@ export const useNodesInteractions = () => { setNodes(nodes, shouldBroadcast, 'nodes:history-back') if (shouldBroadcast) collaborationManager.emitHistoryAction('undo') - workflowStore.setState({ edgeMenu: undefined }) + workflowStore.setState({ contextMenuTarget: undefined }) }, [ collaborativeWorkflow, workflowStore, @@ -2499,7 +2497,7 @@ export const useNodesInteractions = () => { setNodes(nodes, shouldBroadcast, 'nodes:history-forward') if (shouldBroadcast) collaborationManager.emitHistoryAction('redo') - workflowStore.setState({ edgeMenu: undefined }) + workflowStore.setState({ contextMenuTarget: undefined }) }, [ collaborativeWorkflow, redo, diff --git a/web/app/components/workflow/hooks/use-panel-interactions.ts b/web/app/components/workflow/hooks/use-panel-interactions.ts index 687a80613d..c8a2ddf633 100644 --- a/web/app/components/workflow/hooks/use-panel-interactions.ts +++ b/web/app/components/workflow/hooks/use-panel-interactions.ts @@ -23,38 +23,16 @@ export const usePanelInteractions = () => { }) workflowStore.setState({ - nodeMenu: undefined, - selectionMenu: undefined, - edgeMenu: undefined, - panelMenu: { - clientX: e.clientX, - clientY: e.clientY, - }, + contextMenuTarget: { type: 'panel' }, }) }, [workflowStore, appDslVersion]) const handlePaneContextmenuCancel = useCallback(() => { - workflowStore.setState({ - panelMenu: undefined, - }) - }, [workflowStore]) - - const handleNodeContextmenuCancel = useCallback(() => { - workflowStore.setState({ - nodeMenu: undefined, - }) - }, [workflowStore]) - - const handleEdgeContextmenuCancel = useCallback(() => { - workflowStore.setState({ - edgeMenu: undefined, - }) + workflowStore.setState({ contextMenuTarget: undefined }) }, [workflowStore]) return { handlePaneContextMenu, handlePaneContextmenuCancel, - handleNodeContextmenuCancel, - handleEdgeContextmenuCancel, } } diff --git a/web/app/components/workflow/hooks/use-selection-interactions.ts b/web/app/components/workflow/hooks/use-selection-interactions.ts index c97a62a340..cfcbb8c217 100644 --- a/web/app/components/workflow/hooks/use-selection-interactions.ts +++ b/web/app/components/workflow/hooks/use-selection-interactions.ts @@ -141,19 +141,7 @@ export const useSelectionInteractions = () => { e.preventDefault() workflowStore.setState({ - nodeMenu: undefined, - panelMenu: undefined, - edgeMenu: undefined, - selectionMenu: { - clientX: e.clientX, - clientY: e.clientY, - }, - }) - }, [workflowStore]) - - const handleSelectionContextmenuCancel = useCallback(() => { - workflowStore.setState({ - selectionMenu: undefined, + contextMenuTarget: { type: 'selection' }, }) }, [workflowStore]) @@ -163,6 +151,5 @@ export const useSelectionInteractions = () => { handleSelectionDrag, handleSelectionCancel, handleSelectionContextMenu, - handleSelectionContextmenuCancel, } } diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index d946ad4a97..5fe099787b 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -77,7 +77,6 @@ import { import CustomConnectionLine from './custom-connection-line' import CustomEdge from './custom-edge' import DatasetsDetailProvider from './datasets-detail-store/provider' -import EdgeContextmenu from './edge-contextmenu' import HelpLine from './help-line' import { useEdgesInteractions, @@ -94,7 +93,6 @@ import { import { HooksStoreContextProvider, useHooksStore } from './hooks-store' import { useWorkflowComment } from './hooks/use-workflow-comment' import { useWorkflowSearch } from './hooks/use-workflow-search' -import { NodeContextmenu } from './node-contextmenu' import CustomNode from './nodes' import useMatchSchemaType from './nodes/_base/components/variable/use-match-schema-type' import CustomDataSourceEmptyNode from './nodes/data-source-empty' @@ -107,8 +105,6 @@ import CustomNoteNode from './note-node' import { CUSTOM_NOTE_NODE } from './note-node/constants' import Operator from './operator' import Control from './operator/control' -import PanelContextmenu from './panel-contextmenu' -import SelectionContextmenu from './selection-contextmenu' import { useWorkflowHotkeys } from './shortcuts/use-workflow-hotkeys' import CustomSimpleNode from './simple-node' import { CUSTOM_SIMPLE_NODE } from './simple-node/constants' @@ -122,6 +118,7 @@ import { WorkflowRunningStatus, } from './types' import { setupScrollToNodeListener } from './utils/node-navigation' +import { WorkflowContextmenu } from './workflow-contextmenu' import 'reactflow/dist/style.css' import './style.css' @@ -363,7 +360,7 @@ export const Workflow: FC = memo(({ setNodes(v.payload.nodes) store.getState().setNodes(v.payload.nodes) setEdges(v.payload.edges) - workflowStore.setState({ edgeMenu: undefined }) + workflowStore.setState({ contextMenuTarget: undefined }) if (v.payload.viewport) reactflow.setViewport(v.payload.viewport) @@ -633,10 +630,6 @@ export const Workflow: FC = memo(({
- - - - !open && setShowConfirm(undefined)}> @@ -726,66 +719,68 @@ export const Workflow: FC = memo(({ : null })} {children} - - - {showUserCursors && cursors && ( - + + - )} - + {showUserCursors && cursors && ( + + )} + + ) }) diff --git a/web/app/components/workflow/node-contextmenu.tsx b/web/app/components/workflow/node-contextmenu.tsx index e5bd7c933f..491e0dc517 100644 --- a/web/app/components/workflow/node-contextmenu.tsx +++ b/web/app/components/workflow/node-contextmenu.tsx @@ -1,54 +1,36 @@ import type { Node } from './types' import { - ContextMenu, ContextMenuContent, } from '@langgenius/dify-ui/context-menu' -import { useMemo } from 'react' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' -import { usePanelInteractions } from './hooks' import { NodeActionsContextMenuContent } from './node-actions-menu/context-menu-content' import { NODE_ACTIONS_MENU_WIDTH_CLASS_NAME } from './node-actions-menu/shared' import { useStore } from './store' -export function NodeContextmenu() { +export function NodeContextmenu({ + onClose, +}: { + onClose: () => void +}) { const nodes = useNodes() - const { handleNodeContextmenuCancel } = usePanelInteractions() - const nodeMenu = useStore(s => s.nodeMenu) - const currentNode = nodes.find(node => node.id === nodeMenu?.nodeId) as Node + const contextMenuTarget = useStore(s => s.contextMenuTarget) + const nodeId = contextMenuTarget?.type === 'node' ? contextMenuTarget.nodeId : undefined + const currentNode = nodeId ? nodes.find(node => node.id === nodeId) as Node | undefined : undefined - const anchor = useMemo(() => { - if (!nodeMenu || !currentNode) - return undefined - - return { - getBoundingClientRect: () => DOMRect.fromRect({ - width: 0, - height: 0, - x: nodeMenu.clientX, - y: nodeMenu.clientY, - }), - } - }, [currentNode, nodeMenu]) - - if (!nodeMenu || !currentNode || !anchor) + if (!nodeId || !currentNode) return null return ( - !open && handleNodeContextmenuCancel()} + - - - - + + ) } diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx index 02e5294029..568dfe20b8 100644 --- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx @@ -110,7 +110,6 @@ export const NodeTargetHandle = memo(({ open={open} onOpenChange={handleOpenChange} onSelect={handleSelect} - asChild placement="left" triggerClassName={open => ` absolute left-0 top-0 opacity-0 pointer-events-none transition-opacity duration-150 @@ -229,7 +228,6 @@ export const NodeSourceHandle = memo(({ open={open} onOpenChange={handleOpenChange} onSelect={handleSelect} - asChild triggerClassName={open => ` absolute top-0 left-0 opacity-0 pointer-events-none transition-opacity duration-150 ${nodeSelectorClassName} diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 7786dfb2cc..13ec43f3a7 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -79,7 +79,7 @@ const BaseNode: FC = ({ const appId = useStore(s => s.appId) const { nodePanelPresence } = useCollaboration(appId as string) const controlMode = useStore(s => s.controlMode) - const isContextMenuTarget = useStore(s => s.nodeMenu?.nodeId === id) + const isContextMenuTarget = useStore(s => s.contextMenuTarget?.type === 'node' && s.contextMenuTarget.nodeId === id) const currentUserPresence = useMemo(() => { const userId = userProfile?.id || '' diff --git a/web/app/components/workflow/nodes/data-source-empty/index.tsx b/web/app/components/workflow/nodes/data-source-empty/index.tsx index 5895ee6dd5..bd03621b87 100644 --- a/web/app/components/workflow/nodes/data-source-empty/index.tsx +++ b/web/app/components/workflow/nodes/data-source-empty/index.tsx @@ -54,7 +54,6 @@ const DataSourceEmptyNode = ({ id, data }: NodeProps) => { )} > 'hover:scale-125 transition-all'} diff --git a/web/app/components/workflow/operator/add-block.tsx b/web/app/components/workflow/operator/add-block.tsx index 0d1093469c..03e2dc9c46 100644 --- a/web/app/components/workflow/operator/add-block.tsx +++ b/web/app/components/workflow/operator/add-block.tsx @@ -33,11 +33,15 @@ import TipPopup from './tip-popup' type AddBlockProps = { renderTrigger?: (open: boolean) => React.ReactNode + renderTriggerAsButtonRoot?: boolean offset?: OffsetOptions + onClose?: () => void } const AddBlock = ({ renderTrigger, + renderTriggerAsButtonRoot, offset, + onClose, }: AddBlockProps) => { const { t } = useTranslation() const store = useStoreApi() @@ -54,8 +58,8 @@ const AddBlock = ({ const handleOpenChange = useCallback((open: boolean) => { setOpen(open) if (!open) - handlePaneContextmenuCancel() - }, [handlePaneContextmenuCancel]) + (onClose ?? handlePaneContextmenuCancel)() + }, [handlePaneContextmenuCancel, onClose]) const handleSelect = useCallback((type, pluginDefaultValue) => { const { @@ -113,6 +117,7 @@ const AddBlock = ({ crossAxis: -8, }} trigger={renderTrigger || renderTriggerElement} + renderTriggerAsButtonRoot={renderTriggerAsButtonRoot} popupClassName="min-w-[256px]!" availableBlocksTypes={availableNextBlocks} showStartTab={showStartTab} diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx index 3c2207adab..d9b4e6dd60 100644 --- a/web/app/components/workflow/panel-contextmenu.tsx +++ b/web/app/components/workflow/panel-contextmenu.tsx @@ -1,22 +1,18 @@ import { cn } from '@langgenius/dify-ui/cn' import { - ContextMenu, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuSeparator, } from '@langgenius/dify-ui/context-menu' import { - memo, useCallback, - useMemo, } from 'react' import { useTranslation } from 'react-i18next' import { useDSL, useIsChatMode, useNodesInteractions, - usePanelInteractions, useWorkflowMoveMode, useWorkflowStartRun, } from './hooks' @@ -25,16 +21,19 @@ import { useOperator } from './operator/hooks' import { ShortcutKbd } from './shortcuts/shortcut-kbd' import { useStore } from './store' -const PanelContextmenu = () => { +export function PanelContextmenu({ + onClose, +}: { + onClose: () => void +}) { const { t } = useTranslation() - const panelMenu = useStore(s => s.panelMenu) + const isPanelContextMenu = useStore(s => s.contextMenuTarget?.type === 'panel') const clipboardElements = useStore(s => s.clipboardElements) const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal) const pendingComment = useStore(s => s.pendingComment) const setCommentPlacing = useStore(s => s.setCommentPlacing) const setCommentQuickAdd = useStore(s => s.setCommentQuickAdd) const { handleNodesPaste } = useNodesInteractions() - const { handlePaneContextmenuCancel } = usePanelInteractions() const { handleStartWorkflowRun, handleWorkflowStartRunInChatflow, @@ -43,34 +42,20 @@ const PanelContextmenu = () => { const { isCommentModeAvailable } = useWorkflowMoveMode() const { exportCheck } = useDSL() const isChatMode = useIsChatMode() - const panelMenuClientX = panelMenu?.clientX - const panelMenuClientY = panelMenu?.clientY - - const anchor = useMemo(() => { - if (panelMenuClientX === undefined || panelMenuClientY === undefined) - return null - - return { - getBoundingClientRect: () => DOMRect.fromRect({ - width: 0, - height: 0, - x: panelMenuClientX, - y: panelMenuClientY, - }), - } - }, [panelMenuClientX, panelMenuClientY]) const renderAddBlockTrigger = useCallback(() => { return ( - + ) }, [t]) @@ -80,103 +65,98 @@ const PanelContextmenu = () => { else handleStartWorkflowRun() - handlePaneContextmenuCancel() - }, [isChatMode, handleWorkflowStartRunInChatflow, handleStartWorkflowRun, handlePaneContextmenuCancel]) + onClose() + }, [isChatMode, handleWorkflowStartRunInChatflow, handleStartWorkflowRun, onClose]) - if (!panelMenu || !anchor) + if (!isPanelContextMenu) return null return ( - !open && handlePaneContextmenuCancel()} + - - - + + + { + e.stopPropagation() + handleAddNote() + onClose() + }} + > + {t('nodes.note.addNote', { ns: 'workflow' })} + + {isCommentModeAvailable && ( { - e.stopPropagation() - handleAddNote() - handlePaneContextmenuCancel() - }} - > - {t('nodes.note.addNote', { ns: 'workflow' })} - - {isCommentModeAvailable && ( - { - e.stopPropagation() - if (pendingComment) - return - setCommentQuickAdd(true) - setCommentPlacing(true) - handlePaneContextmenuCancel() - }} - > - {t('comments.actions.addComment', { ns: 'workflow' })} - - )} - - {isChatMode ? t('common.debugAndPreview', { ns: 'workflow' }) : t('common.run', { ns: 'workflow' })} - {!isChatMode && } - - - - - { - if (clipboardElements.length) { - handleNodesPaste() - handlePaneContextmenuCancel() - } + onClick={(e) => { + e.stopPropagation() + if (pendingComment) + return + setCommentQuickAdd(true) + setCommentPlacing(true) + onClose() }} > - {t('common.pasteHere', { ns: 'workflow' })} - + {t('comments.actions.addComment', { ns: 'workflow' })} - - - - exportCheck?.()} - > - {t('export', { ns: 'app' })} - - setShowImportDSLModal(true)} - > - {t('importApp', { ns: 'app' })} - - - - + )} + + {isChatMode ? t('common.debugAndPreview', { ns: 'workflow' }) : t('common.run', { ns: 'workflow' })} + {!isChatMode && } + + + + + { + if (clipboardElements.length) { + handleNodesPaste() + onClose() + } + }} + > + {t('common.pasteHere', { ns: 'workflow' })} + + + + + + exportCheck?.()} + > + {t('export', { ns: 'app' })} + + setShowImportDSLModal(true)} + > + {t('importApp', { ns: 'app' })} + + + ) } - -export default memo(PanelContextmenu) diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index a2d7117022..6e444be245 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -1,6 +1,5 @@ import type { Node } from './types' import { - ContextMenu, ContextMenuContent, ContextMenuGroup, ContextMenuItem, @@ -9,16 +8,12 @@ import { } from '@langgenius/dify-ui/context-menu' import { produce } from 'immer' import { - memo, useCallback, - useEffect, - useMemo, } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useReactFlowStore } from 'reactflow' import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks' -import { useSelectionInteractions } from './hooks/use-selection-interactions' import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history' import { ShortcutKbd } from './shortcuts/shortcut-kbd' import { useStore, useWorkflowStore } from './store' @@ -221,12 +216,15 @@ const distributeNodes = ( }) } -const SelectionContextmenu = () => { +export function SelectionContextmenu({ + onClose, +}: { + onClose: () => void +}) { const { t } = useTranslation() const { getNodesReadOnly } = useNodesReadOnly() - const { handleSelectionContextmenuCancel } = useSelectionInteractions() const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions() - const selectionMenu = useStore(s => s.selectionMenu) + const isSelectionContextMenu = useStore(s => s.contextMenuTarget?.type === 'selection') // Access React Flow methods const workflowStore = useWorkflowStore() @@ -239,43 +237,24 @@ const SelectionContextmenu = () => { const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { saveStateToHistory } = useWorkflowHistory() - const anchor = useMemo(() => { - if (!selectionMenu) - return undefined - - return { - getBoundingClientRect: () => DOMRect.fromRect({ - width: 0, - height: 0, - x: selectionMenu.clientX, - y: selectionMenu.clientY, - }), - } - }, [selectionMenu]) - - useEffect(() => { - if (selectionMenu && selectedNodes.length <= 1) - handleSelectionContextmenuCancel() - }, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel]) - const handleCopyNodes = useCallback(() => { handleNodesCopy() - handleSelectionContextmenuCancel() - }, [handleNodesCopy, handleSelectionContextmenuCancel]) + onClose() + }, [handleNodesCopy, onClose]) const handleDuplicateNodes = useCallback(() => { handleNodesDuplicate() - handleSelectionContextmenuCancel() - }, [handleNodesDuplicate, handleSelectionContextmenuCancel]) + onClose() + }, [handleNodesDuplicate, onClose]) const handleDeleteNodes = useCallback(() => { handleNodesDelete() - handleSelectionContextmenuCancel() - }, [handleNodesDelete, handleSelectionContextmenuCancel]) + onClose() + }, [handleNodesDelete, onClose]) const handleAlignNodes = useCallback((alignType: AlignTypeValue) => { if (getNodesReadOnly() || selectedNodes.length <= 1) { - handleSelectionContextmenuCancel() + onClose() return } @@ -311,13 +290,13 @@ const SelectionContextmenu = () => { const nodesToAlign = getAlignableNodes(nodes, selectedNodes) if (nodesToAlign.length <= 1) { - handleSelectionContextmenuCancel() + onClose() return } const bounds = getAlignBounds(nodesToAlign) if (!bounds) { - handleSelectionContextmenuCancel() + onClose() return } @@ -325,7 +304,7 @@ const SelectionContextmenu = () => { const distributedNodes = distributeNodes(nodesToAlign, nodes, alignType) if (distributedNodes) { setNodes(distributedNodes) - handleSelectionContextmenuCancel() + onClose() const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState() setHelpLineHorizontal() @@ -353,7 +332,7 @@ const SelectionContextmenu = () => { setNodes(newNodes) // Close popup - handleSelectionContextmenuCancel() + onClose() const { setHelpLineHorizontal, setHelpLineVertical } = workflowStore.getState() setHelpLineHorizontal() setHelpLineVertical() @@ -363,73 +342,60 @@ const SelectionContextmenu = () => { catch (err) { console.error('Failed to update nodes:', err) } - }, [collaborativeWorkflow, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel]) + }, [collaborativeWorkflow, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, onClose]) - if (!selectionMenu) + if (!isSelectionContextMenu || selectedNodes.length <= 1) return null return ( - { - if (!open) - handleSelectionContextmenuCancel() - }} - > - - - - {t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })} - - - - {t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })} - - + + + + {t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })} + + + + {t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })} + + + + + + + {t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })} + + + + + {menuSections.map((section, sectionIndex) => ( + + {sectionIndex > 0 && } + + {t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })} + + {section.items.map((item) => { + return ( + handleAlignNodes(item.alignType)} + > + + {t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })} + + ) + })} - - - - {t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })} - - - - - {menuSections.map((section, sectionIndex) => ( - - {sectionIndex > 0 && } - - {t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })} - - {section.items.map((item) => { - return ( - handleAlignNodes(item.alignType)} - > - - {t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })} - - ) - })} - - ))} - - + ))} + ) } - -export default memo(SelectionContextmenu) diff --git a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts index 4b5dc0b302..abecc4f327 100644 --- a/web/app/components/workflow/store/__tests__/workflow-store.spec.ts +++ b/web/app/components/workflow/store/__tests__/workflow-store.spec.ts @@ -88,7 +88,6 @@ describe('createWorkflowStore', () => { ['showSingleRunPanel', 'setShowSingleRunPanel', true], ['nodeAnimation', 'setNodeAnimation', true], ['candidateNode', 'setCandidateNode', undefined], - ['nodeMenu', 'setNodeMenu', { clientX: 200, clientY: 100, nodeId: 'n1' }], ['showAssignVariablePopup', 'setShowAssignVariablePopup', undefined], ['hoveringAssignVariableGroupId', 'setHoveringAssignVariableGroupId', 'group-1'], ['connectingNodePayload', 'setConnectingNodePayload', { nodeId: 'n1', nodeType: 'llm', handleType: 'source', handleId: 'h1' }], @@ -108,9 +107,7 @@ describe('createWorkflowStore', () => { ['showWorkflowVersionHistoryPanel', 'setShowWorkflowVersionHistoryPanel', true], ['showInputsPanel', 'setShowInputsPanel', true], ['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true], - ['panelMenu', 'setPanelMenu', { clientX: 20, clientY: 10 }], - ['selectionMenu', 'setSelectionMenu', { clientX: 50, clientY: 60 }], - ['edgeMenu', 'setEdgeMenu', { clientX: 320, clientY: 180, edgeId: 'e1' }], + ['contextMenuTarget', 'setContextMenuTarget', { type: 'edge', edgeId: 'e1' }], ['showVariableInspectPanel', 'setShowVariableInspectPanel', true], ['initShowLastRunTab', 'setInitShowLastRunTab', true], ])('should update %s', (stateKey, setter, value) => { diff --git a/web/app/components/workflow/store/workflow/__tests__/panel-slice.spec.ts b/web/app/components/workflow/store/workflow/__tests__/panel-slice.spec.ts index 1f30a2b7cf..0ac780c807 100644 --- a/web/app/components/workflow/store/workflow/__tests__/panel-slice.spec.ts +++ b/web/app/components/workflow/store/workflow/__tests__/panel-slice.spec.ts @@ -19,12 +19,10 @@ describe('createPanelSlice', () => { store.getState().setShowFeaturesPanel(true) store.getState().setShowDebugAndPreviewPanel(true) - store.getState().setPanelMenu({ clientX: 48, clientY: 24 }) - store.getState().setEdgeMenu({ clientX: 80, clientY: 120, edgeId: 'edge-1' }) + store.getState().setContextMenuTarget({ type: 'edge', edgeId: 'edge-1' }) expect(store.getState().showFeaturesPanel).toBe(true) expect(store.getState().showDebugAndPreviewPanel).toBe(true) - expect(store.getState().panelMenu).toEqual({ clientX: 48, clientY: 24 }) - expect(store.getState().edgeMenu).toEqual({ clientX: 80, clientY: 120, edgeId: 'edge-1' }) + expect(store.getState().contextMenuTarget).toEqual({ type: 'edge', edgeId: 'edge-1' }) }) }) diff --git a/web/app/components/workflow/store/workflow/node-slice.ts b/web/app/components/workflow/store/workflow/node-slice.ts index fc96635acb..be8580eae5 100644 --- a/web/app/components/workflow/store/workflow/node-slice.ts +++ b/web/app/components/workflow/store/workflow/node-slice.ts @@ -18,12 +18,6 @@ export type NodeSliceShape = { setNodeAnimation: (nodeAnimation: boolean) => void candidateNode?: Node setCandidateNode: (candidateNode?: Node) => void - nodeMenu?: { - clientX: number - clientY: number - nodeId: string - } - setNodeMenu: (nodeMenu: NodeSliceShape['nodeMenu']) => void showAssignVariablePopup?: { nodeId: string nodeData: Node['data'] @@ -65,8 +59,6 @@ export const createNodeSlice: StateCreator = set => ({ setNodeAnimation: nodeAnimation => set(() => ({ nodeAnimation })), candidateNode: undefined, setCandidateNode: candidateNode => set(() => ({ candidateNode })), - nodeMenu: undefined, - setNodeMenu: nodeMenu => set(() => ({ nodeMenu })), showAssignVariablePopup: undefined, setShowAssignVariablePopup: showAssignVariablePopup => set(() => ({ showAssignVariablePopup })), hoveringAssignVariableGroupId: undefined, diff --git a/web/app/components/workflow/store/workflow/panel-slice.ts b/web/app/components/workflow/store/workflow/panel-slice.ts index 2f4264fc78..09f08b68ba 100644 --- a/web/app/components/workflow/store/workflow/panel-slice.ts +++ b/web/app/components/workflow/store/workflow/panel-slice.ts @@ -1,5 +1,11 @@ import type { StateCreator } from 'zustand' +export type WorkflowContextMenuTarget + = | { type: 'panel' } + | { type: 'selection' } + | { type: 'node', nodeId: string } + | { type: 'edge', edgeId: string } + export type PanelSliceShape = { panelWidth: number showFeaturesPanel: boolean @@ -16,22 +22,8 @@ export type PanelSliceShape = { setShowUserComments: (showUserComments: boolean) => void showUserCursors: boolean setShowUserCursors: (showUserCursors: boolean) => void - panelMenu?: { - clientX: number - clientY: number - } - setPanelMenu: (panelMenu: PanelSliceShape['panelMenu']) => void - selectionMenu?: { - clientX: number - clientY: number - } - setSelectionMenu: (selectionMenu: PanelSliceShape['selectionMenu']) => void - edgeMenu?: { - clientX: number - clientY: number - edgeId: string - } - setEdgeMenu: (edgeMenu: PanelSliceShape['edgeMenu']) => void + contextMenuTarget?: WorkflowContextMenuTarget + setContextMenuTarget: (contextMenuTarget: WorkflowContextMenuTarget | undefined) => void showVariableInspectPanel: boolean setShowVariableInspectPanel: (showVariableInspectPanel: boolean) => void initShowLastRunTab: boolean @@ -56,12 +48,8 @@ export const createPanelSlice: StateCreator = set => ({ setShowUserComments: showUserComments => set(() => ({ showUserComments })), showUserCursors: true, setShowUserCursors: showUserCursors => set(() => ({ showUserCursors })), - panelMenu: undefined, - setPanelMenu: panelMenu => set(() => ({ panelMenu })), - selectionMenu: undefined, - setSelectionMenu: selectionMenu => set(() => ({ selectionMenu })), - edgeMenu: undefined, - setEdgeMenu: edgeMenu => set(() => ({ edgeMenu })), + contextMenuTarget: undefined, + setContextMenuTarget: contextMenuTarget => set(() => ({ contextMenuTarget })), showVariableInspectPanel: false, setShowVariableInspectPanel: showVariableInspectPanel => set(() => ({ showVariableInspectPanel })), initShowLastRunTab: false, diff --git a/web/app/components/workflow/workflow-contextmenu.tsx b/web/app/components/workflow/workflow-contextmenu.tsx new file mode 100644 index 0000000000..0eedac6128 --- /dev/null +++ b/web/app/components/workflow/workflow-contextmenu.tsx @@ -0,0 +1,47 @@ +import type { ContextMenuActions } from '@langgenius/dify-ui/context-menu' +import type { ReactNode } from 'react' +import { + ContextMenu, + ContextMenuTrigger, +} from '@langgenius/dify-ui/context-menu' +import { useCallback, useRef } from 'react' +import { EdgeContextmenu } from './edge-contextmenu' +import { NodeContextmenu } from './node-contextmenu' +import { PanelContextmenu } from './panel-contextmenu' +import { SelectionContextmenu } from './selection-contextmenu' +import { useWorkflowStore } from './store' + +export function WorkflowContextmenu({ + children, +}: { + children: ReactNode +}) { + const workflowStore = useWorkflowStore() + const actionsRef = useRef(null) + + const clearContextMenuTarget = useCallback(() => { + workflowStore.setState({ contextMenuTarget: undefined }) + }, [workflowStore]) + + const closeContextMenu = useCallback(() => { + actionsRef.current?.close() + }, []) + + return ( + { + if (!open) + clearContextMenuTarget() + }} + > + }> + {children} + + + + + + + ) +}