React Flow

  • React Flow 一个可定制的 React 组件,用于构建基于节点的编辑器和交互式图表

  • 官网提供了大量示例,以及在线演示建议看完每个示例,很有用!!!

  • 其他参考:Vue Flow

代码地址

在线演示

自动布局示例

  • 参考官网示例:Dagre Tree

  • 使用 Dagre 时注意事项

    • Dagre 布局算法需要指定节点的,否则会报错

    • @dagrejs/dagre v1.1.4 在老版浏览器 (eg: chromev89.0.4389.90) 会报 Object.hasOwn is not a function 错误,降级为 @dagrejs/dagre v1.1.3 即可解决

  • 官网示例下面有公开的代码,此处不再贴出代码

复制粘贴示例

  • 参考官网示例:Copy and Paste

  • 可以仿照例子,当节点被选中时,再出现粘贴功能。核心代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    import {
    Background,
    BackgroundVariant,
    Edge,
    Node,
    OnSelectionChangeParams,
    Panel,
    ReactFlow,
    ReactFlowProvider,
    useReactFlow,
    } from '@xyflow/react';
    import { cloneDeep } from 'lodash';
    import { useCallback, useState } from 'react';
    import { v4 as uuidv4 } from 'uuid';

    import '@xyflow/react/dist/style.css'; // 引入样式
    import './index.css';

    /** 粘贴时偏移量 */
    const OFFSET = 50;

    /** 默认节点 */
    const defaultNodes = [
    {
    id: '1',
    data: { label: 'Node 1' },
    position: { x: 250, y: 5 },
    },
    {
    id: '2',
    data: { label: 'Node 2' },
    position: { x: 100, y: 100 },
    },
    ];

    /** 默认连接线 */
    const defaultEdges = [
    {
    id: '1-2',
    source: '1',
    target: '2',
    },
    ];

    /** 自定义复制粘贴组件 */
    const CustomCopyPaste = () => {
    // 监听 'Control+v', 'Meta+v' 不能连续粘贴
    // const keyPress = useKeyPress(['v']); // 可以监听按键变化, 不过比较难判断

    const { setNodes, setEdges, getNodes } = useReactFlow();

    const [selected, setSelected] = useState<OnSelectionChangeParams>();

    /** 节点/连接线选择事件 */
    const handleSelectionChange = useCallback(
    (nodeEdgeObj: OnSelectionChangeParams) => {
    setSelected?.(nodeEdgeObj);
    },
    []
    );

    /** 粘贴事件 */
    const handlePaste = useCallback(() => {
    if (selected?.nodes.length === 0) return;

    const selectedNodes = selected?.nodes || [];
    const selectedEdges = selected?.edges || [];

    // 获取当前选中的节点(防止复制节点之后, 节点移动, 坐标未更新)
    const selectedNodeIds = new Set(selectedNodes?.map((_) => _.id) || []);
    const allNodes = getNodes() || [];
    const newSelectedNodes = allNodes.filter((_) =>
    selectedNodeIds.has(_.id)
    );

    // 创建旧节点ID到新节点ID的映射
    const nodeIdMap = selectedNodes?.reduce((acc, node) => {
    acc[node.id] = uuidv4();
    return acc;
    }, {} as Record<string, string>);

    // 生成新节点
    const newNodes = cloneDeep(newSelectedNodes)?.map((node) => ({
    ...node,
    id: nodeIdMap?.[node.id],
    position: {
    x: node.position.x + OFFSET,
    y: node.position.y + OFFSET,
    },
    selected: true,
    })) as Node[];

    // 生成新边
    const newEdges = cloneDeep(selectedEdges)?.map((edge) => {
    const { source, target } = edge;
    const newSource = nodeIdMap?.[source];
    const newTarget = nodeIdMap?.[target];
    const id = [newSource, newTarget].filter(Boolean).join('-');

    return {
    ...edge,
    id,
    source: newSource || source,
    target: newTarget || target,
    selected: true,
    };
    }) as Edge[];

    // 更新状态(单次批量更新)
    setNodes((prevNodes) => [
    ...prevNodes.map((node) =>
    newSelectedNodes?.some((n) => n.id === node.id)
    ? { ...node, selected: false }
    : node
    ),
    ...newNodes,
    ]);

    setEdges((prevEdges) => [
    ...prevEdges.map((edge) =>
    selectedEdges?.some((e) => e.id === edge.id)
    ? { ...edge, selected: false }
    : edge
    ),
    ...newEdges,
    ]);
    }, [selected?.nodes, selected?.edges, getNodes, setNodes, setEdges]);

    /** 按键抬起事件(选中节点才会触发) */
    const handleKeyUp = useCallback(
    (event: React.KeyboardEvent) => {
    if (event.ctrlKey && event.key === 'v') {
    handlePaste();
    }
    },
    [handlePaste]
    );

    return (
    <ReactFlow
    fitView
    defaultNodes={defaultNodes}
    defaultEdges={defaultEdges}
    onSelectionChange={handleSelectionChange}
    onKeyUp={handleKeyUp}
    // selectionKeyCode={'Shift'} // 选中一片
    // multiSelectionKeyCode={['Control', 'Meta']} // 多选
    // deleteKeyCode={'Backspace'} // 删除选中节点
    proOptions={{ hideAttribution: true }}>
    {/* 背景 */}
    <Background variant={BackgroundVariant.Dots} gap={12} size={1} />

    {/* 面板 */}
    <Panel position="top-left">
    <div className="custom-copy-paste">
    <div>Ctrl/Command + V 可以粘贴</div>
    <div>长按Ctrl + 选中 可以多选</div>
    <div>长按Shif + 拖拽 可以多选</div>
    <div
    className={`btn ${
    selected?.nodes.length === 0 ? 'btn-disabled' : ''
    }`}
    onClick={handlePaste}>
    粘贴
    </div>
    </div>
    </Panel>
    </ReactFlow>
    );
    };

    /** Provider */
    const CustomCopyPasteProvider = () => {
    return (
    <ReactFlowProvider>
    <CustomCopyPaste />
    </ReactFlowProvider>
    );
    };

    export default CustomCopyPasteProvider;

