refactor: streamline workflow context menu lifecycle (#36500)

This commit is contained in:
yyh 2026-05-22 12:31:39 +08:00 committed by GitHub
parent 92181dbe09
commit 964aaad7ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 622 additions and 763 deletions

View File

@ -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<Pick<ContextMenuContentProps, 'child
popupClassName?: string
positionerProps?: ContextMenuContentProps['positionerProps']
popupProps?: ContextMenuContentProps['popupProps']
withBackdrop?: boolean
}
function renderContextMenuPopup({
@ -62,15 +62,11 @@ function renderContextMenuPopup({
popupClassName,
positionerProps,
popupProps,
withBackdrop = false,
}: ContextMenuPopupRenderProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseContextMenu.Portal>
{withBackdrop && (
<BaseContextMenu.Backdrop className={overlayBackdropClassName} />
)}
<BaseContextMenu.Positioner
side={side}
align={align}
@ -113,7 +109,6 @@ export function ContextMenuContent({
popupClassName,
positionerProps,
popupProps,
withBackdrop: true,
})
}

View File

@ -8,6 +8,7 @@ import { cva } from 'class-variance-authority'
import { cn } from '../cn'
import {
overlayLabelClassName,
overlayPopupAnimationClassName,
overlaySeparatorClassName,
} from '../overlay-shared'
import { parsePlacement } from '../placement'
@ -141,7 +142,7 @@ export function SelectContent({
<BaseSelect.Popup
className={cn(
'min-w-(--anchor-width) rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
overlayPopupAnimationClassName,
popupClassName,
)}
{...popupProps}

View File

@ -1,11 +1,12 @@
import type { Edge, Node } from '../types'
import { ContextMenu } from '@langgenius/dify-ui/context-menu'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import { useEdges, useNodes, useStoreApi } from 'reactflow'
import { createEdge, createNode } from '../__tests__/fixtures'
import { renderWorkflowFlowComponent } from '../__tests__/workflow-test-env'
import EdgeContextmenu from '../edge-contextmenu'
import { EdgeContextmenu } from '../edge-contextmenu'
import { useEdgesInteractions } from '../hooks/use-edges-interactions'
const mockSaveStateToHistory = vi.fn()
@ -148,7 +149,9 @@ const EdgeMenuHarness = () => {
>
remove-e1
</button>
<EdgeContextmenu />
<ContextMenu open>
<EdgeContextmenu onClose={vi.fn()} />
</ContextMenu>
</div>
)
}
@ -176,13 +179,18 @@ describe('EdgeContextmenu', () => {
latestEdges = []
})
it('should not render when edgeMenu is absent', () => {
renderWorkflowFlowComponent(<EdgeContextmenu />, {
nodes: createFlowNodes(),
edges: createFlowEdges(),
hooksStoreProps,
reactFlowProps: { fitView: false },
})
it('should not render when edge context menu target is absent', () => {
renderWorkflowFlowComponent(
<ContextMenu open>
<EdgeContextmenu onClose={vi.fn()} />
</ContextMenu>,
{
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(<EdgeContextmenu />, {
nodes: createFlowNodes(),
edges: createFlowEdges(),
initialStoreState: {
edgeMenu: {
clientX: 320,
clientY: 180,
edgeId: 'missing-edge',
renderWorkflowFlowComponent(
<ContextMenu open>
<EdgeContextmenu onClose={vi.fn()} />
</ContextMenu>,
{
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)

View File

@ -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 }) => (
<div>
{children}
<button type="button" onClick={() => onOpenChange(false)}>close-context-menu</button>
</div>
),
ContextMenuContent: ({ children, positionerProps, popupClassName }: { children: React.ReactNode, positionerProps?: { anchor?: unknown }, popupClassName?: string }) => {
mockContextMenuContent({ positionerProps, popupClassName })
return <div>{children}</div>
},
}))
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 (
<button type="button" onClick={props.onClose}>
{props.id}
:
{props.data.title}
</button>
)
},
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(
<ContextMenu open>
<NodeContextmenu onClose={mockClose} />
</ContextMenu>,
)
it('should stay hidden when the node menu is absent', () => {
render(<NodeContextmenu />)
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(<NodeContextmenu />)
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(<NodeContextmenu />)
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)
})
})

View File

@ -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<typeof renderWorkflowFlowComponent>[1]) => {
return renderWorkflowFlowComponent(
<ContextMenu open>
<PanelContextmenu onClose={mockClose} />
</ContextMenu>,
options,
)
}
it('should stay hidden when the panel menu is absent', () => {
renderWorkflowFlowComponent(<PanelContextmenu />)
renderPanelContextmenu()
expect(screen.queryByText('common.addBlock')).not.toBeInTheDocument()
})
it('should keep paste disabled when the clipboard is empty', async () => {
renderWorkflowFlowComponent(<PanelContextmenu />, {
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(<PanelContextmenu />, {
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(<PanelContextmenu />, {
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()
})
})
})

View File

@ -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 (
<ContextMenu open>
<SelectionContextmenu
onClose={() => workflowStore.getState().setContextMenuTarget(undefined)}
/>
</ContextMenu>
)
}
const renderSelectionMenu = (options?: {
nodes?: Node[]
edges?: Edge[]
@ -61,7 +75,7 @@ const renderSelectionMenu = (options?: {
return renderWorkflowFlowComponent(
<div id="workflow-container" style={{ width: 800, height: 600 }}>
<RuntimeProbe />
<SelectionContextmenu />
<SelectionMenuHarness />
</div>,
{
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)
})

View File

@ -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 () => {

View File

@ -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(
<NodeSelector
asChild
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
@ -135,4 +138,57 @@ describe('NodeSelector', () => {
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 (
<span>
open-from-shell
</span>
)
}
renderWorkflowComponent(
<NodeSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
trigger={() => <TriggerShell />}
/>,
)
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<HTMLButtonElement>) {
return (
<button type="button" data-testid="selector-root-trigger" {...props}>
open-selector-root
</button>
)
}
renderWorkflowComponent(
<NodeSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
renderTriggerAsButtonRoot
trigger={() => <ForwardingButtonTrigger />}
/>,
)
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()
})
})

View File

@ -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<NodeSelectorProps> = ({
function NodeSelector({
open: openFromProps,
onOpenChange,
onSelect,
@ -72,9 +70,9 @@ const NodeSelector: FC<NodeSelectorProps> = ({
offset = 6,
triggerClassName,
triggerInnerClassName,
renderTriggerAsButtonRoot = false,
triggerStyle,
popupClassName,
asChild,
availableBlocksTypes,
disabled,
blocks = [],
@ -87,7 +85,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
ignoreNodeIds = [],
forceEnableStartTab = false,
allowUserInputSelection,
}) => {
}: NodeSelectorProps) {
const { t } = useTranslation()
const nodes = useNodes()
const [searchText, setSearchText] = useState('')
@ -191,36 +189,19 @@ const NodeSelector: FC<NodeSelectorProps> = ({
</PopoverTrigger>
)
const triggerElement = trigger?.(open)
const shouldRenderTriggerElementAsRoot = React.isValidElement(triggerElement)
&& (asChild || triggerElement.type === 'button')
const triggerElementProps = React.isValidElement(triggerElement)
? (triggerElement.props as {
onClick?: MouseEventHandler<HTMLElement>
})
: null
const resolvedTriggerElement = shouldRenderTriggerElementAsRoot
? React.cloneElement(
triggerElement as React.ReactElement<{
onClick?: MouseEventHandler<HTMLElement>
}>,
{
onClick: (e: ReactMouseEvent<HTMLElement>) => {
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
: (
<div className={triggerInnerClassName} onClick={handleTrigger}>
<div className={triggerInnerClassName}>
{triggerElement}
</div>
)
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 (
<Popover
@ -228,7 +209,13 @@ const NodeSelector: FC<NodeSelectorProps> = ({
onOpenChange={handleOpenChange}
>
{trigger
? <PopoverTrigger nativeButton={nativeButton} render={resolvedTriggerElement as React.ReactElement} />
? (
<PopoverTrigger
nativeButton={shouldRenderTriggerAsButtonRoot}
onClick={handleTrigger}
render={resolvedTriggerElement as React.ReactElement}
/>
)
: defaultTriggerElement}
<PopoverContent
placement={placement}

View File

@ -161,7 +161,6 @@ const CustomEdge = ({
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
asChild
onSelect={handleInsert}
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks)}
triggerClassName={() => 'hover:scale-150 transition-all'}

View File

@ -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 (
<ContextMenu
open={!!edgeMenu}
onOpenChange={open => !open && handleEdgeContextmenuCancel()}
<ContextMenuContent
popupClassName="rounded-lg"
sideOffset={4}
>
<ContextMenuContent
positionerProps={{ anchor }}
popupClassName="rounded-lg"
<ContextMenuItem
variant="destructive"
className="justify-between gap-4 px-3"
onClick={() => {
handleEdgeDeleteById(edgeId)
onClose()
}}
>
<ContextMenuItem
variant="destructive"
className="justify-between gap-4 px-3"
onClick={() => handleEdgeDeleteById(edgeMenu.edgeId)}
>
<span>{t('common:operation.delete')}</span>
<ShortcutKbd shortcut="workflow.delete" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<span>{t('common:operation.delete')}</span>
<ShortcutKbd shortcut="workflow.delete" />
</ContextMenuItem>
</ContextMenuContent>
)
}
export default memo(EdgeContextmenu)

View File

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

View File

@ -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', () => {

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

@ -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<EdgeMouseHandler>((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,
},
})

View File

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

View File

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

View File

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

View File

@ -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<WorkflowProps> = 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<WorkflowProps> = memo(({
<Control />
</div>
<Operator handleRedo={handleHistoryForward} handleUndo={handleHistoryBack} />
<PanelContextmenu />
<NodeContextmenu />
<EdgeContextmenu />
<SelectionContextmenu />
<HelpLine />
<AlertDialog open={!!showConfirm} onOpenChange={open => !open && setShowConfirm(undefined)}>
<AlertDialogContent>
@ -726,66 +719,68 @@ export const Workflow: FC<WorkflowProps> = memo(({
: null
})}
{children}
<ReactFlow
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
className={controlMode === ControlMode.Comment ? 'comment-mode-flow' : ''}
onNodeDragStart={handleNodeDragStart}
onNodeDrag={handleNodeDrag}
onNodeDragStop={handleNodeDragStop}
onNodeMouseEnter={handleNodeEnter}
onNodeMouseLeave={handleNodeLeave}
onNodeClick={handleNodeClick}
onNodeContextMenu={handleNodeContextMenu}
onConnect={handleNodeConnect}
onConnectStart={handleNodeConnectStart}
onConnectEnd={handleNodeConnectEnd}
onEdgeMouseEnter={handleEdgeEnter}
onEdgeMouseLeave={handleEdgeLeave}
onEdgesChange={handleEdgesChange}
onEdgeContextMenu={handleEdgeContextMenu}
onSelectionStart={handleSelectionStart}
onSelectionChange={handleSelectionChange}
onSelectionDrag={handleSelectionDrag}
onPaneContextMenu={handlePaneContextMenu}
onSelectionContextMenu={handleSelectionContextMenu}
connectionLineComponent={CustomConnectionLine}
// NOTE: For LOOP node, how to distinguish between ITERATION and LOOP here? Maybe both are the same?
connectionLineContainerStyle={{ zIndex: ITERATION_CHILDREN_Z_INDEX }}
defaultViewport={viewport}
multiSelectionKeyCode={null}
deleteKeyCode={null}
nodesDraggable={!nodesReadOnly && controlMode !== ControlMode.Comment}
nodesConnectable={!nodesReadOnly}
nodesFocusable={!nodesReadOnly}
edgesFocusable={!nodesReadOnly}
panOnScroll={controlMode === ControlMode.Pointer && !workflowReadOnly}
panOnDrag={controlMode === ControlMode.Hand || [1]}
zoomOnPinch={true}
zoomOnScroll={true}
zoomOnDoubleClick={true}
isValidConnection={isValidConnection}
selectionKeyCode={null}
selectionMode={SelectionMode.Partial}
selectionOnDrag={controlMode === ControlMode.Pointer && !workflowReadOnly}
minZoom={0.25}
>
<Background
gap={[14, 14]}
size={2}
className="bg-workflow-canvas-workflow-bg"
color="var(--color-workflow-canvas-workflow-dot-color)"
/>
{showUserCursors && cursors && (
<UserCursors
cursors={cursors}
myUserId={myUserId || null}
onlineUsers={onlineUsers || []}
<WorkflowContextmenu>
<ReactFlow
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
className={controlMode === ControlMode.Comment ? 'comment-mode-flow' : ''}
onNodeDragStart={handleNodeDragStart}
onNodeDrag={handleNodeDrag}
onNodeDragStop={handleNodeDragStop}
onNodeMouseEnter={handleNodeEnter}
onNodeMouseLeave={handleNodeLeave}
onNodeClick={handleNodeClick}
onNodeContextMenu={handleNodeContextMenu}
onConnect={handleNodeConnect}
onConnectStart={handleNodeConnectStart}
onConnectEnd={handleNodeConnectEnd}
onEdgeMouseEnter={handleEdgeEnter}
onEdgeMouseLeave={handleEdgeLeave}
onEdgesChange={handleEdgesChange}
onEdgeContextMenu={handleEdgeContextMenu}
onSelectionStart={handleSelectionStart}
onSelectionChange={handleSelectionChange}
onSelectionDrag={handleSelectionDrag}
onPaneContextMenu={handlePaneContextMenu}
onSelectionContextMenu={handleSelectionContextMenu}
connectionLineComponent={CustomConnectionLine}
// NOTE: For LOOP node, how to distinguish between ITERATION and LOOP here? Maybe both are the same?
connectionLineContainerStyle={{ zIndex: ITERATION_CHILDREN_Z_INDEX }}
defaultViewport={viewport}
multiSelectionKeyCode={null}
deleteKeyCode={null}
nodesDraggable={!nodesReadOnly && controlMode !== ControlMode.Comment}
nodesConnectable={!nodesReadOnly}
nodesFocusable={!nodesReadOnly}
edgesFocusable={!nodesReadOnly}
panOnScroll={controlMode === ControlMode.Pointer && !workflowReadOnly}
panOnDrag={controlMode === ControlMode.Hand || [1]}
zoomOnPinch={true}
zoomOnScroll={true}
zoomOnDoubleClick={true}
isValidConnection={isValidConnection}
selectionKeyCode={null}
selectionMode={SelectionMode.Partial}
selectionOnDrag={controlMode === ControlMode.Pointer && !workflowReadOnly}
minZoom={0.25}
>
<Background
gap={[14, 14]}
size={2}
className="bg-workflow-canvas-workflow-bg"
color="var(--color-workflow-canvas-workflow-dot-color)"
/>
)}
</ReactFlow>
{showUserCursors && cursors && (
<UserCursors
cursors={cursors}
myUserId={myUserId || null}
onlineUsers={onlineUsers || []}
/>
)}
</ReactFlow>
</WorkflowContextmenu>
</div>
)
})

View File

@ -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 (
<ContextMenu
open
onOpenChange={open => !open && handleNodeContextmenuCancel()}
<ContextMenuContent
popupClassName={NODE_ACTIONS_MENU_WIDTH_CLASS_NAME}
sideOffset={4}
>
<ContextMenuContent
positionerProps={{ anchor }}
popupClassName={NODE_ACTIONS_MENU_WIDTH_CLASS_NAME}
>
<NodeActionsContextMenuContent
id={currentNode.id}
data={currentNode.data}
onClose={handleNodeContextmenuCancel}
showHelpLink
/>
</ContextMenuContent>
</ContextMenu>
<NodeActionsContextMenuContent
id={currentNode.id}
data={currentNode.data}
onClose={onClose}
showHelpLink
/>
</ContextMenuContent>
)
}

View File

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

View File

@ -79,7 +79,7 @@ const BaseNode: FC<BaseNodeProps> = ({
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 || ''

View File

@ -54,7 +54,6 @@ const DataSourceEmptyNode = ({ id, data }: NodeProps) => {
)}
>
<BlockSelector
asChild
onSelect={handleReplaceNode}
trigger={renderTrigger}
noBlocks

View File

@ -49,7 +49,6 @@ const InsertBlock = ({
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
asChild
onSelect={handleInsert}
availableBlocksTypes={availableBlocksTypes}
triggerClassName={() => 'hover:scale-125 transition-all'}

View File

@ -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<OnSelectBlock>((type, pluginDefaultValue) => {
const {
@ -113,6 +117,7 @@ const AddBlock = ({
crossAxis: -8,
}}
trigger={renderTrigger || renderTriggerElement}
renderTriggerAsButtonRoot={renderTriggerAsButtonRoot}
popupClassName="min-w-[256px]!"
availableBlocksTypes={availableNextBlocks}
showStartTab={showStartTab}

View File

@ -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 (
<button
type="button"
<ContextMenuItem
nativeButton
closeOnClick={false}
render={<button type="button" />}
className={cn(
'mx-1 flex h-8 w-[calc(100%-8px)] items-center rounded-lg outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
'justify-between gap-4 px-3 text-text-secondary',
'w-[calc(100%-8px)]',
'justify-between gap-4 border-0 bg-transparent px-3 text-left text-text-secondary',
)}
>
{t('common.addBlock', { ns: 'workflow' })}
</button>
</ContextMenuItem>
)
}, [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 (
<ContextMenu
open
onOpenChange={open => !open && handlePaneContextmenuCancel()}
<ContextMenuContent
popupClassName="w-[200px] rounded-lg"
sideOffset={4}
>
<ContextMenuContent
positionerProps={{ anchor }}
popupClassName="w-[200px] rounded-lg"
>
<ContextMenuGroup>
<AddBlock
renderTrigger={renderAddBlockTrigger}
offset={{
mainAxis: -36,
crossAxis: -4,
}}
/>
<ContextMenuGroup>
<AddBlock
renderTrigger={renderAddBlockTrigger}
renderTriggerAsButtonRoot
onClose={onClose}
offset={{
mainAxis: -36,
crossAxis: -4,
}}
/>
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={(e) => {
e.stopPropagation()
handleAddNote()
onClose()
}}
>
{t('nodes.note.addNote', { ns: 'workflow' })}
</ContextMenuItem>
{isCommentModeAvailable && (
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={(e) => {
e.stopPropagation()
handleAddNote()
handlePaneContextmenuCancel()
}}
>
{t('nodes.note.addNote', { ns: 'workflow' })}
</ContextMenuItem>
{isCommentModeAvailable && (
<ContextMenuItem
disabled={!!pendingComment}
className={cn(
'justify-between gap-4 px-3 text-text-secondary',
pendingComment && 'cursor-not-allowed opacity-50',
)}
onClick={(e) => {
e.stopPropagation()
if (pendingComment)
return
setCommentQuickAdd(true)
setCommentPlacing(true)
handlePaneContextmenuCancel()
}}
>
{t('comments.actions.addComment', { ns: 'workflow' })}
</ContextMenuItem>
)}
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={handleRunAction}
>
{isChatMode ? t('common.debugAndPreview', { ns: 'workflow' }) : t('common.run', { ns: 'workflow' })}
{!isChatMode && <ShortcutKbd shortcut="workflow.open-test-run-menu" />}
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem
disabled={!clipboardElements.length}
disabled={!!pendingComment}
className={cn(
'justify-between gap-4 px-3 text-text-secondary',
!clipboardElements.length && 'cursor-not-allowed opacity-50',
pendingComment && 'cursor-not-allowed opacity-50',
)}
onClick={() => {
if (clipboardElements.length) {
handleNodesPaste()
handlePaneContextmenuCancel()
}
onClick={(e) => {
e.stopPropagation()
if (pendingComment)
return
setCommentQuickAdd(true)
setCommentPlacing(true)
onClose()
}}
>
{t('common.pasteHere', { ns: 'workflow' })}
<ShortcutKbd shortcut="workflow.paste" />
{t('comments.actions.addComment', { ns: 'workflow' })}
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={() => exportCheck?.()}
>
{t('export', { ns: 'app' })}
</ContextMenuItem>
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={() => setShowImportDSLModal(true)}
>
{t('importApp', { ns: 'app' })}
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
)}
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={handleRunAction}
>
{isChatMode ? t('common.debugAndPreview', { ns: 'workflow' }) : t('common.run', { ns: 'workflow' })}
{!isChatMode && <ShortcutKbd shortcut="workflow.open-test-run-menu" />}
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem
disabled={!clipboardElements.length}
className={cn(
'justify-between gap-4 px-3 text-text-secondary',
!clipboardElements.length && 'cursor-not-allowed opacity-50',
)}
onClick={() => {
if (clipboardElements.length) {
handleNodesPaste()
onClose()
}
}}
>
{t('common.pasteHere', { ns: 'workflow' })}
<ShortcutKbd shortcut="workflow.paste" />
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={() => exportCheck?.()}
>
{t('export', { ns: 'app' })}
</ContextMenuItem>
<ContextMenuItem
className="justify-between gap-4 px-3 text-text-secondary"
onClick={() => setShowImportDSLModal(true)}
>
{t('importApp', { ns: 'app' })}
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
)
}
export default memo(PanelContextmenu)

View File

@ -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 (
<ContextMenu
open
onOpenChange={(open) => {
if (!open)
handleSelectionContextmenuCancel()
}}
>
<ContextMenuContent
popupClassName="w-[240px]"
positionerProps={anchor ? { anchor } : undefined}
>
<ContextMenuGroup>
<ContextMenuItem
className="justify-between px-3 text-text-secondary"
onClick={handleCopyNodes}
>
<span>{t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })}</span>
<ShortcutKbd shortcut="workflow.copy" />
</ContextMenuItem>
<ContextMenuItem
className="justify-between px-3 text-text-secondary"
onClick={handleDuplicateNodes}
>
<span>{t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })}</span>
<ShortcutKbd shortcut="workflow.duplicate" />
</ContextMenuItem>
<ContextMenuContent popupClassName="w-[240px]" sideOffset={4}>
<ContextMenuGroup>
<ContextMenuItem
className="justify-between px-3 text-text-secondary"
onClick={handleCopyNodes}
>
<span>{t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })}</span>
<ShortcutKbd shortcut="workflow.copy" />
</ContextMenuItem>
<ContextMenuItem
className="justify-between px-3 text-text-secondary"
onClick={handleDuplicateNodes}
>
<span>{t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })}</span>
<ShortcutKbd shortcut="workflow.duplicate" />
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem
className="justify-between px-3 text-text-secondary data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive"
onClick={handleDeleteNodes}
>
<span>{t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })}</span>
<ShortcutKbd shortcut="workflow.delete" />
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
{menuSections.map((section, sectionIndex) => (
<ContextMenuGroup key={section.titleKey}>
{sectionIndex > 0 && <ContextMenuSeparator />}
<ContextMenuLabel>
{t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })}
</ContextMenuLabel>
{section.items.map((item) => {
return (
<ContextMenuItem
key={item.alignType}
data-testid={`selection-contextmenu-item-${item.alignType}`}
onClick={() => handleAlignNodes(item.alignType)}
>
<span aria-hidden className={`${item.icon} h-4 w-4 ${item.iconClassName ?? ''}`.trim()} />
{t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })}
</ContextMenuItem>
)
})}
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem
className="justify-between px-3 text-text-secondary data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive"
onClick={handleDeleteNodes}
>
<span>{t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })}</span>
<ShortcutKbd shortcut="workflow.delete" />
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
{menuSections.map((section, sectionIndex) => (
<ContextMenuGroup key={section.titleKey}>
{sectionIndex > 0 && <ContextMenuSeparator />}
<ContextMenuLabel>
{t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })}
</ContextMenuLabel>
{section.items.map((item) => {
return (
<ContextMenuItem
key={item.alignType}
data-testid={`selection-contextmenu-item-${item.alignType}`}
onClick={() => handleAlignNodes(item.alignType)}
>
<span aria-hidden className={`${item.icon} h-4 w-4 ${item.iconClassName ?? ''}`.trim()} />
{t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })}
</ContextMenuItem>
)
})}
</ContextMenuGroup>
))}
</ContextMenuContent>
</ContextMenu>
))}
</ContextMenuContent>
)
}
export default memo(SelectionContextmenu)

View File

@ -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) => {

View File

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

View File

@ -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<NodeSliceShape> = 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,

View File

@ -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<PanelSliceShape> = 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,

View File

@ -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<ContextMenuActions | null>(null)
const clearContextMenuTarget = useCallback(() => {
workflowStore.setState({ contextMenuTarget: undefined })
}, [workflowStore])
const closeContextMenu = useCallback(() => {
actionsRef.current?.close()
}, [])
return (
<ContextMenu
actionsRef={actionsRef}
onOpenChangeComplete={(open) => {
if (!open)
clearContextMenuTarget()
}}
>
<ContextMenuTrigger render={<div className="h-full w-full" />}>
{children}
</ContextMenuTrigger>
<PanelContextmenu onClose={closeContextMenu} />
<NodeContextmenu onClose={closeContextMenu} />
<EdgeContextmenu onClose={closeContextMenu} />
<SelectionContextmenu onClose={closeContextMenu} />
</ContextMenu>
)
}