2023/01/10

React Flowを使ったマインドマップアプリの構築

Moritz Klack
共同創設者

このチュートリアルでは、ブレインストーミング、アイデアの整理、または視覚的な方法で思考をマッピングするために使用できる、シンプルなマインドマップツールをReact Flowで作成する方法を学習します。このアプリを構築するには、状態管理、カスタムノードとエッジなどを利用します。

デモの時間です!

実際に始める前に、このチュートリアルの最後に完成するマインドマッピングツールをご紹介します。

もし、すぐにコードに飛び込みたいのであれば、ソースコードはGithubで見つけることができます。

はじめに

このチュートリアルを行うには、ReactReact Flow(私たちです! ワークフローツール、ETLパイプライン、そしてその他のようなノードベースのUIを構築するためのオープンソースライブラリです)に関する知識が必要です。

アプリの開発にはViteを使用しますが、Create React Appまたはその他の好きなツールを使用することもできます。Viteで新しいReactアプリをスキャフォールディングするには、次の手順を実行する必要があります。

npm create vite@latest reactflow-mind-map -- --template react

TypeScriptを使用したい場合

npm create vite@latest reactflow-mind-map -- --template react-ts

初期設定後、いくつかのパッケージをインストールする必要があります。

npm install reactflow zustand classcat nanoid

アプリケーションの状態管理にはZustandを使用しています。Reduxに似ていますが、はるかに小さく、記述するボイラープレートコードも少なくなります。React FlowもZustandを使用しているので、インストールに追加コストはかかりません。(このチュートリアルではTypeScriptを使用していますが、プレーンなJavaScriptを使用することもできます。)

シンプルにするために、すべてのコードをsrc/Appフォルダに配置します。そのためには、src/Appフォルダを作成し、次の内容を含むindexファイルを追加する必要があります。

src/App/index.tsx

import { ReactFlow, Controls, Panel } from '@xyflow/react';
 
// we have to import the React Flow styles for it to work
import '@xyflow/react/dist/style.css';
 
function Flow() {
  return (
    <ReactFlow>
      <Controls showInteractive={false} />
      <Panel position="top-left">React Flow Mind Map</Panel>
    </ReactFlow>
  );
}
 
export default Flow;

これはマインドマップをレンダリングするためのメインコンポーネントになります。まだノードやエッジはありませんが、React FlowのControlsコンポーネントと、アプリのタイトルを表示するためのPanelを追加しました。

React Flowフックを使用できるようにするには、main.tsx(viteのエントリファイル)でReactFlowProviderコンポーネントでアプリケーションをラップする必要があります。また、新しく作成したApp/index.tsxをインポートし、ReactFlowProvider内でレンダリングします。メインファイルは次のようになります。

src/main.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { ReactFlowProvider } from '@xyflow/react';
 
import App from './App';
 
import './index.css';
 
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <ReactFlowProvider>
      <App />
    </ReactFlowProvider>
  </React.StrictMode>,
);

React Flowコンポーネントの親コンテナには、正しく動作するために幅と高さが必要です。私たちのアプリはフルスクリーンアプリなので、これらのルールをindex.cssファイルに追加します。

src/index.css

body {
  margin: 0;
}
 
html,
body,
#root {
  height: 100%;
}

アプリのすべてのスタイルをindex.cssファイルに追加しています(Styled ComponentsTailwindのようなCSS-in-JSライブラリを使用することもできます)。これでnpm run devで開発サーバーを起動でき、次のようになります。

ノードとエッジのためのストア

前述のように、状態管理にはZustandを使用しています。そのため、src/Appフォルダにstore.tsという名前の新しいファイルを作成します。

src/App/store.ts

import {
  Edge,
  EdgeChange,
  Node,
  NodeChange,
  OnNodesChange,
  OnEdgesChange,
  applyNodeChanges,
  applyEdgeChanges,
} from '@xyflow/react';
import { createWithEqualityFn } from 'zustand/traditional';
 
export type RFState = {
  nodes: Node[];
  edges: Edge[];
  onNodesChange: OnNodesChange;
  onEdgesChange: OnEdgesChange;
};
 