自定义连接线里流星拖尾

  • 参考官网示例:Animating Edges

  • 实现思路:使用多个 <circle> 元素,通过延迟控制实现流星动画。这样做的好处是不用处理元素的方向,例如 <rect> 在拐弯时方向不会改变问题

  • 官方提供了四种获取路径的方法

    • getBezierPath:通过贝塞尔曲线计算路径

    • getSimpleBezierPath:通过简单贝塞尔曲线计算路径

    • getSmoothStepPath:通过平滑曲线计算路径,如果 borderRadius 参数为 0,则为 Step 效果

    • getStraightPath: 通过直线计算路径

  • 核心代码如下:

    • 配置 customEdgeAnimation.tsx 文件如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      142
      143
      144
      145
      146
      147
      148
      149
      150
      151
      152
      153
      154
      155
      156
      157
      158
      159
      160
      161
      162
      163
      164
      165
      166
      167
      168
      169
      170
      171
      172
      173
      174
      175
      176
      177
      178
      179
      180
      181
      182
      183
      184
      185
      186
      187
      188
      189
      190
      191
      192
      193
      194
      195
      196
      197
      198
      199
      import {
      Background,
      BackgroundVariant,
      BaseEdge,
      EdgeProps,
      getBezierPath,
      getSimpleBezierPath,
      getSmoothStepPath,
      getStraightPath,
      Panel,
      ReactFlow,
      ReactFlowProvider,
      } from '@xyflow/react';
      import { memo } from 'react';
      import { useShallow } from 'zustand/shallow';
      import useEdgeType, { EdgeType } from '../../hooks/useEdgeType';

      import '@xyflow/react/dist/style.css'; // 引入样式
      import './index.css';

      /** 是否反转动画 */
      const reverse = false;

      /** 动画小圆点个数 */
      const count = 10;

      /** 连接线类型 */
      const edgeTypeOptions = [
      'default',
      'straight',
      'step',
      'smoothstep',
      'simplebezier',
      ] satisfies EdgeType[];

      /** 默认节点 */
      const defaultNodes = [
      {
      id: '1',
      data: { label: 'Node 1' },
      position: { x: 400, y: 0 },
      },
      {
      id: '2',
      data: { label: 'Node 2' },
      position: { x: 0, y: 200 },
      },
      ];

      /** 默认连接线 */
      const defaultEdges = [
      {
      id: '1-2',
      source: '1',
      target: '2',
      type: 'customEdge',
      },
      ];

      /** 自定义连接线 */
      const CustomEdge = memo((props: EdgeProps) => {
      const list = Array.from({ length: count });

      const {
      id,
      sourceX,
      sourceY,
      targetX,
      targetY,
      sourcePosition,
      targetPosition,
      } = props;

      let path = ''; // 路线
      const params = {
      sourceX,
      sourceY,
      sourcePosition,
      targetX,
      targetY,
      targetPosition,
      };

      const { edgeType } = useEdgeType(
      useShallow((state) => ({
      edgeType: state.edgeType,
      }))
      );

      console.log('edgeType', edgeType);

      switch (edgeType) {
      case 'default': {
      const bezierPath = getBezierPath(params);
      path = bezierPath[0];
      break;
      }
      case 'simplebezier': {
      const bezierPath = getSimpleBezierPath(params);
      path = bezierPath[0];
      break;
      }
      case 'smoothstep': {
      const smoothStepPath = getSmoothStepPath({
      ...params,
      borderRadius: 5,
      });
      path = smoothStepPath[0];
      break;
      }
      case 'step': {
      const smoothStepPath = getSmoothStepPath({
      ...params,
      borderRadius: 0,
      });
      path = smoothStepPath[0];
      break;
      }
      case 'straight': {
      const smoothStepPath = getStraightPath(params);
      path = smoothStepPath[0];
      break;
      }
      }

      return (
      <>
      <BaseEdge id={id} path={path} />

      {list?.map((point, index) => (
      <circle
      key={index}
      r={'2'}
      fill={`rgba(255, 195, 0, ${0.1 * (count - index)})`}
      style={{
      filter: 'drop-shadow(0px 0px 2px rgb(255, 195, 0))',
      }}>
      <animateMotion
      dur={'6s'}
      repeatCount={'indefinite'}
      path={path}
      begin={`${0.02 * (index + 1)}s`}
      keyPoints={reverse ? '1;0' : '0;1'} //
      keyTimes={'0;1'}
      />
      </circle>
      ))}
      </>
      );
      });

      /** 注入连接线 */
      const edgeTypes = { customEdge: CustomEdge };

      /** 自定义连接线动画组件 */
      const CustomEdgeAnimation = () => {
      const { onChangeEdgeType } = useEdgeType(
      useShallow((state) => ({
      onChangeEdgeType: state.onChangeEdgeType,
      }))
      );

      return (
      <ReactFlow
      fitView
      defaultNodes={defaultNodes}
      defaultEdges={defaultEdges}
      edgeTypes={edgeTypes}
      proOptions={{ hideAttribution: true }}>
      {/* 背景 */}
      <Background variant={BackgroundVariant.Dots} gap={12} size={1} />

      {/* 面板 */}
      <Panel position="top-left">
      <div className="custom-edge-animation">
      {edgeTypeOptions?.map((item) => (
      <div
      key={item}
      className={`btn`}
      onClick={() => onChangeEdgeType?.(item)}>
      {item}
      </div>
      ))}
      </div>
      </Panel>
      </ReactFlow>
      );
      };

      /** Provider */
      const CustomEdgeAnimationProvider = () => {
      return (
      <ReactFlowProvider>
      <CustomEdgeAnimation />
      </ReactFlowProvider>
      );
      };

      export default CustomEdgeAnimationProvider;
    • 配置 useEdgeType.ts 文件如下

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      import { create } from 'zustand';

      export type EdgeType =
      | 'default'
      | 'straight'
      | 'step'
      | 'smoothstep'
      | 'simplebezier';

      interface Props {
      /** 连接线类型 */
      edgeType?: EdgeType;
      /** 切换连接线类型事件 */
      onChangeEdgeType: (edgeType: EdgeType) => void;
      }

      /** 连接线类型 */
      const useEdgeType = create<Props>((set) => ({
      edgeType: 'default',
      onChangeEdgeType: (edgeType) => set(() => ({ edgeType })),
      }));

      export default useEdgeType;

