重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
reactflow-custom-nodes by thebushidocollective/han
npx skills add https://github.com/thebushidocollective/han --skill reactflow-custom-nodes使用 React Flow 创建完全自定义的节点和边。通过自定义样式、行为和交互构建复杂的基于节点的编辑器。
import { memo } from 'react';
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
// 定义自定义节点数据类型
type TextUpdaterNodeData = {
label: string;
onChange: (value: string) => void;
};
type TextUpdaterNode = Node<TextUpdaterNodeData>;
function TextUpdaterNode({ data, isConnectable }: NodeProps<TextUpdaterNode>) {
const onChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
data.onChange(evt.target.value);
};
return (
<div className="text-updater-node">
<Handle
type="target"
position={Position.Top}
isConnectable={isConnectable}
/>
<div>
<label htmlFor="text">文本:</label>
<input
id="text"
name="text"
onChange={onChange}
className="nodrag"
defaultValue={data.label}
/>
</div>
<Handle
type="source"
position={Position.Bottom}
id="a"
isConnectable={isConnectable}
/>
</div>
);
}
// 使用 memo 进行性能优化
export default memo(TextUpdaterNode);
import { ReactFlow } from '@xyflow/react';
import TextUpdaterNode from './TextUpdaterNode';
import ColorPickerNode from './ColorPickerNode';
// 在组件外部定义节点类型以防止重新渲染
const nodeTypes = {
textUpdater: TextUpdaterNode,
colorPicker: ColorPickerNode,
};
function Flow() {
const [nodes, setNodes, onNodesChange] = useNodesState([
{
id: '1',
type: 'textUpdater',
position: { x: 0, y: 0 },
data: {
label: '你好',
onChange: (value) => console.log(value),
},
},
]);
return (
<ReactFlow
nodes={nodes}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
/>
);
}
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
type StatusNodeData = {
label: string;
status: 'pending' | 'running' | 'completed' | 'error';
};
const statusColors = {
pending: 'bg-yellow-100 border-yellow-400',
running: 'bg-blue-100 border-blue-400',
completed: 'bg-green-100 border-green-400',
error: 'bg-red-100 border-red-400',
};
const statusIcons = {
pending: '⏳',
running: '⚡',
completed: '✅',
error: '❌',
};
function StatusNode({ data }: NodeProps<Node<StatusNodeData>>) {
return (
<div
className={`px-4 py-2 rounded-lg border-2 shadow-sm ${statusColors[data.status]}`}
>
<Handle type="target" position={Position.Top} className="!bg-gray-400" />
<div className="flex items-center gap-2">
<span className="text-xl">{statusIcons[data.status]}</span>
<span className="font-medium">{data.label}</span>
</div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-gray-400"
/>
</div>
);
}
export default memo(StatusNode);
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
type SwitchNodeData = {
label: string;
cases: string[];
};
function SwitchNode({ data }: NodeProps<Node<SwitchNodeData>>) {
return (
<div className="switch-node bg-white rounded-lg shadow-lg p-3 min-w-[150px]">
{/* 单个输入 */}
<Handle type="target" position={Position.Top} id="input" />
<div className="font-bold text-center border-b pb-2 mb-2">
{data.label}
</div>
{/* 多个输出 - 每个分支一个 */}
<div className="space-y-2">
{data.cases.map((caseLabel, index) => (
<div key={index} className="relative text-sm text-right pr-4">
{caseLabel}
<Handle
type="source"
position={Position.Right}
id={`case-${index}`}
style={{ top: `${30 + index * 28}px` }}
/>
</div>
))}
</div>
</div>
);
}
export default memo(SwitchNode);
import { memo } from 'react';
import { Handle, Position, NodeResizer, type NodeProps } from '@xyflow/react';
type ResizableNodeData = {
label: string;
content: string;
};
function ResizableNode({ data, selected }: NodeProps<Node<ResizableNodeData>>) {
return (
<>
<NodeResizer
color="#ff0071"
isVisible={selected}
minWidth={100}
minHeight={50}
handleStyle={{ width: 8, height: 8 }}
/>
<Handle type="target" position={Position.Top} />
<div className="p-4 h-full">
<div className="font-bold">{data.label}</div>
<div className="text-sm text-gray-600">{data.content}</div>
</div>
<Handle type="source" position={Position.Bottom} />
</>
);
}
export default memo(ResizableNode);
import { memo, useState } from 'react';
import {
Handle,
Position,
NodeToolbar,
type NodeProps,
useReactFlow,
} from '@xyflow/react';
type EditableNodeData = {
label: string;
};
function EditableNode({
id,
data,
selected,
}: NodeProps<Node<EditableNodeData>>) {
const { setNodes, deleteElements } = useReactFlow();
const [isEditing, setIsEditing] = useState(false);
const [label, setLabel] = useState(data.label);
const handleSave = () => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id ? { ...node, data: { ...node.data, label } } : node
)
);
setIsEditing(false);
};
const handleDelete = () => {
deleteElements({ nodes: [{ id }] });
};
return (
<>
<NodeToolbar isVisible={selected} position={Position.Top}>
<button onClick={() => setIsEditing(true)} className="toolbar-btn">
✏️ 编辑
</button>
<button onClick={handleDelete} className="toolbar-btn text-red-500">
🗑️ 删除
</button>
</NodeToolbar>
<Handle type="target" position={Position.Top} />
<div className="px-4 py-2 bg-white rounded shadow">
{isEditing ? (
<div className="flex gap-2">
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
className="border rounded px-2"
autoFocus
/>
<button onClick={handleSave}>保存</button>
</div>
) : (
<span>{data.label}</span>
)}
</div>
<Handle type="source" position={Position.Bottom} />
</>
);
}
export default memo(EditableNode);
import { memo } from 'react';
import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
useReactFlow,
type EdgeProps,
} from '@xyflow/react';
function ButtonEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
}: EdgeProps) {
const { setEdges } = useReactFlow();
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const onEdgeClick = () => {
setEdges((edges) => edges.filter((edge) => edge.id !== id));
};
return (
<>
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
fontSize: 12,
pointerEvents: 'all',
}}
className="nodrag nopan"
>
<button
className="w-5 h-5 bg-gray-200 rounded-full border border-gray-400 cursor-pointer hover:bg-red-200"
onClick={onEdgeClick}
>
×
</button>
</div>
</EdgeLabelRenderer>
</>
);
}
export default memo(ButtonEdge);
import { memo } from 'react';
import { BaseEdge, getStraightPath, type EdgeProps } from '@xyflow/react';
function CustomPathEdge({
sourceX,
sourceY,
targetX,
targetY,
}: EdgeProps) {
// 创建自定义 S 曲线路径
const midY = (sourceY + targetY) / 2;
const path = `
M ${sourceX} ${sourceY}
C ${sourceX} ${midY},
${targetX} ${midY},
${targetX} ${targetY}
`;
return <BaseEdge path={path} style={{ stroke: '#b1b1b7', strokeWidth: 2 }} />;
}
export default memo(CustomPathEdge);
import { ReactFlow } from '@xyflow/react';
import ButtonEdge from './ButtonEdge';
import CustomPathEdge from './CustomPathEdge';
const edgeTypes = {
buttonEdge: ButtonEdge,
customPath: CustomPathEdge,
};
function Flow() {
const [edges, setEdges, onEdgesChange] = useEdgesState([
{
id: 'e1-2',
source: '1',
target: '2',
type: 'buttonEdge',
},
]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
edgeTypes={edgeTypes}
onEdgesChange={onEdgesChange}
/>
);
}
import { useCallback } from 'react';
import { ReactFlow, type IsValidConnection } from '@xyflow/react';
function Flow() {
// 在建立连接之前进行验证
const isValidConnection: IsValidConnection = useCallback(
(connection) => {
// 防止自连接
if (connection.source === connection.target) {
return false;
}
// 仅允许特定连接点类型之间的连接
const sourceNode = nodes.find((n) => n.id === connection.source);
const targetNode = nodes.find((n) => n.id === connection.target);
// 示例:输入节点不能接收连接
if (targetNode?.type === 'input') {
return false;
}
// 示例:输出节点不能发送连接
if (sourceNode?.type === 'output') {
return false;
}
return true;
},
[nodes]
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
isValidConnection={isValidConnection}
/>
);
}
import { memo } from 'react';
import { type NodeProps } from '@xyflow/react';
type GroupNodeData = {
label: string;
};
function GroupNode({ data }: NodeProps<Node<GroupNodeData>>) {
return (
<div className="p-2 border-2 border-dashed border-gray-400 rounded-lg bg-gray-50/50 min-w-[200px] min-h-[150px]">
<div className="text-xs text-gray-500 font-medium mb-2">{data.label}</div>
</div>
);
}
export default memo(GroupNode);
// 用法 - 子节点引用父节点
const nodes = [
{
id: 'group-1',
type: 'group',
data: { label: '分组 A' },
position: { x: 0, y: 0 },
style: { width: 300, height: 200 },
},
{
id: 'child-1',
data: { label: '子节点' },
position: { x: 50, y: 50 },
parentId: 'group-1',
extent: 'parent',
},
];
/* node.css */
.react-flow__node-custom {
background: white;
border: 1px solid #1a192b;
border-radius: 8px;
padding: 10px;
font-size: 12px;
width: 150px;
}
.react-flow__node-custom.selected {
border-color: #ff0071;
box-shadow: 0 0 0 2px #ff0071;
}
.react-flow__handle {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #555;
}
.react-flow__handle-connecting {
background-color: #ff0071;
}
.react-flow__handle-valid {
background-color: #55dd99;
}
/* 防止交互元素被拖动 */
.nodrag {
pointer-events: all;
}
/* 边样式 */
.react-flow__edge-path {
stroke: #b1b1b7;
stroke-width: 2;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: #ff0071;
}
.react-flow__edge.animated .react-flow__edge-path {
stroke-dasharray: 5;
animation: dashdraw 0.5s linear infinite;
}
@keyframes dashdraw {
from {
stroke-dashoffset: 10;
}
}
当您需要时使用 reactflow-custom-nodes:
memo() 对自定义节点组件进行记忆化nodrag 类每周安装量
56
仓库
GitHub 星标数
122
首次出现
2026年2月12日
安全审计
安装于
codex56
github-copilot55
gemini-cli55
opencode54
amp54
kimi-cli54
Create fully customized nodes and edges with React Flow. Build complex node-based editors with custom styling, behaviors, and interactions.
import { memo } from 'react';
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
// Define custom node data type
type TextUpdaterNodeData = {
label: string;
onChange: (value: string) => void;
};
type TextUpdaterNode = Node<TextUpdaterNodeData>;
function TextUpdaterNode({ data, isConnectable }: NodeProps<TextUpdaterNode>) {
const onChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
data.onChange(evt.target.value);
};
return (
<div className="text-updater-node">
<Handle
type="target"
position={Position.Top}
isConnectable={isConnectable}
/>
<div>
<label htmlFor="text">Text:</label>
<input
id="text"
name="text"
onChange={onChange}
className="nodrag"
defaultValue={data.label}
/>
</div>
<Handle
type="source"
position={Position.Bottom}
id="a"
isConnectable={isConnectable}
/>
</div>
);
}
// Memoize for performance
export default memo(TextUpdaterNode);
import { ReactFlow } from '@xyflow/react';
import TextUpdaterNode from './TextUpdaterNode';
import ColorPickerNode from './ColorPickerNode';
// Define node types outside component to prevent re-renders
const nodeTypes = {
textUpdater: TextUpdaterNode,
colorPicker: ColorPickerNode,
};
function Flow() {
const [nodes, setNodes, onNodesChange] = useNodesState([
{
id: '1',
type: 'textUpdater',
position: { x: 0, y: 0 },
data: {
label: 'Hello',
onChange: (value) => console.log(value),
},
},
]);
return (
<ReactFlow
nodes={nodes}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
/>
);
}
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
type StatusNodeData = {
label: string;
status: 'pending' | 'running' | 'completed' | 'error';
};
const statusColors = {
pending: 'bg-yellow-100 border-yellow-400',
running: 'bg-blue-100 border-blue-400',
completed: 'bg-green-100 border-green-400',
error: 'bg-red-100 border-red-400',
};
const statusIcons = {
pending: '⏳',
running: '⚡',
completed: '✅',
error: '❌',
};
function StatusNode({ data }: NodeProps<Node<StatusNodeData>>) {
return (
<div
className={`px-4 py-2 rounded-lg border-2 shadow-sm ${statusColors[data.status]}`}
>
<Handle type="target" position={Position.Top} className="!bg-gray-400" />
<div className="flex items-center gap-2">
<span className="text-xl">{statusIcons[data.status]}</span>
<span className="font-medium">{data.label}</span>
</div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-gray-400"
/>
</div>
);
}
export default memo(StatusNode);
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
type SwitchNodeData = {
label: string;
cases: string[];
};
function SwitchNode({ data }: NodeProps<Node<SwitchNodeData>>) {
return (
<div className="switch-node bg-white rounded-lg shadow-lg p-3 min-w-[150px]">
{/* Single input */}
<Handle type="target" position={Position.Top} id="input" />
<div className="font-bold text-center border-b pb-2 mb-2">
{data.label}
</div>
{/* Multiple outputs - one per case */}
<div className="space-y-2">
{data.cases.map((caseLabel, index) => (
<div key={index} className="relative text-sm text-right pr-4">
{caseLabel}
<Handle
type="source"
position={Position.Right}
id={`case-${index}`}
style={{ top: `${30 + index * 28}px` }}
/>
</div>
))}
</div>
</div>
);
}
export default memo(SwitchNode);
import { memo } from 'react';
import { Handle, Position, NodeResizer, type NodeProps } from '@xyflow/react';
type ResizableNodeData = {
label: string;
content: string;
};
function ResizableNode({ data, selected }: NodeProps<Node<ResizableNodeData>>) {
return (
<>
<NodeResizer
color="#ff0071"
isVisible={selected}
minWidth={100}
minHeight={50}
handleStyle={{ width: 8, height: 8 }}
/>
<Handle type="target" position={Position.Top} />
<div className="p-4 h-full">
<div className="font-bold">{data.label}</div>
<div className="text-sm text-gray-600">{data.content}</div>
</div>
<Handle type="source" position={Position.Bottom} />
</>
);
}
export default memo(ResizableNode);
import { memo, useState } from 'react';
import {
Handle,
Position,
NodeToolbar,
type NodeProps,
useReactFlow,
} from '@xyflow/react';
type EditableNodeData = {
label: string;
};
function EditableNode({
id,
data,
selected,
}: NodeProps<Node<EditableNodeData>>) {
const { setNodes, deleteElements } = useReactFlow();
const [isEditing, setIsEditing] = useState(false);
const [label, setLabel] = useState(data.label);
const handleSave = () => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id ? { ...node, data: { ...node.data, label } } : node
)
);
setIsEditing(false);
};
const handleDelete = () => {
deleteElements({ nodes: [{ id }] });
};
return (
<>
<NodeToolbar isVisible={selected} position={Position.Top}>
<button onClick={() => setIsEditing(true)} className="toolbar-btn">
✏️ Edit
</button>
<button onClick={handleDelete} className="toolbar-btn text-red-500">
🗑️ Delete
</button>
</NodeToolbar>
<Handle type="target" position={Position.Top} />
<div className="px-4 py-2 bg-white rounded shadow">
{isEditing ? (
<div className="flex gap-2">
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
className="border rounded px-2"
autoFocus
/>
<button onClick={handleSave}>Save</button>
</div>
) : (
<span>{data.label}</span>
)}
</div>
<Handle type="source" position={Position.Bottom} />
</>
);
}
export default memo(EditableNode);
import { memo } from 'react';
import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
useReactFlow,
type EdgeProps,
} from '@xyflow/react';
function ButtonEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
}: EdgeProps) {
const { setEdges } = useReactFlow();
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const onEdgeClick = () => {
setEdges((edges) => edges.filter((edge) => edge.id !== id));
};
return (
<>
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
fontSize: 12,
pointerEvents: 'all',
}}
className="nodrag nopan"
>
<button
className="w-5 h-5 bg-gray-200 rounded-full border border-gray-400 cursor-pointer hover:bg-red-200"
onClick={onEdgeClick}
>
×
</button>
</div>
</EdgeLabelRenderer>
</>
);
}
export default memo(ButtonEdge);
import { memo } from 'react';
import { BaseEdge, getStraightPath, type EdgeProps } from '@xyflow/react';
function CustomPathEdge({
sourceX,
sourceY,
targetX,
targetY,
}: EdgeProps) {
// Create a custom S-curve path
const midY = (sourceY + targetY) / 2;
const path = `
M ${sourceX} ${sourceY}
C ${sourceX} ${midY},
${targetX} ${midY},
${targetX} ${targetY}
`;
return <BaseEdge path={path} style={{ stroke: '#b1b1b7', strokeWidth: 2 }} />;
}
export default memo(CustomPathEdge);
import { ReactFlow } from '@xyflow/react';
import ButtonEdge from './ButtonEdge';
import CustomPathEdge from './CustomPathEdge';
const edgeTypes = {
buttonEdge: ButtonEdge,
customPath: CustomPathEdge,
};
function Flow() {
const [edges, setEdges, onEdgesChange] = useEdgesState([
{
id: 'e1-2',
source: '1',
target: '2',
type: 'buttonEdge',
},
]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
edgeTypes={edgeTypes}
onEdgesChange={onEdgesChange}
/>
);
}
import { useCallback } from 'react';
import { ReactFlow, type IsValidConnection } from '@xyflow/react';
function Flow() {
// Validate connections before they're made
const isValidConnection: IsValidConnection = useCallback(
(connection) => {
// Prevent self-connections
if (connection.source === connection.target) {
return false;
}
// Only allow connections between specific handle types
const sourceNode = nodes.find((n) => n.id === connection.source);
const targetNode = nodes.find((n) => n.id === connection.target);
// Example: input nodes can't receive connections
if (targetNode?.type === 'input') {
return false;
}
// Example: output nodes can't send connections
if (sourceNode?.type === 'output') {
return false;
}
return true;
},
[nodes]
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
isValidConnection={isValidConnection}
/>
);
}
import { memo } from 'react';
import { type NodeProps } from '@xyflow/react';
type GroupNodeData = {
label: string;
};
function GroupNode({ data }: NodeProps<Node<GroupNodeData>>) {
return (
<div className="p-2 border-2 border-dashed border-gray-400 rounded-lg bg-gray-50/50 min-w-[200px] min-h-[150px]">
<div className="text-xs text-gray-500 font-medium mb-2">{data.label}</div>
</div>
);
}
export default memo(GroupNode);
// Usage - child nodes reference parent
const nodes = [
{
id: 'group-1',
type: 'group',
data: { label: 'Group A' },
position: { x: 0, y: 0 },
style: { width: 300, height: 200 },
},
{
id: 'child-1',
data: { label: 'Child Node' },
position: { x: 50, y: 50 },
parentId: 'group-1',
extent: 'parent',
},
];
/* node.css */
.react-flow__node-custom {
background: white;
border: 1px solid #1a192b;
border-radius: 8px;
padding: 10px;
font-size: 12px;
width: 150px;
}
.react-flow__node-custom.selected {
border-color: #ff0071;
box-shadow: 0 0 0 2px #ff0071;
}
.react-flow__handle {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #555;
}
.react-flow__handle-connecting {
background-color: #ff0071;
}
.react-flow__handle-valid {
background-color: #55dd99;
}
/* Prevent drag on interactive elements */
.nodrag {
pointer-events: all;
}
/* Edge styling */
.react-flow__edge-path {
stroke: #b1b1b7;
stroke-width: 2;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: #ff0071;
}
.react-flow__edge.animated .react-flow__edge-path {
stroke-dasharray: 5;
animation: dashdraw 0.5s linear infinite;
}
@keyframes dashdraw {
from {
stroke-dashoffset: 10;
}
}
Use reactflow-custom-nodes when you need to:
memo()nodrag class on interactive elementsWeekly Installs
56
Repository
GitHub Stars
122
First Seen
Feb 12, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex56
github-copilot55
gemini-cli55
opencode54
amp54
kimi-cli54
React视图过渡API使用指南:实现原生浏览器动画与状态管理
9,100 周安装