const useStore = createWithEqualityFn<RFState>((set, get) => ({
  nodes: [
    {
      id: 'root',
      type: 'mindmap',
      data: { label: 'React Flow Mind Map' },
      position: { x: 0, y: 0 },
    },
  ],
  edges: [],
  onNodesChange: (changes: NodeChange[]) => {
    set({
      nodes: applyNodeChanges(changes, get().nodes),
    });
  },
  onEdgesChange: (changes: EdgeChange[]) => {
    set({
      edges: applyEdgeChanges(changes, get().edges),
    });
  },
}));
 
export default useStore;

多くのコードのように見えますが、ほとんどが型です ストアはノードとエッジを追跡し、変更イベントを処理します。ユーザーがノードをドラッグすると、React Flowは変更イベントを発生させ、ストアはその変更を適用し、更新されたノードがレンダリングされます。(これについては、状態管理ライブラリガイドで詳しく読むことができます。)

ご覧のように、'mindmap'タイプの初期ノードを{ x: 0, y: 0 }に配置して開始します。ストアをアプリに接続するには、useStoreフックを使用します。

src/App/index.tsx

import { ReactFlow, Controls, Panel, NodeOrigin } from '@xyflow/react';
import { shallow } from 'zustand/shallow';
 
import useStore, { RFState } from './store';
 
// we have to import the React Flow styles for it to work
import '@xyflow/react/dist/style.css';
 
const selector = (state: RFState) => ({
  nodes: state.nodes,
  edges: state.edges,
  onNodesChange: state.onNodesChange,
  onEdgesChange: state.onEdgesChange,
});
 
// this places the node origin in the center of a node
const nodeOrigin: NodeOrigin = [0.5, 0.5];
 
function Flow() {
  // whenever you use multiple values, you should use shallow to make sure the component only re-renders when one of the values changes
  const { nodes, edges, onNodesChange, onEdgesChange } = useStore(
    selector,
    shallow,
  );
 
  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      onNodesChange={onNodesChange}
      onEdgesChange={onEdgesChange}
      nodeOrigin={nodeOrigin}
      fitView
    >
      <Controls showInteractive={false} />
      <Panel position="top-left">React Flow Mind Map</Panel>
    </ReactFlow>
  );
}
 
export default Flow;

ストアからノード、エッジ、変更ハンドラにアクセスし、それらをReact Flowコンポーネントに渡します。fitViewプロップを使用して、初期ノードがビューの中央に配置されるようにし、ノードの原点を[0.5, 0.5]に設定して、ノードの中央に原点を設定します。これにより、アプリは次のようになります。

export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

読み取り専用

ノードを移動したり、ズームイン/アウトしたりできます。順調に進んでいます 今度は、さらに機能を追加しましょう。

カスタムノードとエッジ

ノードには「mindmap」というカスタムタイプを使用したいと考えています。そのためには、新しいコンポーネントを追加する必要があります。src/Appの下にMindMapNodeという新しいフォルダを作成し、次の内容のindexファイルを追加します。

src/App/MindMapNode/index.tsx

import { Handle, NodeProps, Position } from '@xyflow/react';
 
export type NodeData = {
  label: string;
};
 
function MindMapNode({ id, data }: NodeProps<NodeData>) {
  return (
    <>
      <input defaultValue={data.label} />
 
      <Handle type="target" position={Position.Top} />
      <Handle type="source" position={Position.Bottom} />
    </>
  );
}
 
export default MindMapNode;

マインドマップノードのラベル表示と編集のための入力欄、そしてノードを接続するための2つのハンドルを使用しています。これはReact Flowを動作させるために必要で、ハンドルはエッジの開始位置と終了位置として使用されます。

index.cssファイルにCSSを追加して、ノードの見栄えを少し良くしています。

src/index.css

.react-flow__node-mindmap {
  background: white;
  border-radius: 2px;
  border: 1px solid transparent;
  padding: 2px 5px;
  font-weight: 700;
}

(詳細については、ドキュメントのカスタムノードに関するガイドをご覧ください。)

