diff --git a/packages/global/core/workflow/type/index.d.ts b/packages/global/core/workflow/type/index.d.ts index a90aa4677..4fb7611e0 100644 --- a/packages/global/core/workflow/type/index.d.ts +++ b/packages/global/core/workflow/type/index.d.ts @@ -80,3 +80,17 @@ export type SystemPluginTemplateItemType = WorkflowTemplateType & { workflow: WorkflowTemplateBasicType; }; + +export type THelperLine = { + position: number; + nodes: { + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; + centerX: number; + centerY: number; + }[]; +}; diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/HelperLines.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/HelperLines.tsx new file mode 100644 index 000000000..bd1bce3d3 --- /dev/null +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/HelperLines.tsx @@ -0,0 +1,97 @@ +import { THelperLine } from '@fastgpt/global/core/workflow/type'; +import { CSSProperties, useEffect, useRef } from 'react'; +import { ReactFlowState, useStore, useViewport } from 'reactflow'; + +const canvasStyle: CSSProperties = { + width: '100%', + height: '100%', + position: 'absolute', + zIndex: 10, + pointerEvents: 'none' +}; + +const storeSelector = (state: ReactFlowState) => ({ + width: state.width, + height: state.height, + transform: state.transform +}); + +export type HelperLinesProps = { + horizontal?: THelperLine; + vertical?: THelperLine; +}; + +function HelperLinesRenderer({ horizontal, vertical }: HelperLinesProps) { + const { width, height, transform } = useStore(storeSelector); + const { zoom } = useViewport(); + + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + + if (!ctx || !canvas) { + return; + } + + const dpi = window.devicePixelRatio; + canvas.width = width * dpi; + canvas.height = height * dpi; + + ctx.scale(dpi, dpi); + ctx.clearRect(0, 0, width, height); + ctx.strokeStyle = '#D92D20'; + + const drawCross = (x: number, y: number, size: number) => { + ctx.beginPath(); + ctx.moveTo(x - size, y - size); + ctx.lineTo(x + size, y + size); + ctx.moveTo(x + size, y - size); + ctx.lineTo(x - size, y + size); + ctx.stroke(); + }; + + if (vertical) { + const x = vertical.position * transform[2] + transform[0]; + ctx.beginPath(); + ctx.moveTo( + x, + Math.min(...vertical.nodes.map((node) => node.top)) * transform[2] + transform[1] + ); + ctx.lineTo( + x, + Math.max(...vertical.nodes.map((node) => node.bottom)) * transform[2] + transform[1] + ); + ctx.stroke(); + + vertical.nodes.forEach((node) => { + drawCross(x, node.top * transform[2] + transform[1], 5 * zoom); + drawCross(x, node.bottom * transform[2] + transform[1], 5 * zoom); + }); + } + + if (horizontal) { + const y = horizontal.position * transform[2] + transform[1]; + ctx.beginPath(); + ctx.moveTo( + Math.min(...horizontal.nodes.map((node) => node.left)) * transform[2] + transform[0], + y + ); + ctx.lineTo( + Math.max(...horizontal.nodes.map((node) => node.right)) * transform[2] + transform[0], + y + ); + ctx.stroke(); + + horizontal.nodes.forEach((node) => { + drawCross(node.left * transform[2] + transform[0], y, 5 * zoom); + drawCross(node.right * transform[2] + transform[0], y, 5 * zoom); + }); + } + }, [width, height, transform, horizontal, vertical]); + + return ; +} + +export default HelperLinesRenderer; diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useUtils.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useUtils.tsx index f0d973c6f..dd843eb2d 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useUtils.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useUtils.tsx @@ -3,6 +3,14 @@ import { WorkflowContext } from '../../context'; import { useTranslation } from 'next-i18next'; import { useCallback } from 'react'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { Node, NodePositionChange, XYPosition } from 'reactflow'; +import { THelperLine } from '@fastgpt/global/core/workflow/type'; + +type GetHelperLinesResult = { + horizontal?: THelperLine; + vertical?: THelperLine; + snapPosition: Partial; +}; export const useWorkflowUtils = () => { const { t } = useTranslation(); @@ -32,8 +40,235 @@ export const useWorkflowUtils = () => { [nodeList] ); + const getHelperLines = ( + change: NodePositionChange, + nodes: Node[], + distance = 8 + ): GetHelperLinesResult => { + const nodeA = nodes.find((node) => node.id === change.id); + + if (!nodeA || !change.position) { + return { + horizontal: undefined, + vertical: undefined, + snapPosition: { x: undefined, y: undefined } + }; + } + + const nodeABounds = { + left: change.position.x, + right: change.position.x + (nodeA.width ?? 0), + top: change.position.y, + bottom: change.position.y + (nodeA.height ?? 0), + width: nodeA.width ?? 0, + height: nodeA.height ?? 0, + centerX: change.position.x + (nodeA.width ?? 0) / 2, + centerY: change.position.y + (nodeA.height ?? 0) / 2 + }; + + let horizontalDistance = distance; + let verticalDistance = distance; + + return nodes + .filter((node) => node.id !== nodeA.id) + .reduce( + (result, nodeB) => { + if (!result.vertical) { + result.vertical = { + position: nodeABounds.centerX, + nodes: [] + }; + } + + if (!result.horizontal) { + result.horizontal = { + position: nodeABounds.centerY, + nodes: [] + }; + } + + const nodeBBounds = { + left: nodeB.position.x, + right: nodeB.position.x + (nodeB.width ?? 0), + top: nodeB.position.y, + bottom: nodeB.position.y + (nodeB.height ?? 0), + width: nodeB.width ?? 0, + height: nodeB.height ?? 0, + centerX: nodeB.position.x + (nodeB.width ?? 0) / 2, + centerY: nodeB.position.y + (nodeB.height ?? 0) / 2 + }; + + const distanceLeftLeft = Math.abs(nodeABounds.left - nodeBBounds.left); + const distanceRightRight = Math.abs(nodeABounds.right - nodeBBounds.right); + const distanceLeftRight = Math.abs(nodeABounds.left - nodeBBounds.right); + const distanceRightLeft = Math.abs(nodeABounds.right - nodeBBounds.left); + const distanceTopTop = Math.abs(nodeABounds.top - nodeBBounds.top); + const distanceBottomTop = Math.abs(nodeABounds.bottom - nodeBBounds.top); + const distanceBottomBottom = Math.abs(nodeABounds.bottom - nodeBBounds.bottom); + const distanceTopBottom = Math.abs(nodeABounds.top - nodeBBounds.bottom); + const distanceCenterXCenterX = Math.abs(nodeABounds.centerX - nodeBBounds.centerX); + const distanceCenterYCenterY = Math.abs(nodeABounds.centerY - nodeBBounds.centerY); + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | A | + // |___________| + // | + // | + // |‾‾‾‾‾‾‾‾‾‾‾| + // | B | + // |___________| + if (distanceLeftLeft < verticalDistance) { + result.snapPosition.x = nodeBBounds.left; + result.vertical.position = nodeBBounds.left; + result.vertical.nodes = [nodeABounds, nodeBBounds]; + verticalDistance = distanceLeftLeft; + } else if (distanceLeftLeft === verticalDistance) { + result.vertical.nodes.push(nodeBBounds); + } + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | A | + // |___________| + // | + // | + // |‾‾‾‾‾‾‾‾‾‾‾| + // | B | + // |___________| + if (distanceRightRight < verticalDistance) { + result.snapPosition.x = nodeBBounds.right - nodeABounds.width; + result.vertical.position = nodeBBounds.right; + result.vertical.nodes = [nodeABounds, nodeBBounds]; + verticalDistance = distanceRightRight; + } else if (distanceRightRight === verticalDistance) { + result.vertical.nodes.push(nodeBBounds); + } + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | A | + // |___________| + // | + // | + // |‾‾‾‾‾‾‾‾‾‾‾| + // | B | + // |___________| + if (distanceLeftRight < verticalDistance) { + result.snapPosition.x = nodeBBounds.right; + result.vertical.position = nodeBBounds.right; + result.vertical.nodes = [nodeABounds, nodeBBounds]; + verticalDistance = distanceLeftRight; + } else if (distanceLeftRight === verticalDistance) { + result.vertical.nodes.push(nodeBBounds); + } + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | A | + // |___________| + // | + // | + // |‾‾‾‾‾‾‾‾‾‾‾| + // | B | + // |___________| + if (distanceRightLeft < verticalDistance) { + result.snapPosition.x = nodeBBounds.left - nodeABounds.width; + result.vertical.position = nodeBBounds.left; + result.vertical.nodes = [nodeABounds, nodeBBounds]; + verticalDistance = distanceRightLeft; + } else if (distanceRightLeft === verticalDistance) { + result.vertical.nodes.push(nodeBBounds); + } + + // |‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾| + // | A | | B | + // |___________| |___________| + if (distanceTopTop < horizontalDistance) { + result.snapPosition.y = nodeBBounds.top; + result.horizontal.position = nodeBBounds.top; + result.horizontal.nodes = [nodeABounds, nodeBBounds]; + horizontalDistance = distanceTopTop; + } else if (distanceTopTop === horizontalDistance) { + result.horizontal.nodes.push(nodeBBounds); + } + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | A | + // |___________|_________________ + // | | + // | B | + // |___________| + if (distanceBottomTop < horizontalDistance) { + result.snapPosition.y = nodeBBounds.top - nodeABounds.height; + result.horizontal.position = nodeBBounds.top; + result.horizontal.nodes = [nodeABounds, nodeBBounds]; + horizontalDistance = distanceBottomTop; + } else if (distanceBottomTop === horizontalDistance) { + result.horizontal.nodes.push(nodeBBounds); + } + + // |‾‾‾‾‾‾‾‾‾‾‾| |‾‾‾‾‾‾‾‾‾‾‾| + // | A | | B | + // |___________|_____|___________| + if (distanceBottomBottom < horizontalDistance) { + result.snapPosition.y = nodeBBounds.bottom - nodeABounds.height; + result.horizontal.position = nodeBBounds.bottom; + result.horizontal.nodes = [nodeABounds, nodeBBounds]; + horizontalDistance = distanceBottomBottom; + } else if (distanceBottomBottom === horizontalDistance) { + result.horizontal.nodes.push(nodeBBounds); + } + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | B | + // | | + // |‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ + // | A | + // |___________| + if (distanceTopBottom < horizontalDistance) { + result.snapPosition.y = nodeBBounds.bottom; + result.horizontal.position = nodeBBounds.bottom; + result.horizontal.nodes = [nodeABounds, nodeBBounds]; + horizontalDistance = distanceTopBottom; + } else if (distanceTopBottom === horizontalDistance) { + result.horizontal.nodes.push(nodeBBounds); + } + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | A | + // |___________| + // | + // | + // |‾‾‾‾‾‾‾‾‾‾‾| + // | B | + // |___________| + if (distanceCenterXCenterX < verticalDistance) { + result.snapPosition.x = nodeBBounds.centerX - nodeABounds.width / 2; + result.vertical.position = nodeBBounds.centerX; + result.vertical.nodes = [nodeABounds, nodeBBounds]; + verticalDistance = distanceCenterXCenterX; + } else if (distanceCenterXCenterX === verticalDistance) { + result.vertical.nodes.push(nodeBBounds); + } + + // |‾‾‾‾‾‾‾‾‾‾‾| |‾‾‾‾‾‾‾‾‾‾‾| + // | A |----| B | + // |___________| |___________| + if (distanceCenterYCenterY < horizontalDistance) { + result.snapPosition.y = nodeBBounds.centerY - nodeABounds.height / 2; + result.horizontal.position = nodeBBounds.centerY; + result.horizontal.nodes = [nodeABounds, nodeBBounds]; + horizontalDistance = distanceCenterYCenterY; + } else if (distanceCenterYCenterY === horizontalDistance) { + result.horizontal.nodes.push(nodeBBounds); + } + + return result; + }, + { snapPosition: { x: undefined, y: undefined } } as GetHelperLinesResult + ); + }; + return { - computedNewNodeName + computedNewNodeName, + getHelperLines }; }; diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useWorkflow.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useWorkflow.tsx index 3b9fc7786..f57c56eae 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useWorkflow.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useWorkflow.tsx @@ -1,5 +1,14 @@ import React, { useCallback, useMemo } from 'react'; -import { Connection, NodeChange, OnConnectStartParams, addEdge, EdgeChange, Edge } from 'reactflow'; +import { + Connection, + NodeChange, + OnConnectStartParams, + addEdge, + EdgeChange, + Edge, + applyNodeChanges, + Node +} from 'reactflow'; import { EDGE_TYPE } from '@fastgpt/global/core/workflow/node/constant'; import 'reactflow/dist/style.css'; import { useToast } from '@fastgpt/web/hooks/useToast'; @@ -8,6 +17,7 @@ import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; import { useKeyboard } from './useKeyboard'; import { useContextSelector } from 'use-context-selector'; import { WorkflowContext } from '../../context'; +import { useWorkflowUtils } from './useUtils'; export const useWorkflow = () => { const { toast } = useToast(); @@ -18,12 +28,47 @@ export const useWorkflow = () => { }); const { isDowningCtrl } = useKeyboard(); - const { setConnectingEdge, nodes, onNodesChange, setEdges, onEdgesChange, setHoverEdgeId } = - useContextSelector(WorkflowContext, (v) => v); + const { + setConnectingEdge, + nodes, + setNodes, + onNodesChange, + setEdges, + onEdgesChange, + setHoverEdgeId, + setHelperLineHorizontal, + setHelperLineVertical + } = useContextSelector(WorkflowContext, (v) => v); + + const { getHelperLines } = useWorkflowUtils(); + + const customApplyNodeChanges = useCallback((changes: NodeChange[], nodes: Node[]): Node[] => { + setHelperLineHorizontal(undefined); + setHelperLineVertical(undefined); + + if ( + changes.length === 1 && + changes[0].type === 'position' && + changes[0].dragging && + changes[0].position + ) { + const helperLines = getHelperLines(changes[0], nodes); + + changes[0].position.x = helperLines.snapPosition.x ?? changes[0].position.x; + changes[0].position.y = helperLines.snapPosition.y ?? changes[0].position.y; + + setHelperLineHorizontal(helperLines.horizontal); + setHelperLineVertical(helperLines.vertical); + } + + return applyNodeChanges(changes, nodes); + }, []); /* node */ const handleNodesChange = useCallback( (changes: NodeChange[]) => { + setNodes((nodes) => customApplyNodeChanges(changes, nodes)); + for (const change of changes) { if (change.type === 'remove') { const node = nodes.find((n) => n.id === change.id); diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/index.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/index.tsx index d52d9f42d..a87f3424d 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/index.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/index.tsx @@ -26,6 +26,7 @@ import { useContextSelector } from 'use-context-selector'; import { WorkflowContext } from '../context'; import { useWorkflow } from './hooks/useWorkflow'; import { t } from 'i18next'; +import HelperLines from './components/HelperLines'; const NodeSimple = dynamic(() => import('./nodes/NodeSimple')); const nodeTypes: Record = { @@ -62,7 +63,8 @@ const edgeTypes = { }; const Workflow = () => { - const { nodes, edges, reactFlowWrapper } = useContextSelector(WorkflowContext, (v) => v); + const { nodes, edges, reactFlowWrapper, helperLineHorizontal, helperLineVertical } = + useContextSelector(WorkflowContext, (v) => v); const { ConfirmDeleteModal, @@ -135,6 +137,7 @@ const Workflow = () => { onEdgeMouseLeave={onEdgeMouseLeave} > + diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/context.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/context.tsx index 0f23a7cb7..914dba7fd 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/context.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/context.tsx @@ -48,6 +48,7 @@ import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; import { formatTime2HM, formatTime2YMDHMW } from '@fastgpt/global/common/string/time'; import type { InitProps } from '@/pages/app/detail/components/PublishHistoriesSlider'; import { cloneDeep } from 'lodash'; +import { THelperLine } from '@fastgpt/global/core/workflow/type'; type OnChange = (changes: ChangesType[]) => void; @@ -135,6 +136,12 @@ type WorkflowContextType = { historiesDefaultData?: InitProps; setHistoriesDefaultData: React.Dispatch>; + // helper line + helperLineHorizontal?: THelperLine; + setHelperLineHorizontal: React.Dispatch>; + helperLineVertical?: THelperLine; + setHelperLineVertical: React.Dispatch>; + // chat test setWorkflowTestData: React.Dispatch< React.SetStateAction< @@ -260,6 +267,14 @@ export const WorkflowContext = createContext({ setHistoriesDefaultData: function (value: React.SetStateAction): void { throw new Error('Function not implemented.'); }, + helperLineHorizontal: undefined, + setHelperLineHorizontal: function (value: React.SetStateAction): void { + throw new Error('Function not implemented.'); + }, + helperLineVertical: undefined, + setHelperLineVertical: function (value: React.SetStateAction): void { + throw new Error('Function not implemented.'); + }, getNodeDynamicInputs: function (nodeId: string): FlowNodeInputItemType[] { throw new Error('Function not implemented.'); } @@ -728,6 +743,11 @@ const WorkflowContextProvider = ({ /* Version histories */ const [historiesDefaultData, setHistoriesDefaultData] = useState(); + /* helper line */ + const [helperLineHorizontal, setHelperLineHorizontal] = useState( + undefined + ); + const [helperLineVertical, setHelperLineVertical] = useState(undefined); /* event bus */ useEffect(() => { eventBus.on(EventNameEnum.requestWorkflowStore, () => { @@ -797,6 +817,12 @@ const WorkflowContextProvider = ({ historiesDefaultData, setHistoriesDefaultData, + // helper line + helperLineHorizontal, + setHelperLineHorizontal, + helperLineVertical, + setHelperLineVertical, + // chat test setWorkflowTestData };