自定义编辑连接线

  • 参考官网示例:Editable Edge

  • 实现思路:这是一个自定义连接线,当选中时,显示两个节点之间的中心顶点。当拖动时,在当前顶点与其相近的两个顶点中心位置各自添加一个顶点。

  • 目前仅实现在已知拐点添加了拖拽事件,核心代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    import {
    Background,
    BackgroundVariant,
    BaseEdge,
    Edge,
    EdgeProps,
    getSmoothStepPath,
    ReactFlow,
    ReactFlowProvider,
    useReactFlow,
    } from '@xyflow/react';
    import { isEmpty } from 'lodash';
    import { memo, useEffect, useRef } from 'react';

    import '@xyflow/react/dist/style.css'; // 引入样式
    import './index.css';

    /** 顶点坐标 */
    type Vertices = { x: number; y: number };

    /** 默认节点 */
    const defaultNodes = [
    {
    id: '1',
    data: { label: 'Node 1' },
    position: { x: 400, y: 0 },
    },
    {
    id: '2',
    data: { label: 'Node 2' },
    position: { x: 0, y: 200 },
    },
    ];

    /** 默认连接线 */
    const defaultEdges = [
    {
    id: '1-2',
    source: '1',
    target: '2',
    type: 'customEdge',
    },
    ];

    /** 解析路径, 获取拐点 */
    const getVerticesByPathUtil = (path: string) => {
    const regex = /L\s+(\d+),(\d+)/g; // 匹配 L x,y 的格式
    const vertices: Vertices[] = [];
    let match;

    while ((match = regex.exec(path)) !== null) {
    const x = parseFloat(match[1]);
    const y = parseFloat(match[2]);
    vertices.push({ x, y });
    }

    return vertices;
    };

    /** 自定义连接线 */
    const CustomEdge = memo(
    (props: EdgeProps<Edge<{ vertices: Vertices[] }>>) => {
    const {
    id,
    sourceX,
    sourceY,
    targetX,
    targetY,
    sourcePosition,
    targetPosition,
    data,
    selected,
    } = props;

    const { updateEdgeData, screenToFlowPosition } = useReactFlow();

    const edgePathRef = useRef(''); // 连接线路径
    const mouseDownIdRef = useRef(-1); // 鼠标按下的拐点索引

    // 如果有拐点,则使用拐点坐标
    if (data?.vertices) {
    // 组合所有路径点(起点 + 拐点 + 终点)
    const points = [
    { x: sourceX, y: sourceY },
    ...(data?.vertices || []), // 拐点
    { x: targetX, y: targetY },
    ];

    // 生成直角路径指令
    edgePathRef.current = points.reduce((path, point, i) => {
    return i === 0
    ? `M ${point.x},${point.y}`
    : `${path} L ${point.x},${point.y}`;
    }, '');
    }

    // 如果没有拐点,则使用默认路径
    useEffect(() => {
    if (data?.vertices) return;

    // 获取路径
    const [path] = getSmoothStepPath({
    sourceX,
    sourceY,
    targetX,
    targetY,
    sourcePosition,
    targetPosition,
    borderRadius: 0, // 圆角还是直角
    });

    edgePathRef.current = path; // 更新路径

    // 解析路径,获取拐点坐标
    const vertices = getVerticesByPathUtil(edgePathRef.current) || [];

    // 更新拐点坐标
    updateEdgeData(id, { vertices: vertices });
    }, [updateEdgeData]);

    /** 鼠标移动事件 */
    const handleMouseMove = (event: MouseEvent) => {
    if (mouseDownIdRef.current < 0) return;
    event.preventDefault();
    const dragX = event.clientX;
    const dragY = event.clientY;

    const newVertices = [...(data?.vertices || [])];
    newVertices[mouseDownIdRef.current] = screenToFlowPosition(
    { x: dragX, y: dragY },
    { snapToGrid: false }
    );
    // 更新拐点坐标
    updateEdgeData(id, { vertices: newVertices });
    };

    /** 鼠标抬起事件 */
    const handleMouseUp = () => {
    mouseDownIdRef.current = -1;
    document.removeEventListener('mousemove', handleMouseMove);
    document.removeEventListener('mouseup', handleMouseUp);
    };

    /** 鼠标按下事件 */
    const handleMouseDown = (e: React.MouseEvent, index: number) => {
    mouseDownIdRef.current = index;
    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);
    e.preventDefault();
    };

    useEffect(() => {
    return () => {
    document.removeEventListener('mousemove', handleMouseMove);
    document.removeEventListener('mouseup', handleMouseUp);
    };
    }, []);

    return (
    <>
    <BaseEdge id={id} path={edgePathRef.current} />

    {selected &&
    !isEmpty(data?.vertices) &&
    data?.vertices?.map((vertex, index) => (
    <circle
    key={index}
    tabIndex={0}
    cx={vertex.x}
    cy={vertex.y}
    r="4px"
    fill="#ff0066"
    strokeWidth={1}
    stroke={mouseDownIdRef.current === index ? 'black' : 'white'}
    style={{ pointerEvents: 'all', outline: 'none' }}
    onMouseDown={(e) => handleMouseDown(e, index)}
    />
    ))}
    </>
    );
    }
    );

    /** 注入连接线 */
    const edgeTypes = { customEdge: CustomEdge };

    /** 自定义连接线动画组件 */
    const CustomEditableEdge = () => {
    return (
    <ReactFlow
    fitView
    defaultNodes={defaultNodes}
    defaultEdges={defaultEdges}
    edgeTypes={edgeTypes}
    proOptions={{ hideAttribution: true }}>
    {/* 背景 */}
    <Background variant={BackgroundVariant.Dots} gap={12} size={1} />
    </ReactFlow>
    );
    };

    /** Provider */
    const CustomEditableEdgeProvider = () => {
    return (
    <ReactFlowProvider>
    <CustomEditableEdge />
    </ReactFlowProvider>
    );
    };

    export default CustomEditableEdgeProvider;