カスタムエッジについても同様に行いましょう。src/Appの下にMindMapEdgeという新しいフォルダを作成し、その下にindexファイルを作成します。

src/App/MindMapEdge/index.tsx

import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react';
 
function MindMapEdge(props: EdgeProps) {
  const { sourceX, sourceY, targetX, targetY } = props;
 
  const [edgePath] = getStraightPath({
    sourceX,
    sourceY,
    targetX,
    targetY,
  });
 
  return <BaseEdge path={edgePath} {...props} />;
}
 
export default MindMapEdge;

カスタムノードとエッジについては、次のセクションで詳しく説明します。現時点では、Flowコンポーネントに以下を追加することで、アプリケーションで新しい型を使用できることが重要です。

import MindMapNode from './MindMapNode';
import MindMapEdge from './MindMapEdge';
 
const nodeTypes = {
  mindmap: MindMapNode,
};
 
const edgeTypes = {
  mindmap: MindMapEdge,
};

そして、新しく作成した型をReact Flowコンポーネントに渡します。

export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

読み取り専用

素晴らしい!入力欄をクリックして何かを入力することで、ノードのラベルを既に変更できます。

新しいノード

ユーザーが新しいノードを非常に簡単に作成できるようにしたいと考えています。ユーザーは、ノードをクリックして新しいノードを配置する位置までドラッグすることで、新しいノードを追加できるようにする必要があります。この機能はReact Flowに組み込まれていませんが、onConnectStartおよびonConnectEndハンドラーを使用して実装できます。

クリックされたノードを記憶するために開始ハンドラーを使用し、新しいノードを作成するために終了ハンドラーを使用しています。

src/App/index.tsxに追加

const connectingNodeId = useRef<string | null>(null);
 
const onConnectStart: OnConnectStart = useCallback((_, { nodeId }) => {
  connectingNodeId.current = nodeId;
}, []);
 
const onConnectEnd: OnConnectEnd = useCallback((event) => {
  // we only want to create a new node if the connection ends on the pane
  const targetIsPane = (event.target as Element).classList.contains(
    'react-flow__pane',
  );
 
  if (targetIsPane && connectingNodeId.current) {
    console.log(`add new node with parent node ${connectingNodeId.current}`);
  }
}, []);

ノードはストアによって管理されているため、新しいノードとそのエッジを追加するためのアクションを作成します。これがaddChildNodeアクションの見た目です。

src/store.tsへの新しいアクション

addChildNode: (parentNode: Node, position: XYPosition) => {
  const newNode = {
    id: nanoid(),
    type: 'mindmap',
    data: { label: 'New Node' },
    position,
    parentNode: parentNode.id,
  };
 
  const newEdge = {
    id: nanoid(),
    source: parentNode.id,
    target: newNode.id,
  };
 
  set({
    nodes: [...get().nodes, newNode],
    edges: [...get().edges, newEdge],
  });
};

渡されたノードを親として使用しています。通常、この機能はグループ化またはサブフローの実装に使用されます。ここでは、親ノードが移動されたときにすべての子ノードを移動するために使用します。これにより、すべての子ノードを手動で移動する必要がなくなり、マインドマップを整理して並べ替えることができます。onConnectEndハンドラーで新しいアクションを使用しましょう。

src/App/index.tsxの調整

const store = useStoreApi();
 
const onConnectEnd: OnConnectEnd = useCallback(
  (event) => {
    const { nodeLookup } = store.getState();
    const targetIsPane = (event.target as Element).classList.contains(
      'react-flow__pane',
    );
 
    if (targetIsPane && connectingNodeId.current) {
      const parentNode = nodeLookup.get(connectingNodeId.current);
      const childNodePosition = getChildNodePosition(event, parentNode);
 
      if (parentNode && childNodePosition) {
        addChildNode(parentNode, childNodePosition);
      }
    }
  },
  [getChildNodePosition],
);

