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

このチュートリアルでは、ブレインストーミング、アイデアの整理、または視覚的な方法で思考をマッピングするために使用できる、シンプルなマインドマップツールをReact Flowで作成する方法を学習します。このアプリを構築するには、状態管理、カスタムノードとエッジなどを利用します。
デモの時間です!
実際に始める前に、このチュートリアルの最後に完成するマインドマッピングツールをご紹介します。
もし、すぐにコードに飛び込みたいのであれば、ソースコードはGithubで見つけることができます。
はじめに
このチュートリアルを行うには、ReactとReact 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 ComponentsやTailwindのような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]
に設定して、ノードの中央に原点を設定します。これにより、アプリは次のようになります。
ノードを移動したり、ズームイン/アウトしたりできます。順調に進んでいます
今度は、さらに機能を追加しましょう。カスタムノードとエッジ
ノードには「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コンポーネントに渡します。
素晴らしい!入力欄をクリックして何かを入力することで、ノードのラベルを既に変更できます。
新しいノード
ユーザーが新しいノードを非常に簡単に作成できるようにしたいと考えています。ユーザーは、ノードをクリックして新しいノードを配置する位置までドラッグすることで、新しいノードを追加できるようにする必要があります。この機能は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座標に変換します。前述のように、子ノードは親ノードを基準に配置されます。そのため、子ノードの位置から親ノードの位置を引く必要があります。多くの情報がありましたが、実際に見てみましょう。
新しい機能をテストするには、ハンドルから接続を開始し、ペインで終了します。マインドマップに新しいノードが追加されているのが確認できるはずです。
データの同期を維持
既にラベルを更新できますが、ノードのデータオブジェクトは更新していません。これは、アプリケーションを同期状態に保つために重要であり、たとえばノードをサーバーに保存する場合にも必要です。これを実現するために、ストアに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;
}
これは機能しますが、ソースハンドルがノード全体になり、入力欄を覆ってしまうため、ノードを移動できなくなります。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;
}
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;
}
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);
}, []);
ノードのラベルを調整すると、ノードの幅がそれに合わせて調整されます。新しいノードを作成することもでき、すぐにフォーカスされます。
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;
}
うまくいきました!
最終的なコードはこちらにあります。最後に
素晴らしい旅でしたね!空のペインから始めて、完全に機能するマインドマップアプリを作成しました。さらに進めたい場合は、次の機能に取り組むことができます。
- ペインをクリックして新しいノードを追加する
- 現在の状態をローカルストレージに保存および復元するためのボタン
- エクスポートおよびインポートUI
- 共同編集
このチュートリアルを楽しんで、何か新しいことを学んでいただければ幸いです!ご質問やフィードバックがありましたら、Twitterでお気軽にご連絡いただくか、Discordサーバーに参加してください。React Flowは、ユーザーによって資金提供される独立系企業です。サポートしたい場合は、Githubでスポンサーになるか、Proプランのいずれかに登録することができます。