选择分组示例

  • 参考官网示例:Selection Grouping

  • 实现思路:计算所有选中的节点的最小坐标位置和最大坐标位置,然后根据这两个坐标位置创建一个新的节点作为分组节点。官方提供了 getNodesBounds 方法,传入节点数组,返回节点组的 xywidthheight 信息。核心代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    import {
    Background,
    BackgroundVariant,
    Node,
    OnSelectionChangeParams,
    Panel,
    ReactFlow,
    ReactFlowProvider,
    useReactFlow,
    } from '@xyflow/react';
    import { useCallback, useState } from 'react';
    import { v4 as uuidv4 } from 'uuid';

    import '@xyflow/react/dist/style.css'; // 引入样式
    import './index.css';

    /** 节点组padding (不想要可设置为0) */
    const GROUP_PADDING = 10;

    /** 默认节点 */
    const defaultNodes = [
    {
    id: '1',
    data: { label: 'Node 1' },
    position: { x: 250, y: 5 },
    },
    {
    id: '2',
    data: { label: 'Node 2' },
    position: { x: 100, y: 100 },
    },
    ];

    /** 默认连接线 */
    const defaultEdges = [
    {
    id: '1-2',
    source: '1',
    target: '2',
    type: 'smoothstep',
    },
    ];

    /** 自定义选择分组组件 */
    const CustomSelectGroup = () => {
    const { setNodes, getNodes, getNodesBounds } = useReactFlow();

    const [selected, setSelected] = useState<OnSelectionChangeParams>({
    nodes: [],
    edges: [],
    });

    /** 节点/连接线选择事件 */
    const handleSelectionChange = useCallback(
    (nodeEdgeObj: OnSelectionChangeParams) => {
    setSelected?.(nodeEdgeObj);
    },
    []
    );

    /** 创建组 */
    const handleCreateGroup = () => {
    const selectedNodes = selected?.nodes || [];

    // 生成组 ID
    const groupId = uuidv4();
    // 获取所有选中节点的 ID
    const selectedNodeIds = selectedNodes.map((node) => node.id);
    // 获取所有节点的最大/最小坐标
    const { x, y, width, height } = getNodesBounds(selectedNodes);

    // 创建组节点
    const groupNode = {
    id: groupId,
    type: 'group',
    data: {},
    position: { x: x - GROUP_PADDING, y: y - GROUP_PADDING },
    width: width + GROUP_PADDING * 2,
    height: height + GROUP_PADDING * 2,
    style: {
    backgroundColor: 'rgba(207, 182, 255, 0.4)',
    borderColor: '#9e86ed',
    },
    } satisfies Node;

    // 获取所有节点
    const nodes = getNodes();

    // 更新节点
    const updatedNodes: Node[] = nodes.map((node) => {
    if (selectedNodeIds.includes(node.id)) {
    const { position } = node;
    return {
    ...node,
    parentId: groupId,
    extent: 'parent',
    position: {
    x: position.x - Math.abs(x) + GROUP_PADDING,
    y: position.y - Math.abs(y) + GROUP_PADDING,
    },
    selected: false,
    };
    }
    return node;
    });

    setNodes([groupNode, ...updatedNodes]); // groupNode 必须放在前面, 否则会导致 extent: 'parent' 不生效
    };

    /** 移除组 */
    const handleRemoveGroup = () => {
    const selectedNodes = selected?.nodes || [];

    const groupId = selectedNodes[0].id;
    // 获取所有节点
    const allNodes = getNodes();
    // 获取最新的组节点, 防止组节点坐标被修改
    const realNode = allNodes.filter((node) => node.id === groupId);

    setNodes((nodes) =>
    nodes
    .map((node) => {
    const { parentId, position } = node;
    if (parentId === groupId) {
    const x = position.x + realNode[0].position.x;
    const y = position.y + realNode[0].position.y;
    return {
    ...node,
    parentId: undefined,
    extent: undefined,
    position: { x, y },
    };
    }
    return node;
    })
    .filter((node) => node.id !== groupId)
    );
    };

    return (
    <ReactFlow
    fitView
    defaultNodes={defaultNodes}
    defaultEdges={defaultEdges}
    onSelectionChange={handleSelectionChange}
    proOptions={{ hideAttribution: true }}>
    {/* 背景 */}
    <Background variant={BackgroundVariant.Dots} gap={12} size={1} />

    {/* 面板 */}
    <Panel position="top-left">
    <div className="custom-select-group">
    <div
    className={`btn ${
    selected?.nodes.length < 1 ||
    selected.nodes.some(
    (node) => node.type === 'group' || node.parentId
    )
    ? 'btn-disabled'
    : ''
    }`}
    onClick={handleCreateGroup}>
    创建组
    </div>
    <div
    className={`btn ${
    selected?.nodes.length !== 1 ||
    selected?.nodes[0].type !== 'group'
    ? 'btn-disabled'
    : ''
    }`}
    onClick={handleRemoveGroup}>
    删除组
    </div>
    </div>
    </Panel>
    </ReactFlow>
    );
    };

    /** Provider */
    const CustomSelectGroupProvider = () => {
    return (
    <ReactFlowProvider>
    <CustomSelectGroup />
    </ReactFlowProvider>
    );
    };

    export default CustomSelectGroupProvider;

集成演示地址