まず、store.getState()を使用して、React FlowストアからnodeLookupを取得します。nodeLookupは、すべてのノードとその現在の状態を含むマップです。クリックされたノードの位置と寸法を取得するために必要です。次に、onConnectEndイベントのターゲットがReact Flowペインかどうかを確認します。その場合は、新しいノードを追加したいです。これにはaddChildNodeと新しく作成されたgetChildNodePositionヘルパー関数を使用します。

src/App/index.tsxのヘルパー関数

const getChildNodePosition = (event: MouseEvent, parentNode?: Node) => {
  const { domNode } = store.getState();
 
  if (
    !domNode ||
    // we need to check if these properites exist, because when a node is not initialized yet,
    // it doesn't have a positionAbsolute nor a width or height
    !parentNode?.computed?.positionAbsolute ||
    !parentNode?.computed?.width ||
    !parentNode?.computed?.height
  ) {
    return;
  }
 
  const panePosition = screenToFlowPosition({
    x: event.clientX,
    y: event.clientY,
  });
 
  // we are calculating with positionAbsolute here because child nodes are positioned relative to their parent
  return {
    x:
      panePosition.x -
      parentNode.computed?.positionAbsolute.x +
      parentNode.computed?.width / 2,
    y:
      panePosition.y -
      parentNode.computed?.positionAbsolute.y +
      parentNode.computed?.height / 2,
  };
};

この関数は、ストアに追加する新しいノードの位置を返します。project関数を使用して、スクリーン座標をReact Flow座標に変換します。前述のように、子ノードは親ノードを基準に配置されます。そのため、子ノードの位置から親ノードの位置を引く必要があります。多くの情報がありましたが、実際に見てみましょう。

export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

読み取り専用

新しい機能をテストするには、ハンドルから接続を開始し、ペインで終了します。マインドマップに新しいノードが追加されているのが確認できるはずです。

データの同期を維持

既にラベルを更新できますが、ノードのデータオブジェクトは更新していません。これは、アプリケーションを同期状態に保つために重要であり、たとえばノードをサーバーに保存する場合にも必要です。これを実現するために、ストアにupdateNodeLabelという新しいアクションを追加します。このアクションは、ノードIDとラベルを受け取ります。実装は非常に簡単です。既存のノードを反復処理し、渡されたラベルを使用して一致するノードを更新します。

src/store.ts

updateNodeLabel: (nodeId: string, label: string) => {
  set({
    nodes: get().nodes.map((node) => {
      if (node.id === nodeId) {
        // it's important to create a new object here, to inform React Flow about the changes
        node.data = { ...node.data, label };
      }
 
      return node;
    }),
  });
},

MindmapNodeコンポーネントで新しいアクションを使用しましょう。

src/App/MindmapNode/index.tsx

import { Handle, NodeProps, Position } from '@xyflow/react';
 
import useStore from '../store';
 
export type NodeData = {
  label: string;
};
 
function MindMapNode({ id, data }: NodeProps<NodeData>) {
  const updateNodeLabel = useStore((state) => state.updateNodeLabel);
 
  return (
    <>
      <input
        // from now on we can use value instead of defaultValue
        // this makes sure that the input always shows the current label of the node
        value={data.label}
        onChange={(evt) => updateNodeLabel(id, evt.target.value)}
        className="input"
      />
 
      <Handle type="target" position={Position.Top} />
      <Handle type="source" position={Position.Top} />
    </>
  );
}
 
export default MindMapNode;

素早くできました!カスタムノードの入力欄には、ノードの現在のラベルが表示されるようになりました。ノードデータを取得し、サーバーに保存してから再度読み込むことができます。

よりシンプルなUXと洗練されたスタイリング

機能的には、マインドマップアプリケーションは完成しました!新しいノードを追加し、ラベルを更新し、移動できます。しかし、UXとスタイリングは改善の余地があります。ノードのドラッグと新しいノードの作成を簡単にしましょう!

1. ノードをハンドルとして使用

デフォルトのハンドルを表示するのではなく、ノード全体をハンドルとして使用しましょう。これにより、新しい接続を開始できる領域が大きくなるため、ノードの作成が容易になります。ノードを接続するためにReact Flowは依然としてそれを必要としますが、ペインにエッジをドロップすることで新しいノードを作成しているため、表示する必要はありません。ターゲットハンドルを非表示にし、ノードの中央に配置するために、通常のCSSを使用します。

