ReactFlow:https://reactflow.dev/docs/examples/overview/
效果图:
目录结构:
最外层ReactFlowProvider包裹,内部组件可共享reactFlowInstance
FlowEditor/index.tsx
import { ReactFlowProvider } from 'reactflow'
import 'reactflow/dist/style.css'
import styles from './index.module.scss'
import EditorPanel from './EditorPanel'
import PropsPanel from './PropsPanel'
import ButtonsPanel from './ButtonsPanel'
import { flowData } from '@/constants/nodes-edges'
const FlowEditor: React.FC = () => {
return (
<div className={styles.ReactFlowProvider}>
<ReactFlowProvider>
<EditorPanel flowData={flowData} />
<ButtonsPanel />
<PropsPanel />
</ReactFlowProvider>
</div>
)
}
export default FlowEditor
左侧 可拖拽节点区
FlowEditor/NodesPanel/index.tsx
import React from 'react'
import styles from '../index.module.scss'
const NodesPanel: React.FC = () => {
const onDragStart = (event: React.DragEvent<HTMLDivElement>, nodeType: string) => {
event.dataTransfer.setData('application/reactflow', nodeType)
event.dataTransfer.effectAllowed = 'move'
}
return (
<aside className={styles.NodesPanel}>
<div className={styles.title}>组件库</div>
<div className={styles.dndnode} onDragStart={(event) => onDragStart(event, 'default')} draggable>
节点
</div>
</aside>
)
}
export default NodesPanel
右侧 节点属性设置区
FlowEditor/PropsPanel/index.tsx
import React, { useEffect, useState } from 'react'
import { Form, Select } from 'antd'
import { Node, useReactFlow, useOnSelectionChange } from 'reactflow'
import styles from '../index.module.scss'
interface NObject {
[key: string]: any
}
export const NODE_TYPES_ENM = {
1: { value: '1', label: '类型1' },
2: { value: '2', label: '类型2' },
} as NObject
const NODE_TYPES = [
{ value: '1', label: '类型1' },
{ value: '2', label: '类型2' },
]
const PropsPanel: React.FC = () => {
const [form] = Form.useForm()
const reactFlowInstance = useReactFlow() // reactFlow实例
const [selectedNode, setSelectedNode] = useState<Node | null>(null) // 当前选中的节点
// 设置 当前选中的节点
useOnSelectionChange({
onChange: ({ nodes, edges }) => {
if (nodes.length == 0) {
setSelectedNode(null)
} else {
setSelectedNode(nodes[0])
}
},
})
useEffect(() => {
form.resetFields()
form.setFieldsValue(selectedNode?.data)
}, [selectedNode?.data])
// 属性设置区 - 节点类型切换,同步 操作区 节点名称变化
const handleValuesChange = (changedValues: any, allValues: any) => {
const { componentCode } = allValues
const type = NODE_TYPES_ENM[componentCode]
reactFlowInstance.setNodes((nds: Node[]) =>
nds.map((node: Node) => {
if (node.id === selectedNode?.id) {
// it's important that you create a new object here
// in order to notify react flow about the change
node.data = {
...node.data,
...allValues,
label: type?.label,
}
}
return node
}),
)
}
return (
<>
{selectedNode && (
<div className={styles.PropsPanel}>
<div className={styles.title}>属性设置</div>
<Form
form={form}
name="basic"
layout="vertical"
initialValues={{ canSkip: 2 }}
onValuesChange={handleValuesChange}
autoComplete="off"
>
<Form.Item label="节点类型" name="componentCode" rules={[{ required: true }]}>
<Select placeholder="请选择" allowClear options={NODE_TYPES}></Select>
</Form.Item>
</Form>
</div>
)}
</>
)
}
export default PropsPanel
顶部 操作按钮区
FlowEditor/ButtonsPanel/index.tsx
import React, { useCallback } from 'react'
import { Button, Space } from 'antd'
import { useReactFlow } from 'reactflow'
import { getLayoutedElements } from '@/utils/flow'
import styles from '../index.module.scss'
const ButtonsPanel: React.FC = () => {
const reactFlowInstance = useReactFlow()
// 清空
const onClear = useCallback(() => {
reactFlowInstance.setNodes([])
reactFlowInstance.setEdges([])
}, [reactFlowInstance])
// 一键格式化
const onLayout = useCallback(() => {
const { nodes, edges } = reactFlowInstance.toObject() // 从实例上获取节点和边
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(nodes, edges)
reactFlowInstance.setNodes(layoutedNodes)
reactFlowInstance.setEdges(layoutedEdges)
}, [reactFlowInstance])
// 保存数据
const onSave = useCallback(() => {
const { edges, nodes } = reactFlowInstance.toObject() // 从实例上获取节点和边
// 这里提交数据
}, [reactFlowInstance])
return (
<div className={styles.ButtonsPanel}>
<div className={styles.title}>模版名称</div>
<Space>
<Button onClick={onClear}>重置</Button>
<Button onClick={onLayout}>整理格式</Button>
<Button type="primary" onClick={onSave}>保存</Button>
</Space>
</div>
)
}
export default ButtonsPanel
中间 流程编辑操作区
FlowEditor/EditorPanel/index.tsx
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import ReactFlow, {
useNodesState,
useEdgesState,
addEdge,
useReactFlow,
Background,
MiniMap,
Controls,
MarkerType,
Node,
Edge,
Connection,
OnConnectStartParams,
ReactFlowInstance,
} from 'reactflow'
import 'reactflow/dist/style.css'
import styles from '../index.module.scss'
import { getLayoutedElements } from '@/utils/flow'
import CustomNode from './CustomNode'
import CustomEdge from './CustomEdge'
const edgeTypes = { CustomEdge: CustomEdge } // 注册自定义边(放组件里会有warning)
export type FlowData = {
initialNodes: Node[]
initialEdges: Edge[]
}
type IProps = {
flowData: FlowData
}
let id = 10
const getId = () => `${id++}` + ''
const EditorPanel: React.FC<IProps> = ({ flowData }) => {
const reactFlowWrapper = useRef<HTMLDivElement>(null)
const connectingNodeId = useRef<string | null>(null)
const [rfInstance, setRfInstance] = useState<ReactFlowInstance>({} as ReactFlowInstance)
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const { project } = useReactFlow()
const nodeTypes = useMemo(() => ({ CustomNode: CustomNode }), []) // 注册自定义节点 (useMemo只能放组件里)
const { initialNodes, initialEdges } = flowData
useEffect(() => {
if (!rfInstance || !initialNodes || !initialEdges) return
setLayout(initialNodes, initialEdges) // 布局并展示
}, [rfInstance, flowData])
const onInit = (reactFlowInstance: ReactFlowInstance) => {
setRfInstance(reactFlowInstance)
}
const onConnect = useCallback((params: Connection) => {
setEdges((eds) =>
addEdge(
{
...params,
// animated: true,
// type: 'CustomEdge',
markerEnd: {
type: MarkerType.ArrowClosed,
},
},
eds,
),
)
}, [])
// 添加节点 1 - 从某节点的连接桩处拖动
const onConnectStart = useCallback(
(_: React.MouseEvent | React.TouchEvent, { nodeId }: OnConnectStartParams) => {
connectingNodeId.current = nodeId
},
[],
)
// 添加节点 1 - 从某节点的连接桩处拖动
const onConnectEnd = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const target = event.target as HTMLDivElement
const targetIsPane = target.classList.contains('react-flow__pane')
if (targetIsPane) {
addNodeOnDrop(event)
connectingNodeId.current = null
}
},
[project],
)
// 添加节点 2 - 从组件库拖放
const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}, [])
// 添加节点 2 - 从组件库拖放
const onDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault()
const type = event.dataTransfer.getData('application/reactflow')
// check if the dropped element is valid
if (typeof type === 'undefined' || !type) {
return
}
addNodeOnDrop(event, type)
},
[project],
)
// 添加节点:1、从组件库拖放;2、从某节点的连接桩处拖动
const addNodeOnDrop = (
event: React.DragEvent<HTMLDivElement> | React.MouseEvent<HTMLDivElement, MouseEvent>,
type?: string,
) => {
if (!reactFlowWrapper?.current) return
const reactFlowBounds = reactFlowWrapper?.current.getBoundingClientRect()
const position = project({
x: event.clientX - reactFlowBounds.left,
y: event.clientY - reactFlowBounds.top - 20,
})
const id = getId()
const newNode = {
id,
type: 'CustomNode',
position,
data: { label: '' },
sourcePosition: 'right',
targetPosition: 'left',
} as Node
setNodes((nds) => nds.concat(newNode))
if (!connectingNodeId?.current) return
const newEdge = {
id: `e${connectingNodeId.current}${id}`,
source: connectingNodeId.current,
target: id,
// type: 'CustomEdge',
markerEnd: {
type: MarkerType.ArrowClosed,
},
} as Edge
setEdges((eds) => eds.concat(newEdge))
}
// 设置布局
const setLayout = (nodes: Node[], edges: Edge[]) => {
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(nodes, edges)
setNodes(layoutedNodes)
setEdges(layoutedEdges)
// 不使用fitView时,可自己计算外接矩形宽高,设置高度
// const rect = getRectOfNodes(layoutedNodes)
// console.log('rect', rect)
// setHeight(rect.height + 30) // 额外加的 30 是一个节点的高度,不知为何rect的height缺少了这个高度
}
return (
<div className={styles.reactflowWrapper} ref={reactFlowWrapper}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onInit={onInit}
onDrop={onDrop}
onDragOver={onDragOver}
onConnect={onConnect}
onConnectStart={onConnectStart}
// @ts-ignore
onConnectEnd={onConnectEnd}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
// fitView
>
<MiniMap />
<Controls />
<Background />
</ReactFlow>
</div>
)
}
export default EditorPanel
自定义边
FlowEditor/EditorPanel/CustomEdge.tsx
#直角边
import React from 'react'
import { BaseEdge, EdgeProps } from 'reactflow'
const CustomEdge: React.ComponentType<EdgeProps<any>> = (props) => {
const { sourceX, sourceY, targetX, targetY, id, markerEnd } = props
const edgePath = `M ${sourceX} ${sourceY} L ${sourceX + 20} ${sourceY} L ${
sourceX + 20
} ${targetY} L ${targetX} ${targetY} `
return <BaseEdge path={edgePath} markerEnd={markerEnd} />
}
export default CustomEdge
# 圆弧边
import React from 'react'
import { BaseEdge, EdgeProps } from 'reactflow'
const Radius = 4
const CustomEdge: React.ComponentType<EdgeProps<any>> = (props) => {
const { sourceX, sourceY, targetX, targetY, id, markerEnd, style } = props
let edgePath = `M ${sourceX - 4} ${sourceY}
L ${sourceX + 20} ${sourceY}
L ${sourceX + 20} ${targetY}
L ${targetX + 4} ${targetY} `
if (sourceY < targetY) {
edgePath = `M ${sourceX - 4} ${sourceY}
L ${sourceX + 20 - Radius} ${sourceY}
A ${Radius} ${Radius} 0 0 1 ${sourceX + 20} ${sourceY + Radius}
L ${sourceX + 20} ${targetY - Radius}
A ${Radius} ${Radius} 0 0 0 ${sourceX + 20 + Radius} ${targetY}
L ${targetX + 4} ${targetY} `
}
if (sourceY > targetY) {
edgePath = `M ${sourceX - 4} ${sourceY}
L ${sourceX + 20 - Radius} ${sourceY}
A ${Radius} ${Radius} 0 0 0 ${sourceX + 20} ${sourceY - Radius}
L ${sourceX + 20} ${targetY + Radius}
A ${Radius} ${Radius} 0 0 1 ${sourceX + 20 + Radius} ${targetY}
L ${targetX + 4} ${targetY} `
}
return <BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
}
export default CustomEdge
自定义节点
FlowEditor/EditorPanel/CustomNode.tsx
import React from 'react'
import { Handle, Position, NodeProps } from 'reactflow'
import styles from './index.module.scss'
const CustomNode: React.ComponentType<NodeProps<any>> = ({ id, data, isConnectable, selected }) => {
let className = styles.customTemplateNode_empty
if (data.label) {
className = selected ? styles.customTemplateNode_selected : styles.customTemplateNode
}
return (
<div className={className}>
<Handle
className={styles.customHandle}
type="target"
position={Position.Left}
isConnectable={isConnectable}
/>
<div>{data.label || '请设置节点'}</div>
<Handle
className={styles.customHandle}
type="source"
position={Position.Right}
isConnectable={isConnectable}
/>
</div>
)
}
export default CustomNode
FlowEditor/EditorPanel/index.module.scss
.customTemplateNode {
width: 116px;
height: 40px;
color: #393c5a;
font-size: 12px;
line-height: 38px;
text-align: center;
background-color: #fff;
border: 1px solid #b1b4c5;
border-radius: 8px;
.customHandle {
background-color: rgb(0 0 0 / 0%);
border-color: rgb(0 0 0 / 0%);
}
&:hover {
color: #266bf6;
border: 1px solid #266bf6;
.customHandle {
background-color: #266bf6;
}
}
}
.customTemplateNode_empty {
width: 116px;
height: 40px;
color: #266bf6;
font-weight: 500;
font-size: 12px;
line-height: 36px;
text-align: center;
background-color: #f2f6fe;
border: 2px dashed #266bf6;
border-radius: 8px;
.customHandle {
background-color: #266bf6;
}
}
.customTemplateNode_selected {
width: 116px;
height: 40px;
color: #266bf6;
font-weight: 500;
font-size: 12px;
line-height: 36px;
text-align: center;
background-color: #f2f6fe;
border: 2px solid #266bf6;
border-radius: 8px;
.customHandle {
background-color: #266bf6;
}
}
.customHandle {
width: 8px;
height: 8px;
}
.canSkip {
position: absolute;
top: -10px;
right: -10px;
padding: 0 6px;
color: #33ba99;
font-size: 12px;
line-height: 20px;
background: #edf9f6;
border: 1px solid #9dc;
border-radius: 4px;
}
布局算法,使用dagre算法库https://github.com/dagrejs/dagre/wiki
@/utils/flow.tsx
import { Node, Edge, Position, MarkerType, ConnectionLineType } from 'reactflow'
import dagre from 'dagre'
const MIN_DISTANCE = 150
const NODE_WIDTH = 116
const NODE_HEIGHT = 40
const NODESEP = 40
const direction = 'LR'
export const getLayoutedElements = (
nodes: Node[],
edges: Edge[],
nodeWidth?: number,
nodeHeight?: number,
nodesep?: number,
) => {
// console.log(nodeHeight)
if (!nodes || !edges) return { nodes, edges }
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setGraph({})
dagreGraph.setDefaultEdgeLabel(() => ({}))
const isHorizontal = true
dagreGraph.setGraph({ rankdir: direction, nodesep: nodesep || NODESEP, align: 'UL' })
nodes.forEach((node) => {
dagreGraph.setNode(node.id, {
width: nodeWidth || NODE_WIDTH,
height: nodeHeight || NODE_HEIGHT,
})
})
edges.forEach((edge) => {
edge.type = 'CustomEdge' || ConnectionLineType.SmoothStep
edge.animated = false
edge.markerEnd = {
type: MarkerType.ArrowClosed,
}
dagreGraph.setEdge(edge.source, edge.target)
})
dagre.layout(dagreGraph)
topAlign(dagreGraph)
nodes.forEach((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
node.targetPosition = isHorizontal
? ('left' as Position | undefined)
: ('top' as Position | undefined)
node.sourcePosition = isHorizontal
? ('right' as Position | undefined)
: ('bottom' as Position | undefined)
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
node.position = {
x: nodeWithPosition.x - (nodeWidth || NODE_WIDTH) / 2,
y: nodeWithPosition.y - (nodeHeight || NODE_HEIGHT) / 2,
}
return node
})
return { nodes, edges }
}
// 设置节点上对齐
const topAlign = (dagreGraph: any) => {
const sourceId = dagreGraph.sources()[0]
const process = (nodeId: string) => {
const currentY = dagreGraph.node(nodeId).y
// 直接子节点
const successors = dagreGraph.successors(nodeId)
if (successors && successors?.length > 0) {
const firstY = dagreGraph.node(successors[0]).y
const minY = successors.reduce((result: any, childId: string) => {
return Math.min(result, dagreGraph.node(childId).y)
}, firstY)
if (currentY < minY) {
// 每个都向上移动,顶部跟父节点对齐
const shift = minY - currentY
successors.forEach((childId: string) => {
const position = dagreGraph.node(childId)
dagreGraph.setNode(childId, {
...position,
y: position.y - shift,
})
})
}
successors.forEach((childId: string) => process(childId))
}
}
process(sourceId)
}