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: 通过直线计算路径

  • 核心代码如下:

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
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;
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;

集成演示地址