src/index.css

.react-flow__handle.target {
  top: 50%;
  pointer-events: none;
  opacity: 0;
}

ノード全体をハンドルにするために、ソースのスタイルも更新します。

src/index.css

.react-flow__handle.source {
  top: 0;
  left: 0;
  transform: none;
  background: #f6ad55;
  height: 100%;
  width: 100%;
  border-radius: 2px;
  border: none;
}
export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

読み取り専用

これは機能しますが、ソースハンドルがノード全体になり、入力欄を覆ってしまうため、ノードを移動できなくなります。dragHandleノードオプションを使用することで、ドラッグハンドルとして使用するDOM要素のセレクターを指定できます。これには、カスタムノードを少し調整します。

src/App/MindmapNode/index.tsx

import { Handle, NodeProps, Position } from '@xyflow/react';
 
import useStore from '../store';
 
export type NodeData = {
  label: string;
};
 
function MindMapNode({ id, data }: NodeProps<NodeData>) {
  const updateNodeLabel = useStore((state) => state.updateNodeLabel);
 
  return (
    <>
      <div className="inputWrapper">
        <div className="dragHandle">
          {/* icon taken from grommet https://icons.grommet.io */}
          <svg viewBox="0 0 24 24">
            <path
              fill="#333"
              stroke="#333"
              strokeWidth="1"
              d="M15 5h2V3h-2v2zM7 5h2V3H7v2zm8 8h2v-2h-2v2zm-8 0h2v-2H7v2zm8 8h2v-2h-2v2zm-8 0h2v-2H7v2z"
            />
          </svg>
        </div>
        <input
          value={data.label}
          onChange={(evt) => updateNodeLabel(id, evt.target.value)}
          className="input"
        />
      </div>
 
      <Handle type="target" position={Position.Top} />
      <Handle type="source" position={Position.Top} />
    </>
  );
}
 
export default MindMapNode;

クラス名inputWrapperのラッパーdivと、ドラッグハンドルとして機能するクラス名dragHandleのdivを追加します(驚き!)。これで、新しい要素のスタイルを設定できます。

src/index.css

.inputWrapper {
  display: flex;
  height: 20px;
  z-index: 1;
  position: relative;
}
 
.dragHandle {
  background: transparent;
  width: 14px;
  height: 100%;
  margin-right: 4px;
  display: flex;
  align-items: center;
}
 
.input {
  border: none;
  padding: 0 2px;
  border-radius: 1px;
  font-weight: 700;
  background: transparent;
  height: 100%;
  color: #222;
}
export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

読み取り専用

2. フォーカス時に入力欄をアクティブ化

ほぼ完成しましたが、さらにいくつかの詳細を調整する必要があります。ノードの中央から新しい接続を開始したいと考えています。これには、入力のポインターイベントを「none」に設定し、ユーザーがノードの上にボタンを離したかどうかを確認します。その場合にのみ、入力欄をアクティブ化します。onConnectEnd関数を使用してこれを実現できます。

src/App/index.tsx

const onConnectEnd: OnConnectEnd = useCallback(
  (event) => {
    const { nodeLookup } = store.getState();
    const targetIsPane = (event.target as Element).classList.contains(
      'react-flow__pane',
    );
    const node = (event.target as Element).closest('.react-flow__node');
 
    if (node) {
      node.querySelector('input')?.focus({ preventScroll: true });
    } else if (targetIsPane && connectingNodeId.current) {
      const parentNode = nodeLookup.get(connectingNodeId.current);
      const childNodePosition = getChildNodePosition(event, parentNode);
 
      if (parentNode && childNodePosition) {
        addChildNode(parentNode, childNodePosition);
      }
    }
  },
  [getChildNodePosition],
);

ご覧のように、ユーザーがノードの上にマウスボタンを離すと、入力欄にフォーカスします。フォーカスされた場合にのみ入力欄がアクティブ化されるように(pointerEvents: all)、スタイルを追加できます。

