feat: add workflow helperline (#2356)

* feat: add workflow helperline

* del console

* across zoom & adjust distance
This commit is contained in:
heheer
2024-08-13 21:42:17 +08:00
committed by GitHub
parent fe9cb437e5
commit 7417de74da
6 changed files with 425 additions and 5 deletions

View File

@@ -80,3 +80,17 @@ export type SystemPluginTemplateItemType = WorkflowTemplateType & {
workflow: WorkflowTemplateBasicType; workflow: WorkflowTemplateBasicType;
}; };
export type THelperLine = {
position: number;
nodes: {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
centerX: number;
centerY: number;
}[];
};

View File

@@ -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<HTMLCanvasElement>(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 <canvas ref={canvasRef} style={canvasStyle} />;
}
export default HelperLinesRenderer;

View File

@@ -3,6 +3,14 @@ import { WorkflowContext } from '../../context';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; 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<XYPosition>;
};
export const useWorkflowUtils = () => { export const useWorkflowUtils = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,8 +40,235 @@ export const useWorkflowUtils = () => {
[nodeList] [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<GetHelperLinesResult>(
(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 { return {
computedNewNodeName computedNewNodeName,
getHelperLines
}; };
}; };

View File

@@ -1,5 +1,14 @@
import React, { useCallback, useMemo } from 'react'; 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 { EDGE_TYPE } from '@fastgpt/global/core/workflow/node/constant';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
@@ -8,6 +17,7 @@ import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import { useKeyboard } from './useKeyboard'; import { useKeyboard } from './useKeyboard';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../context'; import { WorkflowContext } from '../../context';
import { useWorkflowUtils } from './useUtils';
export const useWorkflow = () => { export const useWorkflow = () => {
const { toast } = useToast(); const { toast } = useToast();
@@ -18,12 +28,47 @@ export const useWorkflow = () => {
}); });
const { isDowningCtrl } = useKeyboard(); const { isDowningCtrl } = useKeyboard();
const { setConnectingEdge, nodes, onNodesChange, setEdges, onEdgesChange, setHoverEdgeId } = const {
useContextSelector(WorkflowContext, (v) => v); 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 */ /* node */
const handleNodesChange = useCallback( const handleNodesChange = useCallback(
(changes: NodeChange[]) => { (changes: NodeChange[]) => {
setNodes((nodes) => customApplyNodeChanges(changes, nodes));
for (const change of changes) { for (const change of changes) {
if (change.type === 'remove') { if (change.type === 'remove') {
const node = nodes.find((n) => n.id === change.id); const node = nodes.find((n) => n.id === change.id);

View File

@@ -26,6 +26,7 @@ import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../context'; import { WorkflowContext } from '../context';
import { useWorkflow } from './hooks/useWorkflow'; import { useWorkflow } from './hooks/useWorkflow';
import { t } from 'i18next'; import { t } from 'i18next';
import HelperLines from './components/HelperLines';
const NodeSimple = dynamic(() => import('./nodes/NodeSimple')); const NodeSimple = dynamic(() => import('./nodes/NodeSimple'));
const nodeTypes: Record<FlowNodeTypeEnum, any> = { const nodeTypes: Record<FlowNodeTypeEnum, any> = {
@@ -62,7 +63,8 @@ const edgeTypes = {
}; };
const Workflow = () => { const Workflow = () => {
const { nodes, edges, reactFlowWrapper } = useContextSelector(WorkflowContext, (v) => v); const { nodes, edges, reactFlowWrapper, helperLineHorizontal, helperLineVertical } =
useContextSelector(WorkflowContext, (v) => v);
const { const {
ConfirmDeleteModal, ConfirmDeleteModal,
@@ -135,6 +137,7 @@ const Workflow = () => {
onEdgeMouseLeave={onEdgeMouseLeave} onEdgeMouseLeave={onEdgeMouseLeave}
> >
<FlowController /> <FlowController />
<HelperLines horizontal={helperLineHorizontal} vertical={helperLineVertical} />
</ReactFlow> </ReactFlow>
</Box> </Box>

View File

@@ -48,6 +48,7 @@ import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { formatTime2HM, formatTime2YMDHMW } from '@fastgpt/global/common/string/time'; import { formatTime2HM, formatTime2YMDHMW } from '@fastgpt/global/common/string/time';
import type { InitProps } from '@/pages/app/detail/components/PublishHistoriesSlider'; import type { InitProps } from '@/pages/app/detail/components/PublishHistoriesSlider';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { THelperLine } from '@fastgpt/global/core/workflow/type';
type OnChange<ChangesType> = (changes: ChangesType[]) => void; type OnChange<ChangesType> = (changes: ChangesType[]) => void;
@@ -135,6 +136,12 @@ type WorkflowContextType = {
historiesDefaultData?: InitProps; historiesDefaultData?: InitProps;
setHistoriesDefaultData: React.Dispatch<React.SetStateAction<undefined | InitProps>>; setHistoriesDefaultData: React.Dispatch<React.SetStateAction<undefined | InitProps>>;
// helper line
helperLineHorizontal?: THelperLine;
setHelperLineHorizontal: React.Dispatch<React.SetStateAction<THelperLine | undefined>>;
helperLineVertical?: THelperLine;
setHelperLineVertical: React.Dispatch<React.SetStateAction<THelperLine | undefined>>;
// chat test // chat test
setWorkflowTestData: React.Dispatch< setWorkflowTestData: React.Dispatch<
React.SetStateAction< React.SetStateAction<
@@ -260,6 +267,14 @@ export const WorkflowContext = createContext<WorkflowContextType>({
setHistoriesDefaultData: function (value: React.SetStateAction<InitProps | undefined>): void { setHistoriesDefaultData: function (value: React.SetStateAction<InitProps | undefined>): void {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
helperLineHorizontal: undefined,
setHelperLineHorizontal: function (value: React.SetStateAction<THelperLine | undefined>): void {
throw new Error('Function not implemented.');
},
helperLineVertical: undefined,
setHelperLineVertical: function (value: React.SetStateAction<THelperLine | undefined>): void {
throw new Error('Function not implemented.');
},
getNodeDynamicInputs: function (nodeId: string): FlowNodeInputItemType[] { getNodeDynamicInputs: function (nodeId: string): FlowNodeInputItemType[] {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
} }
@@ -728,6 +743,11 @@ const WorkflowContextProvider = ({
/* Version histories */ /* Version histories */
const [historiesDefaultData, setHistoriesDefaultData] = useState<InitProps>(); const [historiesDefaultData, setHistoriesDefaultData] = useState<InitProps>();
/* helper line */
const [helperLineHorizontal, setHelperLineHorizontal] = useState<THelperLine | undefined>(
undefined
);
const [helperLineVertical, setHelperLineVertical] = useState<THelperLine | undefined>(undefined);
/* event bus */ /* event bus */
useEffect(() => { useEffect(() => {
eventBus.on(EventNameEnum.requestWorkflowStore, () => { eventBus.on(EventNameEnum.requestWorkflowStore, () => {
@@ -797,6 +817,12 @@ const WorkflowContextProvider = ({
historiesDefaultData, historiesDefaultData,
setHistoriesDefaultData, setHistoriesDefaultData,
// helper line
helperLineHorizontal,
setHelperLineHorizontal,
helperLineVertical,
setHelperLineVertical,
// chat test // chat test
setWorkflowTestData setWorkflowTestData
}; };