ReactFlow使用案例

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)
}

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容