React Flow
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
181import {
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 | import { |
1 | import { create } from 'zustand'; |
自定义编辑连接线
参考官网示例: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
211import {
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
方法,传入节点数组,返回节点组的x
、y
、width
、height
信息。核心代码如下: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
190import {
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;