0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【ReactFlow】コンテキストメニューでノードを追加する

0
Posted at

はじめに

こんにちは。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;
}

スクリーンショット 2025-08-07 004229.png

ポイント

  • 公式ドキュメントではコンテキストメニューのtsxをコンポーネント化しているが、めんどくさがりの自分には合わずpage.tsxに記述
  • ノードに対して右クリックするとノードの追加と削除をコンテキストメニューで表示、何もないところに右クリックするとノードの追加だけ表示
  • デフォルトでWindowsのダークモードを使用しているのでReactFlowも合わせてダークモードで実装

まとめ

ReactFlowのコンテキストメニューを実装する方法を記述しました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?