/* we want the connection line to be below the node */
.react-flow .react-flow__connectionline {
  z-index: 0;
}
 
/* pointer-events: none so that the click for the connection goes through */
.inputWrapper {
  display: flex;
  height: 20px;
  position: relative;
  z-index: 1;
  pointer-events: none;
}
 
/* pointer-events: all so that we can use the drag handle (here the user cant start a new connection) */
.dragHandle {
  background: transparent;
  width: 14px;
  height: 100%;
  margin-right: 4px;
  display: flex;
  align-items: center;
  pointer-events: all;
}
 
/* pointer-events: none by default */
.input {
  border: none;
  padding: 0 2px;
  border-radius: 1px;
  font-weight: 700;
  background: transparent;
  height: 100%;
  color: #222;
  pointer-events: none;
}
 
/* pointer-events: all when it's focused so that we can type in it */
.input:focus {
  border: none;
  outline: none;
  background: rgba(255, 255, 255, 0.25);
  pointer-events: all;
}
export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

読み取り専用

3. 動的な幅と自動フォーカス

ほぼ完了です!テキストの長さに基づいて、ノードの動的な幅を持たせたいと考えています。シンプルにするために、テキストの長さに基づいて計算を行います。

src/app/MindMapNode.tsxに追加された効果

useLayoutEffect(() => {
  if (inputRef.current) {
    inputRef.current.style.width = `${data.label.length * 8}px`;
  }
}, [data.label.length]);

また、作成直後にノードにフォーカス/アクティブ化させたいと考えています。

src/app/MindMapNode.tsxに追加された効果

useEffect(() => {
  setTimeout(() => {
    if (inputRef.current) {
      inputRef.current.focus({ preventScroll: true });
    }
  }, 1);
}, []);
export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

読み取り専用

ノードのラベルを調整すると、ノードの幅がそれに合わせて調整されます。新しいノードを作成することもでき、すぐにフォーカスされます。

4. 中央揃えのエッジとスタイリングの詳細

エッジが中央揃えではないことに気づいたかもしれません。最初にこれのためにカスタムエッジを作成しましたが、ハンドルの上部(デフォルトの動作)ではなく、ノードの中央からエッジが始まるように少し調整できます。

src/App/MindMapEdge.tsx

import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react';
 
function MindMapEdge(props: EdgeProps) {
  const { sourceX, sourceY, targetX, targetY } = props;
 
  const [edgePath] = getStraightPath({
    sourceX,
    sourceY: sourceY + 20,
    targetX,
    targetY,
  });
 
  return <BaseEdge path={edgePath} {...props} />;
}
 
export default MindMapEdge;

すべてのpropsをgetStraightPathヘルパー関数に渡していますが、ノードの中央になるようにsourceYを調整しています。

さらに、タイトルをもう少し控えめにして、背景の色を選択したいと考えています。これには、パネルの色(クラス名"header"を追加しました)とbody要素の背景色を調整することで実現できます。

body {
  margin: 0;
  background-color: #f8f8f8;
  height: 100%;
}
 
.header {
  color: #cdcdcd;
}

うまくいきました! 最終的なコードはこちらにあります。

export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

読み取り専用

最後に

素晴らしい旅でしたね!空のペインから始めて、完全に機能するマインドマップアプリを作成しました。さらに進めたい場合は、次の機能に取り組むことができます。

  • ペインをクリックして新しいノードを追加する
  • 現在の状態をローカルストレージに保存および復元するためのボタン
  • エクスポートおよびインポートUI
  • 共同編集

このチュートリアルを楽しんで、何か新しいことを学んでいただければ幸いです!ご質問やフィードバックがありましたら、Twitterでお気軽にご連絡いただくか、Discordサーバーに参加してください。React Flowは、ユーザーによって資金提供される独立系企業です。サポートしたい場合は、Githubでスポンサーになるか、Proプランのいずれかに登録することができます。

React Flow Proでは、Pro向けのサンプル、優先度の高いバグレポート、メンテナーからの1対1のサポートなどを利用できます。