mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:32:01 +08:00
refactor: streamline workflow context menu lifecycle (#36500)
This commit is contained in:
parent
92181dbe09
commit
964aaad7ed
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -161,7 +161,6 @@ const CustomEdge = ({
|
||||
<BlockSelector
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
asChild
|
||||
onSelect={handleInsert}
|
||||
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks)}
|
||||
triggerClassName={() => 'hover:scale-150 transition-all'}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 || ''
|
||||
|
||||
@ -54,7 +54,6 @@ const DataSourceEmptyNode = ({ id, data }: NodeProps) => {
|
||||
)}
|
||||
>
|
||||
<BlockSelector
|
||||
asChild
|
||||
onSelect={handleReplaceNode}
|
||||
trigger={renderTrigger}
|
||||
noBlocks
|
||||
|
||||
@ -49,7 +49,6 @@ const InsertBlock = ({
|
||||
<BlockSelector
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
asChild
|
||||
onSelect={handleInsert}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
triggerClassName={() => 'hover:scale-125 transition-all'}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
47
web/app/components/workflow/workflow-contextmenu.tsx
Normal file
47
web/app/components/workflow/workflow-contextmenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user