はじめに
こんにちは。5回目の記事投稿になります。
この記事ではNext.jsの環境でReactFlowを使ってコンテキストメニューを実装する方法を備忘録も兼ねてまとめます。
ReactFlowのインストールなどについてはこちらの記事をご覧ください
また、必要であればこちらもご覧ください
コンテキストメニューを実装する
github
今回のプロジェクト
公式ドキュメント
公式ドキュメントにもコンテキストメニューの実装方法が書いてありますが、個人的にわかりづらい + jsxで記述されているということでTypeScriptを使ったNextの環境では少し不便な点があります。
実装環境
- Next.js(AppRouter)
- TypeScript
- ReactFlow (v12.8.2)
実際に作ってみる
// app/page.tsx
'use client';
import { useCallback, useState, useRef } from 'react';
import {
ReactFlow,
MiniMap,
Controls,
Background,
useNodesState,
useEdgesState,
addEdge,
Node,
Edge,
Connection,
ReactFlowProvider,
BackgroundVariant,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
const initialNodes: Node[] = [
{
id: '1',
type: 'default',
position: { x: 250, y: 50 },
data: { label: 'ノード 1' },
},
{
id: '2',
type: 'default',
position: { x: 100, y: 150 },
data: { label: 'ノード 2' },
},
{
id: '3',
type: 'default',
position: { x: 400, y: 150 },
data: { label: 'ノード 3' },
},
];
const initialEdges: Edge[] = [
{ id: 'e1-2', source: '1', target: '2' },
{ id: 'e1-3', source: '1', target: '3' },
];
interface ContextMenuProps {
position: { x: number; y: number };
nodeId?: string;
onAddNode: (position: { x: number; y: number }) => void;
onDeleteNode: (nodeId: string) => void;
onClose: () => void;
}
const ContextMenu = ({ position, nodeId, onAddNode, onDeleteNode, onClose }: ContextMenuProps) => {
return (
<div
style={{
position: 'fixed',
top: position.y,
left: position.x,
background: '#374151',
border: '1px solid #4b5563',
borderRadius: '6px',
padding: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
color: 'white',
}}
>
<button
onClick={() => {
onAddNode(position);
onClose();
}}
style={{
display: 'block',
width: '100%',
padding: '8px 16px',
background: 'none',
border: 'none',
color: 'white',
cursor: 'pointer',
borderRadius: '4px',
marginBottom: '4px',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#4b5563')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
>
ノードを追加
</button>
{nodeId && (
<button
onClick={() => {
onDeleteNode(nodeId);
onClose();
}}
style={{
display: 'block',
width: '100%',
padding: '8px 16px',
background: 'none',
border: 'none',
color: '#ef4444',
cursor: 'pointer',
borderRadius: '4px',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#4b5563')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
>
ノードを削除
</button>
)}
</div>
);
};
function Flow() {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [contextMenu, setContextMenu] = useState<{
show: boolean;
position: { x: number; y: number };
nodeId?: string;
}>({ show: false, position: { x: 0, y: 0 } });
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
);
const onNodeContextMenu = useCallback(
(event: React.MouseEvent, node: Node) => {
event.preventDefault();
setContextMenu({
show: true,
position: { x: event.clientX, y: event.clientY },
nodeId: node.id,
});
},
[]
);
const onPaneContextMenu = useCallback(
(event: React.MouseEvent | MouseEvent) => {
event.preventDefault();
const clientX = 'clientX' in event ? event.clientX : (event as MouseEvent).clientX;
const clientY = 'clientY' in event ? event.clientY : (event as MouseEvent).clientY;
setContextMenu({
show: true,
position: { x: clientX, y: clientY },
});
},
[]
);
const onAddNode = useCallback(
(position: { x: number; y: number }) => {
const newNodeId = `node_${Date.now()}`;
const newNode: Node = {
id: newNodeId,
type: 'default',
position: { x: position.x - 100, y: position.y - 50 },
data: { label: `新しいノード ${nodes.length + 1}` },
};
setNodes((nds) => [...nds, newNode]);
},
[nodes.length, setNodes]
);
const onDeleteNode = useCallback(
(nodeId: string) => {
setNodes((nds) => nds.filter((node) => node.id !== nodeId));
setEdges((eds) => eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
},
[setNodes, setEdges]
);
const onCloseContextMenu = useCallback(() => {
setContextMenu({ show: false, position: { x: 0, y: 0 } });
}, []);
return (
<div style={{ width: '100vw', height: '100vh', background: '#111827' }} ref={reactFlowWrapper}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeContextMenu={onNodeContextMenu}
onPaneContextMenu={onPaneContextMenu}
onPaneClick={onCloseContextMenu}
colorMode="dark"
style={{ background: '#111827' }}
>
<Controls />
<MiniMap
style={{ background: '#1f2937' }}
nodeColor="#6366f1"
maskColor="rgba(0, 0, 0, 0.3)"
/>
<Background
variant={BackgroundVariant.Dots}
gap={12}
size={1}
color="#374151"
/>
</ReactFlow>
{contextMenu.show && (
<ContextMenu
position={contextMenu.position}
nodeId={contextMenu.nodeId}
onAddNode={onAddNode}
onDeleteNode={onDeleteNode}
onClose={onCloseContextMenu}
/>
)}
</div>
);
}
export default function Home() {
return (
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
);
}
/* app/globals.css */
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}
/* ReactFlow ダークモード用カスタムスタイル */
.react-flow__node {
background: #374151;
color: #f9fafb;
border: 1px solid #4b5563;
}
.react-flow__node-default {
background: #374151;
color: #f9fafb;
border: 1px solid #4b5563;
}
.react-flow__node:hover {
border-color: #6366f1;
}
.react-flow__node.selected {
border-color: #8b5cf6;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3);
}
.react-flow__edge-path {
stroke: #6b7280;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: #8b5cf6;
}
.react-flow__connection-line {
stroke: #6b7280;
}
.react-flow__handle {
background: #6b7280;
border: 2px solid #374151;
}
.react-flow__handle:hover {
background: #9ca3af;
}
.react-flow__controls {
background: #374151 !important;
border: 1px solid #4b5563 !important;
}
.react-flow__controls-button {
background: #374151 !important;
border-color: #4b5563 !important;
color: #f9fafb !important;
}
.react-flow__controls-button:hover {
background: #4b5563 !important;
}
.react-flow__minimap {
background: #1f2937 !important;
}
.react-flow__attribution {
background: #374151 !important;
color: #9ca3af !important;
}
ポイント
- 公式ドキュメントではコンテキストメニューのtsxをコンポーネント化しているが、めんどくさがりの自分には合わずpage.tsxに記述
- ノードに対して右クリックするとノードの追加と削除をコンテキストメニューで表示、何もないところに右クリックするとノードの追加だけ表示
- デフォルトでWindowsのダークモードを使用しているのでReactFlowも合わせてダークモードで実装
まとめ
ReactFlowのコンテキストメニューを実装する方法を記述しました。
