mirror of
https://github.com/langgenius/dify.git
synced 2026-06-26 17:17:09 +08:00
Compare commits
1 Commits
1.15.0
...
codex/snp-
| Author | SHA1 | Date | |
|---|---|---|---|
| 45e8187182 |
@ -9,7 +9,6 @@ const mockUseEventListener = vi.hoisted(() => vi.fn())
|
||||
const mockUseStoreApi = vi.hoisted(() => vi.fn())
|
||||
const mockUseReactFlow = vi.hoisted(() => vi.fn())
|
||||
const mockUseViewport = vi.hoisted(() => vi.fn())
|
||||
const mockUseStore = vi.hoisted(() => vi.fn())
|
||||
const mockUseWorkflowStore = vi.hoisted(() => vi.fn())
|
||||
const mockUseHooks = vi.hoisted(() => vi.fn())
|
||||
const mockCustomNode = vi.hoisted(() => vi.fn())
|
||||
@ -32,12 +31,6 @@ vi.mock('reactflow', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: { mousePosition: {
|
||||
pageX: number
|
||||
pageY: number
|
||||
elementX: number
|
||||
elementY: number
|
||||
} }) => unknown) => mockUseStore(selector),
|
||||
useWorkflowStore: () => mockUseWorkflowStore(),
|
||||
}))
|
||||
|
||||
@ -80,6 +73,7 @@ describe('CandidateNodeMain', () => {
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
const mockAutoGenerateWebhookUrl = vi.fn()
|
||||
const mockWorkflowStoreSetState = vi.fn()
|
||||
const mockSetPointerPosition = vi.fn()
|
||||
const createNodesInteractions = () => ({
|
||||
handleNodeSelect: mockHandleNodeSelect,
|
||||
})
|
||||
@ -90,7 +84,17 @@ describe('CandidateNodeMain', () => {
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
})
|
||||
const createAutoGenerateWebhookUrl = () => mockAutoGenerateWebhookUrl
|
||||
const eventHandlers: Partial<Record<'click' | 'contextmenu', (event: { preventDefault: () => void }) => void>> = {}
|
||||
type TestMouseEvent = {
|
||||
preventDefault: () => void
|
||||
clientX: number
|
||||
clientY: number
|
||||
}
|
||||
const eventHandlers: Partial<Record<'click' | 'contextmenu' | 'mousemove', (event: TestMouseEvent) => void>> = {}
|
||||
const createMouseEvent = (clientX = 100, clientY = 200): TestMouseEvent => ({
|
||||
preventDefault: vi.fn(),
|
||||
clientX,
|
||||
clientY,
|
||||
})
|
||||
let nodes = [createNode({ id: 'existing-node' })]
|
||||
|
||||
beforeEach(() => {
|
||||
@ -98,8 +102,9 @@ describe('CandidateNodeMain', () => {
|
||||
nodes = [createNode({ id: 'existing-node' })]
|
||||
eventHandlers.click = undefined
|
||||
eventHandlers.contextmenu = undefined
|
||||
eventHandlers.mousemove = undefined
|
||||
|
||||
mockUseEventListener.mockImplementation((event: 'click' | 'contextmenu', handler: (event: { preventDefault: () => void }) => void) => {
|
||||
mockUseEventListener.mockImplementation((event: 'click' | 'contextmenu' | 'mousemove', handler: (event: TestMouseEvent) => void) => {
|
||||
eventHandlers[event] = handler
|
||||
})
|
||||
mockUseStoreApi.mockReturnValue({
|
||||
@ -112,22 +117,22 @@ describe('CandidateNodeMain', () => {
|
||||
screenToFlowPosition: ({ x, y }: { x: number, y: number }) => ({ x: x + 10, y: y + 20 }),
|
||||
})
|
||||
mockUseViewport.mockReturnValue({ zoom: 1.5 })
|
||||
mockUseStore.mockImplementation((selector: (state: { mousePosition: {
|
||||
pageX: number
|
||||
pageY: number
|
||||
elementX: number
|
||||
elementY: number
|
||||
} }) => unknown) => selector({
|
||||
mousePosition: {
|
||||
pageX: 100,
|
||||
pageY: 200,
|
||||
elementX: 30,
|
||||
elementY: 40,
|
||||
},
|
||||
}))
|
||||
mockUseWorkflowStore.mockReturnValue({
|
||||
setState: mockWorkflowStoreSetState,
|
||||
getState: () => ({
|
||||
getPointerPosition: () => ({
|
||||
pageX: 100,
|
||||
pageY: 200,
|
||||
elementX: 30,
|
||||
elementY: 40,
|
||||
}),
|
||||
setPointerPosition: mockSetPointerPosition,
|
||||
}),
|
||||
})
|
||||
mockSetPointerPosition.mockClear()
|
||||
mockSetPointerPosition.mockImplementation(() => {})
|
||||
mockWorkflowStoreSetState.mockClear()
|
||||
mockWorkflowStoreSetState.mockImplementation(() => {})
|
||||
mockUseHooks.mockReturnValue({
|
||||
useNodesInteractions: createNodesInteractions,
|
||||
useWorkflowHistory: createWorkflowHistory,
|
||||
@ -161,7 +166,7 @@ describe('CandidateNodeMain', () => {
|
||||
transform: 'scale(1.5)',
|
||||
})
|
||||
|
||||
eventHandlers.click?.({ preventDefault: vi.fn() })
|
||||
eventHandlers.click?.(createMouseEvent())
|
||||
|
||||
expect(mockSetNodes).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'existing-node' }),
|
||||
@ -171,6 +176,12 @@ describe('CandidateNodeMain', () => {
|
||||
data: expect.objectContaining({ _isCandidate: false }),
|
||||
}),
|
||||
]))
|
||||
expect(mockSetPointerPosition).toHaveBeenCalledWith({
|
||||
pageX: 100,
|
||||
pageY: 200,
|
||||
elementX: 100,
|
||||
elementY: 200,
|
||||
})
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodeAdd', { nodeId: 'candidate-webhook' })
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined })
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true, expect.objectContaining({
|
||||
@ -195,7 +206,7 @@ describe('CandidateNodeMain', () => {
|
||||
|
||||
expect(screen.getByTestId('candidate-note-node'))!.toHaveTextContent('candidate-note')
|
||||
|
||||
eventHandlers.click?.({ preventDefault: vi.fn() })
|
||||
eventHandlers.click?.(createMouseEvent())
|
||||
|
||||
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NoteAdd', { nodeId: 'candidate-note' })
|
||||
expect(mockHandleNodeSelect).toHaveBeenCalledWith('candidate-note')
|
||||
@ -223,7 +234,7 @@ describe('CandidateNodeMain', () => {
|
||||
|
||||
const { rerender } = render(<CandidateNodeMain candidateNode={iterationNode} />)
|
||||
|
||||
eventHandlers.click?.({ preventDefault: vi.fn() })
|
||||
eventHandlers.click?.(createMouseEvent())
|
||||
expect(mockGetIterationStartNode).toHaveBeenCalledWith('candidate-iteration')
|
||||
expect(mockSetNodes.mock.calls[0]![0]).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'candidate-iteration' }),
|
||||
@ -231,7 +242,7 @@ describe('CandidateNodeMain', () => {
|
||||
]))
|
||||
|
||||
rerender(<CandidateNodeMain candidateNode={loopNode} />)
|
||||
eventHandlers.click?.({ preventDefault: vi.fn() })
|
||||
eventHandlers.click?.(createMouseEvent())
|
||||
|
||||
expect(mockGetLoopStartNode).toHaveBeenCalledWith('candidate-loop')
|
||||
expect(mockSetNodes.mock.calls[1]![0]).toEqual(expect.arrayContaining([
|
||||
@ -253,7 +264,7 @@ describe('CandidateNodeMain', () => {
|
||||
|
||||
render(<CandidateNodeMain candidateNode={candidateNode} />)
|
||||
|
||||
eventHandlers.contextmenu?.({ preventDefault: vi.fn() })
|
||||
eventHandlers.contextmenu?.(createMouseEvent())
|
||||
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ candidateNode: undefined })
|
||||
})
|
||||
|
||||
@ -527,6 +527,35 @@ describe('Workflow edge event wiring', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep canvas mouse coordinates outside reactive workflow store state', () => {
|
||||
const { container, store } = renderSubject()
|
||||
const pane = getPane(container)
|
||||
const setStateSpy = vi.spyOn(store, 'setState')
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseMove(pane, { clientX: 150, clientY: 180 })
|
||||
})
|
||||
|
||||
expect(setStateSpy).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
mousePosition: expect.anything(),
|
||||
}))
|
||||
|
||||
const getPointerPosition = (store.getState() as unknown as {
|
||||
getPointerPosition?: () => {
|
||||
pageX: number
|
||||
pageY: number
|
||||
elementX: number
|
||||
elementY: number
|
||||
}
|
||||
}).getPointerPosition
|
||||
|
||||
expect(typeof getPointerPosition).toBe('function')
|
||||
expect(getPointerPosition?.()).toMatchObject({
|
||||
pageX: 150,
|
||||
pageY: 180,
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep edge deletion delegated to workflow shortcuts instead of React Flow defaults', async () => {
|
||||
renderSubject({
|
||||
edges: [
|
||||
@ -665,12 +694,6 @@ describe('Workflow edge event wiring', () => {
|
||||
isCommentPlacing: true,
|
||||
pendingComment: null,
|
||||
isCommentPreviewHovering: true,
|
||||
mousePosition: {
|
||||
pageX: 100,
|
||||
pageY: 120,
|
||||
elementX: 40,
|
||||
elementY: 60,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type {
|
||||
FC,
|
||||
} from 'react'
|
||||
import type { PointerPosition } from './utils/pointer-position'
|
||||
import type {
|
||||
Node,
|
||||
} from '@/app/components/workflow/types'
|
||||
@ -8,6 +9,10 @@ import { useEventListener } from 'ahooks'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
useReactFlow,
|
||||
@ -20,11 +25,11 @@ import CustomNode from './nodes'
|
||||
import CustomNoteNode from './note-node'
|
||||
import { CUSTOM_NOTE_NODE } from './note-node/constants'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from './store'
|
||||
import { BlockEnum } from './types'
|
||||
import { getIterationStartNode, getLoopStartNode } from './utils'
|
||||
import { getPointerPositionFromEvent } from './utils/pointer-position'
|
||||
|
||||
type Props = {
|
||||
candidateNode: Node
|
||||
@ -34,19 +39,49 @@ const CandidateNodeMain: FC<Props> = ({
|
||||
}) => {
|
||||
const reactflow = useReactFlow()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const mousePosition = useStore(s => s.mousePosition)
|
||||
const { zoom } = useViewport()
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
const [pointerPosition, setPointerPosition] = useState<PointerPosition>(() => workflowStore.getState().getPointerPosition())
|
||||
const latestPointerPositionRef = useRef(pointerPosition)
|
||||
const pointerPositionFrameRef = useRef<number | undefined>(undefined)
|
||||
|
||||
const schedulePointerPositionUpdate = useCallback((position: PointerPosition) => {
|
||||
latestPointerPositionRef.current = position
|
||||
if (pointerPositionFrameRef.current)
|
||||
return
|
||||
|
||||
pointerPositionFrameRef.current = requestAnimationFrame(() => {
|
||||
pointerPositionFrameRef.current = undefined
|
||||
setPointerPosition(latestPointerPositionRef.current)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pointerPositionFrameRef.current) {
|
||||
cancelAnimationFrame(pointerPositionFrameRef.current)
|
||||
pointerPositionFrameRef.current = undefined
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEventListener('mousemove', (e) => {
|
||||
const position = getPointerPositionFromEvent(e, document.getElementById('workflow-container'))
|
||||
workflowStore.getState().setPointerPosition(position)
|
||||
schedulePointerPositionUpdate(position)
|
||||
})
|
||||
|
||||
useEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
const clickPosition = getPointerPositionFromEvent(e, document.getElementById('workflow-container'))
|
||||
workflowStore.getState().setPointerPosition(clickPosition)
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
|
||||
const { x, y } = screenToFlowPosition({ x: clickPosition.pageX, y: clickPosition.pageY })
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.push({
|
||||
...candidateNode,
|
||||
@ -92,8 +127,8 @@ const CandidateNodeMain: FC<Props> = ({
|
||||
<div
|
||||
className="absolute z-10"
|
||||
style={{
|
||||
left: mousePosition.elementX,
|
||||
top: mousePosition.elementY,
|
||||
left: pointerPosition.elementX,
|
||||
top: pointerPosition.elementY,
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
|
||||
@ -1,24 +1,27 @@
|
||||
import { useEventListener } from 'ahooks'
|
||||
import { useWorkflowComment } from './hooks/use-workflow-comment'
|
||||
import { useWorkflowStore } from './store'
|
||||
import { getPointerPositionFromEvent } from './utils/pointer-position'
|
||||
|
||||
const CommentManager = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleCreateComment, handleCommentCancel } = useWorkflowComment()
|
||||
|
||||
useEventListener('click', (e) => {
|
||||
const { controlMode, mousePosition, pendingComment, isCommentPlacing } = workflowStore.getState()
|
||||
const { controlMode, pendingComment, isCommentPlacing, setPointerPosition } = workflowStore.getState()
|
||||
const target = e.target as HTMLElement
|
||||
const isInDropdown = target.closest('[data-mention-dropdown]')
|
||||
const isInCommentInput = target.closest('[data-comment-input]')
|
||||
const isOnCanvasPane = target.closest('.react-flow__pane')
|
||||
const pointerPosition = getPointerPositionFromEvent(e, document.getElementById('workflow-container'))
|
||||
setPointerPosition(pointerPosition)
|
||||
|
||||
if (isCommentPlacing) {
|
||||
if (!isInDropdown && !isInCommentInput && isOnCanvasPane) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
workflowStore.setState({
|
||||
pendingComment: mousePosition,
|
||||
pendingComment: pointerPosition,
|
||||
isCommentPlacing: false,
|
||||
})
|
||||
}
|
||||
@ -34,7 +37,7 @@ const CommentManager = () => {
|
||||
if (pendingComment)
|
||||
handleCommentCancel()
|
||||
else
|
||||
handleCreateComment(mousePosition)
|
||||
handleCreateComment(pointerPosition)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -6,10 +6,13 @@ import { CommentCursor } from './cursor'
|
||||
const mockState = {
|
||||
controlMode: ControlMode.Pointer,
|
||||
isCommentPlacing: false,
|
||||
mousePosition: {
|
||||
elementX: 10,
|
||||
elementY: 20,
|
||||
},
|
||||
}
|
||||
|
||||
const pointerPosition = {
|
||||
pageX: 10,
|
||||
pageY: 20,
|
||||
elementX: 10,
|
||||
elementY: 20,
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/public/other', () => ({
|
||||
@ -28,7 +31,7 @@ describe('CommentCursor', () => {
|
||||
it('renders nothing when not in comment mode', () => {
|
||||
mockState.controlMode = ControlMode.Pointer
|
||||
|
||||
render(<CommentCursor />)
|
||||
render(<CommentCursor position={pointerPosition} />)
|
||||
|
||||
expect(screen.queryByTestId('comment-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
@ -37,7 +40,7 @@ describe('CommentCursor', () => {
|
||||
mockState.controlMode = ControlMode.Comment
|
||||
mockState.isCommentPlacing = false
|
||||
|
||||
render(<CommentCursor />)
|
||||
render(<CommentCursor position={pointerPosition} />)
|
||||
|
||||
const icon = screen.getByTestId('comment-icon')
|
||||
const container = icon.parentElement as HTMLElement
|
||||
@ -49,7 +52,7 @@ describe('CommentCursor', () => {
|
||||
mockState.controlMode = ControlMode.Comment
|
||||
mockState.isCommentPlacing = true
|
||||
|
||||
render(<CommentCursor />)
|
||||
render(<CommentCursor position={pointerPosition} />)
|
||||
|
||||
expect(screen.queryByTestId('comment-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import type { FC } from 'react'
|
||||
import type { PointerPosition } from '../utils/pointer-position'
|
||||
import { memo } from 'react'
|
||||
import { Comment } from '@/app/components/base/icons/src/public/other'
|
||||
import { useStore } from '../store'
|
||||
import { ControlMode } from '../types'
|
||||
|
||||
export const CommentCursor: FC = memo(() => {
|
||||
type CommentCursorProps = {
|
||||
position: PointerPosition
|
||||
}
|
||||
|
||||
export const CommentCursor: FC<CommentCursorProps> = memo(({
|
||||
position,
|
||||
}) => {
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
const mousePosition = useStore(s => s.mousePosition)
|
||||
const isCommentPlacing = useStore(s => s.isCommentPlacing)
|
||||
|
||||
if (controlMode !== ControlMode.Comment || isCommentPlacing)
|
||||
@ -16,8 +22,8 @@ export const CommentCursor: FC = memo(() => {
|
||||
<div
|
||||
className="pointer-events-none absolute z-50 flex size-6 items-center justify-center"
|
||||
style={{
|
||||
left: mousePosition.elementX,
|
||||
top: mousePosition.elementY,
|
||||
left: position.elementX,
|
||||
top: position.elementY,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
|
||||
@ -32,6 +32,26 @@ const runtimeState = vi.hoisted(() => ({
|
||||
let currentNodes: Node[] = []
|
||||
let currentEdges: Edge[] = []
|
||||
|
||||
type PointerPositionStore = {
|
||||
getState: () => {
|
||||
setPointerPosition: (position: {
|
||||
pageX: number
|
||||
pageY: number
|
||||
elementX: number
|
||||
elementY: number
|
||||
}) => void
|
||||
}
|
||||
}
|
||||
|
||||
const setPastePointerPosition = (store: PointerPositionStore) => {
|
||||
store.getState().setPointerPosition({
|
||||
pageX: 60,
|
||||
pageY: 80,
|
||||
elementX: 60,
|
||||
elementY: 80,
|
||||
})
|
||||
}
|
||||
|
||||
vi.mock('reactflow', async () =>
|
||||
(await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
|
||||
|
||||
@ -737,11 +757,8 @@ describe('useNodesInteractions', () => {
|
||||
},
|
||||
}),
|
||||
] as never,
|
||||
mousePosition: {
|
||||
pageX: 60,
|
||||
pageY: 80,
|
||||
} as never,
|
||||
})
|
||||
setPastePointerPosition(store)
|
||||
|
||||
act(() => {
|
||||
result.current.handleNodeAdd(
|
||||
@ -812,11 +829,8 @@ describe('useNodesInteractions', () => {
|
||||
}),
|
||||
] as never,
|
||||
clipboardEdges: [] as never,
|
||||
mousePosition: {
|
||||
pageX: 60,
|
||||
pageY: 80,
|
||||
} as never,
|
||||
})
|
||||
setPastePointerPosition(store)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleNodesPaste()
|
||||
@ -870,11 +884,8 @@ describe('useNodesInteractions', () => {
|
||||
}),
|
||||
] as never,
|
||||
clipboardEdges: [] as never,
|
||||
mousePosition: {
|
||||
pageX: 60,
|
||||
pageY: 80,
|
||||
} as never,
|
||||
})
|
||||
setPastePointerPosition(store)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleNodesPaste()
|
||||
@ -958,11 +969,8 @@ describe('useNodesInteractions', () => {
|
||||
}),
|
||||
] as never,
|
||||
clipboardEdges: [] as never,
|
||||
mousePosition: {
|
||||
pageX: 60,
|
||||
pageY: 80,
|
||||
} as never,
|
||||
})
|
||||
setPastePointerPosition(store)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleNodesPaste()
|
||||
@ -1043,11 +1051,8 @@ describe('useNodesInteractions', () => {
|
||||
}),
|
||||
] as never,
|
||||
clipboardEdges: [] as never,
|
||||
mousePosition: {
|
||||
pageX: 60,
|
||||
pageY: 80,
|
||||
} as never,
|
||||
})
|
||||
setPastePointerPosition(store)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleNodesPaste()
|
||||
|
||||
@ -1829,7 +1829,7 @@ export const useNodesInteractions = () => {
|
||||
const {
|
||||
clipboardElements: storeClipboardElements,
|
||||
clipboardEdges: storeClipboardEdges,
|
||||
mousePosition,
|
||||
getPointerPosition,
|
||||
setClipboardData,
|
||||
} = workflowStore.getState()
|
||||
const clipboardData = await readWorkflowClipboard(appDslVersion)
|
||||
@ -1920,9 +1920,10 @@ export const useNodesInteractions = () => {
|
||||
: compatibleClipboardElements
|
||||
const { x, y } = getTopLeftNodePosition(positionReferenceNodes)
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const pointerPosition = getPointerPosition()
|
||||
const currentPosition = screenToFlowPosition({
|
||||
x: mousePosition.pageX,
|
||||
y: mousePosition.pageY,
|
||||
x: pointerPosition.pageX,
|
||||
y: pointerPosition.pageY,
|
||||
})
|
||||
const offsetX = currentPosition.x - x
|
||||
const offsetY = currentPosition.y - y
|
||||
|
||||
@ -584,14 +584,14 @@ export const useWorkflowComment = () => {
|
||||
activeCommentIdRef.current = null
|
||||
}, [setActiveComment, setActiveCommentId, setActiveCommentLoading])
|
||||
|
||||
const handleCreateComment = useCallback((mousePosition: {
|
||||
const handleCreateComment = useCallback((pointerPosition: {
|
||||
pageX: number
|
||||
pageY: number
|
||||
elementX: number
|
||||
elementY: number
|
||||
}) => {
|
||||
if (controlMode === ControlMode.Comment)
|
||||
setPendingComment(mousePosition)
|
||||
setPendingComment(pointerPosition)
|
||||
}, [controlMode, setPendingComment])
|
||||
|
||||
return {
|
||||
|
||||
@ -13,6 +13,7 @@ import type {
|
||||
EnvironmentVariable,
|
||||
Node,
|
||||
} from './types'
|
||||
import type { PointerPosition } from './utils/pointer-position'
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
import {
|
||||
AlertDialog,
|
||||
@ -118,6 +119,10 @@ import {
|
||||
WorkflowRunningStatus,
|
||||
} from './types'
|
||||
import { setupScrollToNodeListener } from './utils/node-navigation'
|
||||
import {
|
||||
DEFAULT_POINTER_POSITION,
|
||||
getPointerPositionFromEvent,
|
||||
} from './utils/pointer-position'
|
||||
import { WorkflowContextmenu } from './workflow-contextmenu'
|
||||
import 'reactflow/dist/style.css'
|
||||
import './style.css'
|
||||
@ -156,15 +161,16 @@ export type WorkflowProps = {
|
||||
}
|
||||
|
||||
const CommentPlacementPreview = memo(({
|
||||
position,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
position: PointerPosition
|
||||
onSubmit: (content: string, mentionedUserIds: string[]) => void
|
||||
onCancel: () => void
|
||||
}) => {
|
||||
const isCommentPlacing = useStore(s => s.isCommentPlacing)
|
||||
const pendingComment = useStore(s => s.pendingComment)
|
||||
const mousePosition = useStore(s => s.mousePosition)
|
||||
|
||||
if (!isCommentPlacing || pendingComment)
|
||||
return null
|
||||
@ -172,8 +178,8 @@ const CommentPlacementPreview = memo(({
|
||||
return (
|
||||
<CommentInput
|
||||
position={{
|
||||
x: mousePosition.elementX,
|
||||
y: mousePosition.elementY,
|
||||
x: position.elementX,
|
||||
y: position.elementY,
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
@ -201,6 +207,10 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
const reactflow = useReactFlow()
|
||||
const store = useStoreApi()
|
||||
const [isMouseOverCanvas, setIsMouseOverCanvas] = useState(false)
|
||||
const [commentPointerPosition, setCommentPointerPosition] = useState<PointerPosition>(DEFAULT_POINTER_POSITION)
|
||||
const latestCommentPointerPositionRef = useRef<PointerPosition>(DEFAULT_POINTER_POSITION)
|
||||
const commentPointerPositionFrameRef = useRef<number | undefined>(undefined)
|
||||
const isMouseOverCanvasRef = useRef(false)
|
||||
const [nodes, setNodes] = useNodesState(originalNodes)
|
||||
const [edges, setEdges] = useEdgesState(originalEdges)
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
@ -218,6 +228,26 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
return workflowCanvasHeight - bottomPanelHeight
|
||||
}, [workflowCanvasHeight, bottomPanelHeight])
|
||||
|
||||
const scheduleCommentPointerPositionUpdate = useCallback((position: PointerPosition) => {
|
||||
latestCommentPointerPositionRef.current = position
|
||||
if (commentPointerPositionFrameRef.current)
|
||||
return
|
||||
|
||||
commentPointerPositionFrameRef.current = requestAnimationFrame(() => {
|
||||
commentPointerPositionFrameRef.current = undefined
|
||||
setCommentPointerPosition(latestCommentPointerPositionRef.current)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (commentPointerPositionFrameRef.current) {
|
||||
cancelAnimationFrame(commentPointerPositionFrameRef.current)
|
||||
commentPointerPositionFrameRef.current = undefined
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// update workflow Canvas width and height
|
||||
useEffect(() => {
|
||||
if (workflowContainerRef.current) {
|
||||
@ -339,6 +369,10 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
const setCommentQuickAdd = useStore(s => s.setCommentQuickAdd)
|
||||
const setPendingCommentState = useStore(s => s.setPendingComment)
|
||||
const isCommentInputActive = Boolean(pendingComment) || isCommentPlacing
|
||||
const commentPointerPositionForRender = controlMode === ControlMode.Comment || isCommentPlacing
|
||||
? workflowStore.getState().getPointerPosition()
|
||||
: commentPointerPosition
|
||||
|
||||
const visibleComments = useMemo(() => {
|
||||
if (showResolvedComments)
|
||||
return comments
|
||||
@ -469,20 +503,27 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
e.preventDefault()
|
||||
})
|
||||
useEventListener('mousemove', (e) => {
|
||||
const containerClientRect = workflowContainerRef.current?.getBoundingClientRect()
|
||||
const container = workflowContainerRef.current
|
||||
|
||||
if (container) {
|
||||
const pointerPosition = getPointerPositionFromEvent(e, container)
|
||||
workflowStore.getState().setPointerPosition(pointerPosition)
|
||||
|
||||
if (controlMode === ControlMode.Comment || isCommentPlacing)
|
||||
scheduleCommentPointerPositionUpdate(pointerPosition)
|
||||
|
||||
if (containerClientRect) {
|
||||
workflowStore.setState({
|
||||
mousePosition: {
|
||||
pageX: e.clientX,
|
||||
pageY: e.clientY,
|
||||
elementX: e.clientX - containerClientRect.left,
|
||||
elementY: e.clientY - containerClientRect.top,
|
||||
},
|
||||
})
|
||||
const target = e.target as HTMLElement
|
||||
const onPane = !!target?.closest('.react-flow__pane')
|
||||
setIsMouseOverCanvas(onPane)
|
||||
if (controlMode === ControlMode.Comment) {
|
||||
const onPane = !!target?.closest('.react-flow__pane')
|
||||
if (isMouseOverCanvasRef.current !== onPane) {
|
||||
isMouseOverCanvasRef.current = onPane
|
||||
setIsMouseOverCanvas(onPane)
|
||||
}
|
||||
}
|
||||
else if (isMouseOverCanvasRef.current) {
|
||||
isMouseOverCanvasRef.current = false
|
||||
setIsMouseOverCanvas(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -652,9 +693,10 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{controlMode === ControlMode.Comment && isMouseOverCanvas && (
|
||||
<CommentCursor />
|
||||
<CommentCursor position={commentPointerPositionForRender} />
|
||||
)}
|
||||
<CommentPlacementPreview
|
||||
position={commentPointerPositionForRender}
|
||||
onSubmit={handleCommentSubmit}
|
||||
onCancel={handleCommentPlacementCancel}
|
||||
/>
|
||||
|
||||
@ -55,7 +55,6 @@ describe('createWorkflowStore', () => {
|
||||
['clipboardEdges', 'setClipboardEdges', []],
|
||||
['selection', 'setSelection', { x1: 0, y1: 0, x2: 100, y2: 100 }],
|
||||
['bundleNodeSize', 'setBundleNodeSize', { width: 200, height: 100 }],
|
||||
['mousePosition', 'setMousePosition', { pageX: 10, pageY: 20, elementX: 5, elementY: 15 }],
|
||||
['showConfirm', 'setShowConfirm', { title: 'Delete?', onConfirm: vi.fn() }],
|
||||
['controlPromptEditorRerenderKey', 'setControlPromptEditorRerenderKey', 42],
|
||||
['showImportDSLModal', 'setShowImportDSLModal', true],
|
||||
@ -64,6 +63,18 @@ describe('createWorkflowStore', () => {
|
||||
testSetter(setter, stateKey, value)
|
||||
})
|
||||
|
||||
it('should update pointer coordinates without replacing workflow store state', () => {
|
||||
const store = createStore()
|
||||
const beforeState = store.getState()
|
||||
const pointerPosition = { pageX: 10, pageY: 20, elementX: 5, elementY: 15 }
|
||||
|
||||
beforeState.setPointerPosition(pointerPosition)
|
||||
|
||||
expect(store.getState()).toBe(beforeState)
|
||||
expect(store.getState().getPointerPosition()).toEqual(pointerPosition)
|
||||
expect(store.getState().pointerPositionRef.current).toEqual(pointerPosition)
|
||||
})
|
||||
|
||||
it('should persist controlMode to localStorage', () => {
|
||||
const store = createStore()
|
||||
store.getState().setControlMode('pointer')
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { PointerPosition } from '../../utils/pointer-position'
|
||||
import type {
|
||||
Edge,
|
||||
Node,
|
||||
@ -6,6 +7,7 @@ import type {
|
||||
WorkflowRunningData,
|
||||
} from '@/app/components/workflow/types'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import { DEFAULT_POINTER_POSITION } from '../../utils/pointer-position'
|
||||
|
||||
type PreviewRunningData = WorkflowRunningData & {
|
||||
resultTabActive?: boolean
|
||||
@ -14,13 +16,6 @@ type PreviewRunningData = WorkflowRunningData & {
|
||||
extraContentAndFormData?: Record<string, any>
|
||||
}
|
||||
|
||||
type MousePosition = {
|
||||
pageX: number
|
||||
pageY: number
|
||||
elementX: number
|
||||
elementY: number
|
||||
}
|
||||
|
||||
export type WorkflowSliceShape = {
|
||||
workflowRunningData?: PreviewRunningData
|
||||
setWorkflowRunningData: (workflowData: PreviewRunningData) => void
|
||||
@ -45,7 +40,7 @@ export type WorkflowSliceShape = {
|
||||
setBundleNodeSize: (bundleNodeSize: WorkflowSliceShape['bundleNodeSize']) => void
|
||||
controlMode: 'pointer' | 'hand' | 'comment'
|
||||
setControlMode: (controlMode: WorkflowSliceShape['controlMode']) => void
|
||||
pendingComment: MousePosition | null
|
||||
pendingComment: PointerPosition | null
|
||||
setPendingComment: (pendingComment: WorkflowSliceShape['pendingComment']) => void
|
||||
isCommentPlacing: boolean
|
||||
setCommentPlacing: (isCommentPlacing: boolean) => void
|
||||
@ -53,8 +48,9 @@ export type WorkflowSliceShape = {
|
||||
setCommentQuickAdd: (isCommentQuickAdd: boolean) => void
|
||||
isCommentPreviewHovering: boolean
|
||||
setCommentPreviewHovering: (hovering: boolean) => void
|
||||
mousePosition: { pageX: number, pageY: number, elementX: number, elementY: number }
|
||||
setMousePosition: (mousePosition: WorkflowSliceShape['mousePosition']) => void
|
||||
pointerPositionRef: { current: PointerPosition }
|
||||
getPointerPosition: () => PointerPosition
|
||||
setPointerPosition: (pointerPosition: PointerPosition) => void
|
||||
showConfirm?: { title: string, desc?: string, onConfirm: () => void }
|
||||
setShowConfirm: (showConfirm: WorkflowSliceShape['showConfirm']) => void
|
||||
controlPromptEditorRerenderKey: number
|
||||
@ -65,60 +61,67 @@ export type WorkflowSliceShape = {
|
||||
setFileUploadConfig: (fileUploadConfig: FileUploadConfigResponse) => void
|
||||
}
|
||||
|
||||
export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
|
||||
workflowRunningData: undefined,
|
||||
setWorkflowRunningData: workflowRunningData => set(() => ({ workflowRunningData })),
|
||||
isListening: false,
|
||||
setIsListening: listening => set(() => ({ isListening: listening })),
|
||||
listeningTriggerType: null,
|
||||
setListeningTriggerType: triggerType => set(() => ({ listeningTriggerType: triggerType })),
|
||||
listeningTriggerNodeId: null,
|
||||
setListeningTriggerNodeId: nodeId => set(() => ({ listeningTriggerNodeId: nodeId })),
|
||||
listeningTriggerNodeIds: [],
|
||||
setListeningTriggerNodeIds: nodeIds => set(() => ({ listeningTriggerNodeIds: nodeIds })),
|
||||
listeningTriggerIsAll: false,
|
||||
setListeningTriggerIsAll: isAll => set(() => ({ listeningTriggerIsAll: isAll })),
|
||||
clipboardElements: [],
|
||||
clipboardEdges: [],
|
||||
setClipboardElements: clipboardElements => set(() => ({ clipboardElements })),
|
||||
setClipboardEdges: clipboardEdges => set(() => ({ clipboardEdges })),
|
||||
setClipboardData: ({ nodes, edges }) => {
|
||||
set(() => ({
|
||||
clipboardElements: nodes,
|
||||
clipboardEdges: edges,
|
||||
}))
|
||||
},
|
||||
selection: null,
|
||||
setSelection: selection => set(() => ({ selection })),
|
||||
bundleNodeSize: null,
|
||||
setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })),
|
||||
controlMode: (() => {
|
||||
const storedControlMode = localStorage.getItem('workflow-operation-mode')
|
||||
if (storedControlMode === 'pointer' || storedControlMode === 'hand' || storedControlMode === 'comment')
|
||||
return storedControlMode
|
||||
export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = (set) => {
|
||||
const pointerPositionRef = { current: DEFAULT_POINTER_POSITION }
|
||||
|
||||
return 'pointer'
|
||||
})(),
|
||||
setControlMode: (controlMode) => {
|
||||
set(() => ({ controlMode }))
|
||||
localStorage.setItem('workflow-operation-mode', controlMode)
|
||||
},
|
||||
pendingComment: null,
|
||||
setPendingComment: pendingComment => set(() => ({ pendingComment })),
|
||||
isCommentPlacing: false,
|
||||
setCommentPlacing: isCommentPlacing => set(() => ({ isCommentPlacing })),
|
||||
isCommentQuickAdd: false,
|
||||
setCommentQuickAdd: isCommentQuickAdd => set(() => ({ isCommentQuickAdd })),
|
||||
mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 },
|
||||
setMousePosition: mousePosition => set(() => ({ mousePosition })),
|
||||
isCommentPreviewHovering: false,
|
||||
setCommentPreviewHovering: hovering => set(() => ({ isCommentPreviewHovering: hovering })),
|
||||
showConfirm: undefined,
|
||||
setShowConfirm: showConfirm => set(() => ({ showConfirm })),
|
||||
controlPromptEditorRerenderKey: 0,
|
||||
setControlPromptEditorRerenderKey: controlPromptEditorRerenderKey => set(() => ({ controlPromptEditorRerenderKey })),
|
||||
showImportDSLModal: false,
|
||||
setShowImportDSLModal: showImportDSLModal => set(() => ({ showImportDSLModal })),
|
||||
fileUploadConfig: undefined,
|
||||
setFileUploadConfig: fileUploadConfig => set(() => ({ fileUploadConfig })),
|
||||
})
|
||||
return {
|
||||
workflowRunningData: undefined,
|
||||
setWorkflowRunningData: workflowRunningData => set(() => ({ workflowRunningData })),
|
||||
isListening: false,
|
||||
setIsListening: listening => set(() => ({ isListening: listening })),
|
||||
listeningTriggerType: null,
|
||||
setListeningTriggerType: triggerType => set(() => ({ listeningTriggerType: triggerType })),
|
||||
listeningTriggerNodeId: null,
|
||||
setListeningTriggerNodeId: nodeId => set(() => ({ listeningTriggerNodeId: nodeId })),
|
||||
listeningTriggerNodeIds: [],
|
||||
setListeningTriggerNodeIds: nodeIds => set(() => ({ listeningTriggerNodeIds: nodeIds })),
|
||||
listeningTriggerIsAll: false,
|
||||
setListeningTriggerIsAll: isAll => set(() => ({ listeningTriggerIsAll: isAll })),
|
||||
clipboardElements: [],
|
||||
clipboardEdges: [],
|
||||
setClipboardElements: clipboardElements => set(() => ({ clipboardElements })),
|
||||
setClipboardEdges: clipboardEdges => set(() => ({ clipboardEdges })),
|
||||
setClipboardData: ({ nodes, edges }) => {
|
||||
set(() => ({
|
||||
clipboardElements: nodes,
|
||||
clipboardEdges: edges,
|
||||
}))
|
||||
},
|
||||
selection: null,
|
||||
setSelection: selection => set(() => ({ selection })),
|
||||
bundleNodeSize: null,
|
||||
setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })),
|
||||
controlMode: (() => {
|
||||
const storedControlMode = localStorage.getItem('workflow-operation-mode')
|
||||
if (storedControlMode === 'pointer' || storedControlMode === 'hand' || storedControlMode === 'comment')
|
||||
return storedControlMode
|
||||
|
||||
return 'pointer'
|
||||
})(),
|
||||
setControlMode: (controlMode) => {
|
||||
set(() => ({ controlMode }))
|
||||
localStorage.setItem('workflow-operation-mode', controlMode)
|
||||
},
|
||||
pendingComment: null,
|
||||
setPendingComment: pendingComment => set(() => ({ pendingComment })),
|
||||
isCommentPlacing: false,
|
||||
setCommentPlacing: isCommentPlacing => set(() => ({ isCommentPlacing })),
|
||||
isCommentQuickAdd: false,
|
||||
setCommentQuickAdd: isCommentQuickAdd => set(() => ({ isCommentQuickAdd })),
|
||||
pointerPositionRef,
|
||||
getPointerPosition: () => pointerPositionRef.current,
|
||||
setPointerPosition: (pointerPosition) => {
|
||||
pointerPositionRef.current = pointerPosition
|
||||
},
|
||||
isCommentPreviewHovering: false,
|
||||
setCommentPreviewHovering: hovering => set(() => ({ isCommentPreviewHovering: hovering })),
|
||||
showConfirm: undefined,
|
||||
setShowConfirm: showConfirm => set(() => ({ showConfirm })),
|
||||
controlPromptEditorRerenderKey: 0,
|
||||
setControlPromptEditorRerenderKey: controlPromptEditorRerenderKey => set(() => ({ controlPromptEditorRerenderKey })),
|
||||
showImportDSLModal: false,
|
||||
setShowImportDSLModal: showImportDSLModal => set(() => ({ showImportDSLModal })),
|
||||
fileUploadConfig: undefined,
|
||||
setFileUploadConfig: fileUploadConfig => set(() => ({ fileUploadConfig })),
|
||||
}
|
||||
}
|
||||
|
||||
34
web/app/components/workflow/utils/pointer-position.ts
Normal file
34
web/app/components/workflow/utils/pointer-position.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export type PointerPosition = {
|
||||
pageX: number
|
||||
pageY: number
|
||||
elementX: number
|
||||
elementY: number
|
||||
}
|
||||
|
||||
export const DEFAULT_POINTER_POSITION: PointerPosition = {
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
elementX: 0,
|
||||
elementY: 0,
|
||||
}
|
||||
|
||||
type ClientPoint = {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}
|
||||
|
||||
export const getPointerPositionFromEvent = (
|
||||
event: ClientPoint,
|
||||
container: Element | null | undefined,
|
||||
): PointerPosition => {
|
||||
const rect = container?.getBoundingClientRect()
|
||||
const left = rect?.left ?? 0
|
||||
const top = rect?.top ?? 0
|
||||
|
||||
return {
|
||||
pageX: event.clientX,
|
||||
pageY: event.clientY,
|
||||
elementX: event.clientX - left,
|
||||
elementY: event.clientY - top,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user