Compare commits

...

1 Commits

5 changed files with 198 additions and 6 deletions

View File

@@ -13,7 +13,7 @@ import {
useWorkflowStore, useWorkflowStore,
} from './store' } from './store'
import { WorkflowHistoryEvent, useNodesInteractions, useWorkflowHistory } from './hooks' import { WorkflowHistoryEvent, useNodesInteractions, useWorkflowHistory } from './hooks'
import { CUSTOM_NODE } from './constants' import { CUSTOM_NODE, ITERATION_PADDING } from './constants'
import { getIterationStartNode, getLoopStartNode } from './utils' import { getIterationStartNode, getLoopStartNode } from './utils'
import CustomNode from './nodes' import CustomNode from './nodes'
import CustomNoteNode from './note-node' import CustomNoteNode from './note-node'
@@ -41,7 +41,33 @@ const CandidateNode = () => {
} = store.getState() } = store.getState()
const { screenToFlowPosition } = reactflow const { screenToFlowPosition } = reactflow
const nodes = getNodes() const nodes = getNodes()
const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY }) // Get mouse position in flow coordinates (this is where the top-left corner should be)
let { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
// If the node has a parent (e.g., inside iteration), apply constraints and convert to relative position
if (candidateNode.parentId) {
const parentNode = nodes.find(node => node.id === candidateNode.parentId)
if (parentNode && parentNode.position) {
// Apply boundary constraints for iteration nodes
if (candidateNode.data.isInIteration) {
const nodeWidth = candidateNode.width || 0
const nodeHeight = candidateNode.height || 0
const minX = parentNode.position.x + ITERATION_PADDING.left
const maxX = parentNode.position.x + (parentNode.width || 0) - ITERATION_PADDING.right - nodeWidth
const minY = parentNode.position.y + ITERATION_PADDING.top
const maxY = parentNode.position.y + (parentNode.height || 0) - ITERATION_PADDING.bottom - nodeHeight
// Constrain position
x = Math.max(minX, Math.min(maxX, x))
y = Math.max(minY, Math.min(maxY, y))
}
// Convert to relative position
x = x - parentNode.position.x
y = y - parentNode.position.y
}
}
const newNodes = produce(nodes, (draft) => { const newNodes = produce(nodes, (draft) => {
draft.push({ draft.push({
...candidateNode, ...candidateNode,
@@ -59,6 +85,20 @@ const CandidateNode = () => {
if (candidateNode.data.type === BlockEnum.Loop) if (candidateNode.data.type === BlockEnum.Loop)
draft.push(getLoopStartNode(candidateNode.id)) draft.push(getLoopStartNode(candidateNode.id))
// Update parent iteration node's _children array
if (candidateNode.parentId && candidateNode.data.isInIteration) {
const parentNode = draft.find(node => node.id === candidateNode.parentId)
if (parentNode && parentNode.data.type === BlockEnum.Iteration) {
if (!parentNode.data._children)
parentNode.data._children = []
parentNode.data._children.push({
nodeId: candidateNode.id,
nodeType: candidateNode.data.type,
})
}
}
}) })
setNodes(newNodes) setNodes(newNodes)
if (candidateNode.type === CUSTOM_NOTE_NODE) if (candidateNode.type === CUSTOM_NOTE_NODE)
@@ -84,6 +124,34 @@ const CandidateNode = () => {
if (!candidateNode) if (!candidateNode)
return null return null
// Apply boundary constraints if node is inside iteration
if (candidateNode.parentId && candidateNode.data.isInIteration) {
const { getNodes } = store.getState()
const nodes = getNodes()
const parentNode = nodes.find(node => node.id === candidateNode.parentId)
if (parentNode && parentNode.position) {
const { screenToFlowPosition, flowToScreenPosition } = reactflow
// Get mouse position in flow coordinates
const flowPosition = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
// Calculate boundaries in flow coordinates
const nodeWidth = candidateNode.width || 0
const nodeHeight = candidateNode.height || 0
const minX = parentNode.position.x + ITERATION_PADDING.left
const maxX = parentNode.position.x + (parentNode.width || 0) - ITERATION_PADDING.right - nodeWidth
const minY = parentNode.position.y + ITERATION_PADDING.top
const maxY = parentNode.position.y + (parentNode.height || 0) - ITERATION_PADDING.bottom - nodeHeight
// Constrain position
const constrainedX = Math.max(minX, Math.min(maxX, flowPosition.x))
const constrainedY = Math.max(minY, Math.min(maxY, flowPosition.y))
// Convert back to screen coordinates
flowToScreenPosition({ x: constrainedX, y: constrainedY })
}
}
return ( return (
<div <div
className='absolute z-10' className='absolute z-10'

View File

@@ -737,7 +737,7 @@ export const useNodesInteractions = () => {
targetHandle = 'target', targetHandle = 'target',
toolDefaultValue, toolDefaultValue,
}, },
{ prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle }, { prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle, skipAutoConnect },
) => { ) => {
if (getNodesReadOnly()) return if (getNodesReadOnly()) return
@@ -830,7 +830,7 @@ export const useNodesInteractions = () => {
} }
let newEdge = null let newEdge = null
if (nodeType !== BlockEnum.DataSource) { if (nodeType !== BlockEnum.DataSource && !skipAutoConnect) {
newEdge = { newEdge = {
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
type: CUSTOM_EDGE, type: CUSTOM_EDGE,
@@ -970,6 +970,7 @@ export const useNodesInteractions = () => {
nodeType !== BlockEnum.IfElse nodeType !== BlockEnum.IfElse
&& nodeType !== BlockEnum.QuestionClassifier && nodeType !== BlockEnum.QuestionClassifier
&& nodeType !== BlockEnum.LoopEnd && nodeType !== BlockEnum.LoopEnd
&& !skipAutoConnect
) { ) {
newEdge = { newEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
@@ -1119,7 +1120,7 @@ export const useNodesInteractions = () => {
) )
let newPrevEdge = null let newPrevEdge = null
if (nodeType !== BlockEnum.DataSource) { if (nodeType !== BlockEnum.DataSource && !skipAutoConnect) {
newPrevEdge = { newPrevEdge = {
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
type: CUSTOM_EDGE, type: CUSTOM_EDGE,
@@ -1159,6 +1160,7 @@ export const useNodesInteractions = () => {
nodeType !== BlockEnum.IfElse nodeType !== BlockEnum.IfElse
&& nodeType !== BlockEnum.QuestionClassifier && nodeType !== BlockEnum.QuestionClassifier
&& nodeType !== BlockEnum.LoopEnd && nodeType !== BlockEnum.LoopEnd
&& !skipAutoConnect
) { ) {
newNextEdge = { newNextEdge = {
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,

View File

@@ -15,7 +15,9 @@ import {
useNodesSyncDraft, useNodesSyncDraft,
} from '@/app/components/workflow/hooks' } from '@/app/components/workflow/hooks'
import ShortcutsName from '@/app/components/workflow/shortcuts-name' import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import type { Node } from '@/app/components/workflow/types' import { BlockEnum, type Node } from '@/app/components/workflow/types'
import PanelAddBlock from '@/app/components/workflow/nodes/iteration/panel-add-block'
import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
type PanelOperatorPopupProps = { type PanelOperatorPopupProps = {
id: string id: string
@@ -51,6 +53,9 @@ const PanelOperatorPopup = ({
(showChangeBlock || canRunBySingle(data.type, isChildNode)) && ( (showChangeBlock || canRunBySingle(data.type, isChildNode)) && (
<> <>
<div className='p-1'> <div className='p-1'>
{data.type === BlockEnum.Iteration && (
<PanelAddBlock iterationNodeData={data as IterationNodeType} onClosePopup={onClosePopup}/>
)}
{ {
canRunBySingle(data.type, isChildNode) && ( canRunBySingle(data.type, isChildNode) && (
<div <div

View File

@@ -0,0 +1,116 @@
import {
memo,
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import type { OffsetOptions } from '@floating-ui/react'
import { useStoreApi } from 'reactflow'
import BlockSelector from '@/app/components/workflow/block-selector'
import type {
OnSelectBlock,
} from '@/app/components/workflow/types'
import {
BlockEnum,
} from '@/app/components/workflow/types'
import { useAvailableBlocks, useNodesMetaData, useNodesReadOnly, usePanelInteractions } from '../../hooks'
import type { IterationNodeType } from './types'
import { useWorkflowStore } from '../../store'
import { generateNewNode, getNodeCustomTypeByNodeDataType } from '../../utils'
import { ITERATION_CHILDREN_Z_INDEX } from '../../constants'
type AddBlockProps = {
renderTrigger?: (open: boolean) => React.ReactNode
offset?: OffsetOptions
iterationNodeData: IterationNodeType
onClosePopup: () => void
}
const AddBlock = ({
offset,
iterationNodeData,
onClosePopup,
}: AddBlockProps) => {
const { t } = useTranslation()
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const { nodesReadOnly } = useNodesReadOnly()
const { handlePaneContextmenuCancel } = usePanelInteractions()
const [open, setOpen] = useState(false)
const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false)
const { nodesMap: nodesMetaDataMap } = useNodesMetaData()
const handleOpenChange = useCallback((open: boolean) => {
setOpen(open)
if (!open)
handlePaneContextmenuCancel()
}, [handlePaneContextmenuCancel])
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
const { getNodes } = store.getState()
const nodes = getNodes()
const nodesWithSameType = nodes.filter(node => node.data.type === type)
const { defaultValue } = nodesMetaDataMap![type]
// Find the parent iteration node
const parentIterationNode = nodes.find(node => node.data.start_node_id === iterationNodeData.start_node_id)
const { newNode } = generateNewNode({
type: getNodeCustomTypeByNodeDataType(type),
data: {
...(defaultValue as any),
title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title,
...toolDefaultValue,
_isCandidate: true,
// Set iteration-specific properties
isInIteration: true,
iteration_id: parentIterationNode?.id,
},
position: {
x: 0,
y: 0,
},
})
// Set parent and z-index for iteration child
if (parentIterationNode) {
newNode.parentId = parentIterationNode.id
newNode.extent = 'parent' as any
newNode.zIndex = ITERATION_CHILDREN_Z_INDEX
}
workflowStore.setState({
candidateNode: newNode,
})
onClosePopup()
}, [store, workflowStore, nodesMetaDataMap, iterationNodeData.start_node_id, onClosePopup])
const renderTrigger = () => {
return (
<div
className='flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover'
>
{t('workflow.common.addBlock')}
</div>
)
}
return (
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
disabled={nodesReadOnly}
onSelect={handleSelect}
placement='right-start'
offset={offset ?? {
mainAxis: 4,
crossAxis: -8,
}}
trigger={renderTrigger}
popupClassName='!min-w-[256px]'
availableBlocksTypes={availableNextBlocks}
/>
)
}
export default memo(AddBlock)

View File

@@ -379,6 +379,7 @@ export type OnNodeAdd = (
prevNodeSourceHandle?: string prevNodeSourceHandle?: string
nextNodeId?: string nextNodeId?: string
nextNodeTargetHandle?: string nextNodeTargetHandle?: string
skipAutoConnect?: boolean
}, },
) => void ) => void