From 2e0a7857f0562013302c01fbd55185d2b991b2b0 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Fri, 24 Oct 2025 14:47:29 +0800 Subject: [PATCH] feat: enhance candidate node interactions with iteration constraints and add block functionality --- .../components/workflow/candidate-node.tsx | 72 ++++++++++- .../workflow/hooks/use-nodes-interactions.ts | 8 +- .../panel-operator/panel-operator-popup.tsx | 7 +- .../nodes/iteration/panel-add-block.tsx | 116 ++++++++++++++++++ web/app/components/workflow/types.ts | 1 + 5 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 web/app/components/workflow/nodes/iteration/panel-add-block.tsx diff --git a/web/app/components/workflow/candidate-node.tsx b/web/app/components/workflow/candidate-node.tsx index 6f2389aad2..5a1fd825fb 100644 --- a/web/app/components/workflow/candidate-node.tsx +++ b/web/app/components/workflow/candidate-node.tsx @@ -13,7 +13,7 @@ import { useWorkflowStore, } from './store' import { WorkflowHistoryEvent, useNodesInteractions, useWorkflowHistory } from './hooks' -import { CUSTOM_NODE } from './constants' +import { CUSTOM_NODE, ITERATION_PADDING } from './constants' import { getIterationStartNode, getLoopStartNode } from './utils' import CustomNode from './nodes' import CustomNoteNode from './note-node' @@ -41,7 +41,33 @@ const CandidateNode = () => { } = store.getState() const { screenToFlowPosition } = reactflow 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) => { draft.push({ ...candidateNode, @@ -59,6 +85,20 @@ const CandidateNode = () => { if (candidateNode.data.type === BlockEnum.Loop) 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) if (candidateNode.type === CUSTOM_NOTE_NODE) @@ -84,6 +124,34 @@ const CandidateNode = () => { if (!candidateNode) 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 (
{ targetHandle = 'target', toolDefaultValue, }, - { prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle }, + { prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle, skipAutoConnect }, ) => { if (getNodesReadOnly()) return @@ -830,7 +830,7 @@ export const useNodesInteractions = () => { } let newEdge = null - if (nodeType !== BlockEnum.DataSource) { + if (nodeType !== BlockEnum.DataSource && !skipAutoConnect) { newEdge = { id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, type: CUSTOM_EDGE, @@ -970,6 +970,7 @@ export const useNodesInteractions = () => { nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier && nodeType !== BlockEnum.LoopEnd + && !skipAutoConnect ) { newEdge = { id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, @@ -1119,7 +1120,7 @@ export const useNodesInteractions = () => { ) let newPrevEdge = null - if (nodeType !== BlockEnum.DataSource) { + if (nodeType !== BlockEnum.DataSource && !skipAutoConnect) { newPrevEdge = { id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, type: CUSTOM_EDGE, @@ -1159,6 +1160,7 @@ export const useNodesInteractions = () => { nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier && nodeType !== BlockEnum.LoopEnd + && !skipAutoConnect ) { newNextEdge = { id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx index a871e60e3a..5fa3257a3f 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx @@ -15,7 +15,9 @@ import { useNodesSyncDraft, } from '@/app/components/workflow/hooks' 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 = { id: string @@ -51,6 +53,9 @@ const PanelOperatorPopup = ({ (showChangeBlock || canRunBySingle(data.type, isChildNode)) && ( <>
+ {data.type === BlockEnum.Iteration && ( + + )} { canRunBySingle(data.type, isChildNode) && (
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((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 ( +
+ {t('workflow.common.addBlock')} +
+ ) + } + + return ( + + ) +} + +export default memo(AddBlock) diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index f6a706a982..55afde0bfd 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -379,6 +379,7 @@ export type OnNodeAdd = ( prevNodeSourceHandle?: string nextNodeId?: string nextNodeTargetHandle?: string + skipAutoConnect?: boolean }, ) => void