Compare commits

...

1 Commits

Author SHA1 Message Date
45e8187182 fix(workflow): keep pointer position out of reactive store 2026-05-27 17:06:07 +08:00
13 changed files with 337 additions and 160 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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