2024/01/07
React Flowを用いたスライドショープレゼンテーションの作成

最近、React Flow 2023年末調査の結果を、React Flow自体を使った主要な調査結果のインタラクティブなプレゼンテーションで公開しました。このスライドショーアプリには多くの便利な機能が組み込まれているため、その作成方法を共有したいと思います!

このチュートリアルの最後までに、以下の機能を備えたプレゼンテーションアプリを作成します。
- マークダウンスライドのサポート
- ビューポート内でのキーボードナビゲーション
- 自動レイアウト
- クリック&ドラッグによるパンナビゲーション(Prezi風)
その過程で、レイアウトアルゴリズムの基本、静的フローの作成、カスタムノードについて少し学習します。
完了したら、アプリは次のようになります!
このチュートリアルに従うには、ReactとReact Flowの基本的な理解があることを前提としていますが、途中で詰まった場合は、Discordでお気軽にご連絡ください!
先に進みたい場合、または参照したい場合は、最終コードを含むリポジトリがあります。
始めましょう!
プロジェクトの設定
新しいReact Flowプロジェクトを開始する際には、Viteの使用をお勧めします。今回はTypeScriptも使用します。次のコマンドを使用して、新しいプロジェクトをスキャフォールディングできます。
npm create vite@latest -- --template react-ts
JavaScriptで進めたい場合は、react
テンプレートを使用してください。また、Codesandboxテンプレートを使用してブラウザで進めることもできます。
React Flowに加えて、マークダウンをスライドにレンダリングするのに役立つ依存関係react-remark
を1つだけ取り込む必要があります。
npm install reactflow react-remark
生成されたmain.tsx
を修正してReact Flowのスタイルを含め、アプリを<ReactFlowProvider />
でラップして、コンポーネント内でReact Flowインスタンスにアクセスできるようにします。
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ReactFlowProvider } from '@xyflow/react';
import App from './App';
import 'reactflow/dist/style.css';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ReactFlowProvider>
{/* The parent element of the React Flow component needs a width and a height
to work properly. If you're styling your app as you follow along, you
can remove this div and apply styles to the #root element in your CSS.
*/}
<div style={{ width: '100vw', height: '100vh' }}>
<App />
</div>
</ReactFlowProvider>
</React.StrictMode>,
);
このチュートリアルではアプリのスタイルについては簡単に説明しますので、お使い慣れたCSSフレームワークまたはスタイルソリューションを使用してください。Styled ComponentsやTailwind CSSなど、CSSを書く以外の方法でアプリをスタイル設定する場合は、index.css
へのインポートをスキップできます。
アプリのスタイル設定方法は任意ですが、React Flowのスタイルは**必ず**含める必要があります!デフォルトのスタイルが必要ない場合は、最低限reactflow/dist/base.css
の基本スタイルを含める必要があります。
プレゼンテーションのスライドごとにキャンバス上のノードを作成しますので、各スライドのレンダリングに使用されるカスタムノードである新しいファイルSlide.tsx
を作成しましょう。
import { type Node, type NodeProps } from '@xyflow/react';
export const SLIDE_WIDTH = 1920;
export const SLIDE_HEIGHT = 1080;
export type SlideNode = Node<SlideData, 'slide'>;
export type SlideData = {};
const style = {
width: `${SLIDE_WIDTH}px`,
height: `${SLIDE_HEIGHT}px`,
} satisfies React.CSSProperties;
export function Slide({ data }: NodeProps<SlideNode>) {
return (
<article className="slide nodrag" style={style}>
<div>Hello, React Flow!</div>
</article>
);
}
ここでは、後でこれらの寸法にアクセスする必要があるため、ノードをCSSでスタイル設定するのではなく、スライドの幅と高さを定数として設定しています。SlideData
型もスタブ化して、コンポーネントのプロップを適切に型指定できるようにしています。
最後に、新しいカスタムノードを登録して、画面に何かを表示します。
import { ReactFlow } from '@xyflow/react';
import { Slide } from './Slide.tsx';
const nodeTypes = {
slide: Slide,
};
export default export default function App() {
const nodes = [
{ id: '0', type: 'slide', position: { x: 0, y: 0 }, data: {} },
];
return <ReactFlow nodes={nodes} nodeTypes={nodeTypes} fitView />;
}
nodeTypes
オブジェクトはコンポーネントの**外側**で定義する(またはReactのuseMemo
フックを使用する)ことを忘れないでください!nodeTypes
オブジェクトが変更されると、フロー全体が再レンダリングされます。
npm run dev
を実行して開発サーバーを起動すると、次のようになります。
まだそれほどエキサイティングではありませんが、マークダウンのレンダリングを追加して、いくつかのスライドを並べて作成しましょう!
マークダウンのレンダリング
スライドへのコンテンツの追加を容易にするために、スライドにマークダウンを記述できるようにします。ご存じない方のために説明すると、マークダウンは、書式付きテキストドキュメントを作成するための簡単なマークアップ言語です。GitHubでREADMEを書いたことがある方は、マークダウンを使用しています!
前にインストールしたreact-remark
パッケージのおかげで、この手順は簡単です。<Remark />
コンポーネントを使用して、マークダウンコンテンツの文字列をスライドにレンダリングできます。
import { type Node, type NodeProps } from '@xyflow/react';
import { Remark } from 'react-remark';
export const SLIDE_WIDTH = 1920;
export const SLIDE_HEIGHT = 1080;
export type SlideNode = Node<SlideData, 'slide'>;
export type SlideData = {
source: string;
};
const style = {
width: `${SLIDE_WIDTH}px`,
height: `${SLIDE_HEIGHT}px`,
} satisfies React.CSSProperties;
export function Slide({ data }: NodeProps<SlideNode>) {
return (
<article className="slide nodrag" style={style}>
<Remark>{data.source}</Remark>
</article>
);
}
React Flowでは、ノードにレンダリング時に使用できるデータが保存されています。ここでは、SlideData
型にsource
プロパティを追加し、それを<Remark />
コンポーネントに渡すことで、表示するマークダウンコンテンツを保存しています。いくつかのマークダウンコンテンツでハードコードされたノードを更新して、実際に動作を確認してみましょう。
import { ReactFlow } from '@xyflow/react';
import { Slide, SLIDE_WIDTH } from './Slide';
const nodeTypes = {
slide: Slide,
};
export default export default function App() {
const nodes = [
{
id: '0',
type: 'slide',
position: { x: 0, y: 0 },
data: { source: '# Hello, React Flow!' },
},
{
id: '1',
type: 'slide',
position: { x: SLIDE_WIDTH, y: 0 },
data: { source: '...' },
},
{
id: '2',
type: 'slide',
position: { x: SLIDE_WIDTH * 2, y: 0 },
data: { source: '...' },
},
];
return <ReactFlow
nodes={nodes}
nodeTypes={nodeTypes}
fitView
minZoom={0.1}
/>;
}
<ReactFlow />
コンポーネントにminZoom
プロップを追加したことに注意してください。スライドは非常に大きく、デフォルトの最小ズームレベルでは、複数のスライドを一度に表示するのに十分ではありません。
上記のノード配列では、SLIDE_WIDTH
定数を使用して手動で計算を行い、スライドを適切に配置しています。次のセクションでは、スライドをグリッドに自動的に配置するアルゴリズムを考案します。
ノードのレイアウト
フロー内でノードを自動的にレイアウトする方法について、よくあるご質問をいただいております。dagreやd3-hierarchyといった一般的なレイアウトライブラリの使用方法については、レイアウトガイドで説明しています。ここでは、非常にシンプルなレイアウトアルゴリズムを自分で記述します。少し高度な内容になりますが、最後までお付き合いください!
プレゼンテーションアプリでは、0,0から開始し、左、右、上、下の新しいスライドが追加されるたびにx座標またはy座標を更新することで、シンプルなグリッドレイアウトを作成します。
まず、現在のスライドの左、右、上、下のスライドのオプションのIDを含めるようにSlideData
型を更新する必要があります。
export type SlideData = {
source: string;
left?: string;
up?: string;
down?: string;
right?: string;
};
この情報をノードデータに直接保存することで、いくつかの利点があります。
-
ノードとエッジの概念を気にすることなく、完全に宣言的なスライドを作成できます。
-
接続されたスライドを巡回することで、プレゼンテーションのレイアウトを計算できます。
-
各スライドにナビゲーションボタンを追加して、スライド間を自動的に移動できるようにします。これは後のステップで処理します。
slidesToElements
という関数で魔法が起こります。この関数は、IDでアドレス指定されたすべてのスライドを含むオブジェクトと、開始するスライドのIDを受け取ります。その後、各接続されたスライドを処理して、<ReactFlow />
コンポーネントに渡すことができるノードとエッジの配列を作成します。
アルゴリズムは次のようになります。
-
初期スライドのIDと位置
{ x: 0, y: 0 }
をスタックにプッシュします。 -
スタックが空でない間…
-
現在の位置とスライドIDをスタックからポップします。
-
IDでスライドデータを探します。
-
現在のID、位置、スライドデータを使用して、新しいノードをノード配列にプッシュします。
-
訪問済みスライドのセットにスライドのIDを追加します。
-
すべての向き(左、右、上、下)について…
-
スライドがすでに訪問済みでないことを確認します。
-
現在の位置を取得し、向きに応じて
SLIDE_WIDTH
またはSLIDE_HEIGHT
を加算または減算することで、x座標またはy座標を更新します。 -
新しい位置と新しいスライドのIDをスタックにプッシュします。
-
現在のスライドと新しいスライドを接続する新しいエッジをエッジ配列にプッシュします。
-
残りの向きについても繰り返します…
-
-
すべてが計画どおりに進めば、下記に示すスライドのスタックを、きれいにレイアウトされたグリッドに変換できるはずです!

コードを見てみましょう。slides.ts
というファイルに、以下を追加します。
import { SlideData, SLIDE_WIDTH, SLIDE_HEIGHT } from './Slide';
export const slidesToElements = (
initial: string,
slides: Record<string, SlideData>,
) => {
// Push the initial slide's id and the position `{ x: 0, y: 0 }` onto a stack.
const stack = [{ id: initial, position: { x: 0, y: 0 } }];
const visited = new Set();
const nodes = [];
const edges = [];
// While that stack is not empty...
while (stack.length) {
// Pop the current position and slide id off the stack.
const { id, position } = stack.pop();
// Look up the slide data by id.
const data = slides[id];
const node = { id, type: 'slide', position, data };
// Push a new node onto the nodes array with the current id, position, and slide
// data.
nodes.push(node);
// add the slide's id to a set of visited slides.
visited.add(id);
// For every direction (left, right, up, down)...
// Make sure the slide has not already been visited.
if (data.left && !visited.has(data.left)) {
// Take the current position and update the x or y coordinate by adding or
// subtracting `SLIDE_WIDTH` or `SLIDE_HEIGHT` depending on the direction.
const nextPosition = {
x: position.x - SLIDE_WIDTH,
y: position.y,
};
// Push the new position and the new slide's id onto a stack.
stack.push({ id: data.left, position: nextPosition });
// Push a new edge onto the edges array connecting the current slide to the
// new slide.
edges.push({ id: `${id}->${data.left}`, source: id, target: data.left });
}
// Repeat for the remaining directions...
}
return { nodes, edges };
};
簡潔にするため、右、上、下の方向のコードは省略していますが、ロジックは各方向で同じです。コードを分かりやすくするために、アルゴリズムの同じ分解をコメントとして含めています。
以下は、レイアウトアルゴリズムのデモアプリです。slides
オブジェクトを編集して、異なる方向にスライドを追加するとレイアウトがどのように変わるかを確認できます。たとえば、4のデータにdown: '5'
を含めて、レイアウトがどのように更新されるかを確認してみてください。
このデモを少し時間をかけて試してみると、このアルゴリズムの2つの制限に気付くでしょう。
-
2つのスライドが同じ位置に重なるレイアウトを作成することが可能です。
-
アルゴリズムは、初期スライドから到達できないノードを無視します。
これらの欠点を修正することは可能ですが、このチュートリアルの範囲を超えています。試行錯誤された方は、Discordサーバーであなたの解決策を共有してください!
レイアウトアルゴリズムを作成したので、App.tsx
に戻り、新しいslidesToElements
関数を使用してハードコードされたノード配列を削除できます。
import { ReactFlow } from '@xyflow/react';
import { slidesToElements } from './slides';
import { Slide, SlideData, SLIDE_WIDTH } from './Slide';
const slides: Record<string, SlideData> = {
'0': { source: '# Hello, React Flow!', right: '1' },
'1': { source: '...', left: '0', right: '2' },
'2': { source: '...', left: '1' },
};
const nodeTypes = {
slide: Slide,
};
const initialSlide = '0';
const { nodes, edges } = slidesToElements(initialSlide, slides);
export default export default function App() {
return (
<ReactFlow
nodes={nodes}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ nodes: [{ id: initialSlide }] }}
minZoom={0.1}
/>
);
}
フロー内のスライドは静的なので、コンポーネントが再レンダリングされた場合にレイアウトを再計算しないように、slidesToElements
の呼び出しをコンポーネントの外側に移動できます。あるいは、ReactのuseMemo
フックを使用してコンポーネント内で定義し、一度だけ計算することもできます。
今では「初期」スライドの概念があるため、fitViewOptions
を使用して、キャンバスが最初に読み込まれたときにフォーカスされるスライドが初期スライドになるようにしています。
スライド間の移動
これまでのところ、プレゼンテーションはグリッド状にレイアウトされていますが、各スライドを見るためにキャンバスを手動でパンする必要があり、プレゼンテーションとしてはあまり実用的ではありません!スライド間を移動する3つの異なる方法を追加します。
-
ノードをクリックしてフォーカスすることで、異なるスライドにジャンプします。
-
各スライドにナビゲーションボタンを追加して、有効な方向のいずれかでスライド間を順番に移動します。
-
矢印キーを使用してキーボードナビゲーションを行い、マウスを使用したり、スライドと直接やり取りすることなくプレゼンテーションを移動します。
クリックによるフォーカス
<ReactFlow />
要素は、任意のノードがクリックされたときに発生するonNodeClick
コールバックを受け取ることができます。マウスイベント自体に加えて、クリックされたノードへの参照も受け取ります。そして、fitView
メソッドのおかげで、それを利用してキャンバスをパンすることができます。
fitView
はReact Flowインスタンスのメソッドであり、useReactFlow
フックを使用してアクセスできます。
import { useCallback } from 'react';
import { ReactFlow, useReactFlow, type NodeMouseHandler } from '@xyflow/react';
import { Slide, SlideData, SLIDE_WIDTH } from './Slide';
const slides: Record<string, SlideData> = {
...
}
const nodeTypes = {
slide: Slide,
};
const initialSlide = '0';
const { nodes, edges } = slidesToElements(initialSlide, slides);
export default function App() {
const { fitView } = useReactFlow();
const handleNodeClick = useCallback<NodeMouseHandler>(
(_, node) => {
fitView({ nodes: [node], duration: 150 });
},
[fitView],
);
return (
<ReactFlow
...
fitViewOptions={{ nodes: [{ id: initialSlide }] }}
onNodeClick={handleNodeClick}
/>
);
}
handleNodeClick
コールバックの依存関係配列にfitView
を含めることを忘れないでください。fitView
関数は、React Flowがビューポートを初期化した後に置き換えられるためです。この手順を忘れると、handleNodeClick
が何も機能しないことがわかります(そして、私たち自身も時々これを忘れます )。
引数なしでfitView
を呼び出すと、グラフ内のすべてのノードをビューに収めようとしますが、クリックされたノードのみにフォーカスしたいだけです。FitViewOptions
オブジェクトを使用すると、フォーカスしたいノードのみの配列を提供できます。この場合は、クリックされたノードだけです。
スライドコントロール
ノードをクリックしてフォーカスする機能は、全体像を確認してから特定のスライドにフォーカスに戻すのに便利ですが、プレゼンテーションを移動するのに非常に実用的な方法ではありません。このステップでは、接続されたスライドに任意の方向に移動できるコントロールを各スライドに追加します。
接続されたスライドがある方向にボタンを条件付きでレンダリングする<footer>
を各スライドに追加します。また、後で使用するmoveToNextSlide
コールバックも事前に作成します。
import { type NodeProps, fitView } from '@xyflow/react';
import { Remark } from 'react-remark';
import { useCallback } from 'react';
...
export function Slide({ data }: NodeProps<SlideNide>) {
const moveToNextSlide = useCallback((id: string) => {}, []);
return (
<article className="slide nodrag" style={style}>
<Remark>{data.source}</Remark>
<footer className="slide__controls nopan">
{data.left && (<button onClick={() => moveToNextSlide(data.left)}>←</button>)}
{data.up && (<button onClick={() => moveToNextSlide(data.up)}>↑</button>)}
{data.down && (<button onClick={() => moveToNextSlide(data.down)}>↓</button>)}
{data.right && (<button onClick={() => moveToNextSlide(data.right)}>→</button>)}
</footer>
</article>
);
}
フッターは自由にスタイル設定できますが、ボタンとやり取りする際にキャンバスがパンされないように、"nopan"
クラスを追加することが重要です。
moveToSlide
を実装するには、再びfitView
を使用します。以前はfitView
に渡すために実際にクリックされたノードへの参照がありましたが、今回はノードのIDしかありません。ターゲットノードをIDで検索しようとするかもしれませんが、実際にはそれは必要ありません!FitViewOptions
の型を見ると、渡すノードの配列にはid
プロパティのみが必要であることがわかります。
export type FitViewOptions = {
padding?: number;
includeHiddenNodes?: boolean;
minZoom?: number;
maxZoom?: number;
duration?: number;
nodes?: (Partial<Node> & { id: Node['id'] })[];
};
Partial<Node>
は、Node
オブジェクト型のすべてのフィールドがオプションとしてマークされ、次に{ id: Node['id'] }
と交差してid
フィールドが常に必須になるようにします。つまり、id
プロパティのみを持つオブジェクトを渡すだけで、fitView
はそれをどのように処理するかを認識します!
import { type NodeProps, useReactFlow } from '@xyflow/react';
export function Slide({ data }: NodeProps<SlideNide>) {
const { fitView } = useReactFlow();
const moveToNextSlide = useCallback(
(id: string) => fitView({ nodes: [{ id }] }),
[fitView],
);
return (
<article className="slide" style={style}>
...
</article>
);
}
キーボードナビゲーション
最後のピースは、プレゼンテーションにキーボードナビゲーションを追加することです。常にスライドをクリックして次のスライドに移動する必要があるのは不便なので、キーボードショートカットを追加して簡単にします。React Flowを使用すると、onKeyDown
のようなハンドラーを介して<ReactFlow />
コンポーネントのキーボードイベントをリッスンできます。
これまで、現在フォーカスされているスライドはキャンバスの位置によって暗黙的に決定されていましたが、キャンバス全体でキーを押下を処理するには、現在のスライドを明示的に追跡する必要があります。これは、矢印キーが押されたときにどのスライドに移動するかを知る必要があるためです!
import { useState, useCallback } from 'react';
import { ReactFlow, useReactFlow } from '@xyflow/react';
import { Slide, SlideData, SLIDE_WIDTH } from './Slide';
const slides: Record<string, SlideData> = {
...
}
const nodeTypes = {
slide: Slide,
};
const initialSlide = '0';
const { nodes, edges } = slidesToElements(initialSlide, slides)
export default function App() {
const [currentSlide, setCurrentSlide] = useState(initialSlide);
const { fitView } = useReactFlow();
const handleNodeClick = useCallback<NodeMouseHandler>(
(_, node) => {
fitView({ nodes: [node] });
setCurrentSlide(node.id);
},
[fitView],
);
return (
<ReactFlow
...
onNodeClick={handleNodeClick}
/>
);
}
ここでは、フローコンポーネントに状態変数`currentSlide`を追加し、ノードがクリックされるたびに更新するようにしています。次に、キャンバス上のキーボードイベントを処理するためのコールバックを作成します。
export default function App() {
const [currentSlide, setCurrentSlide] = useState(initialSlide);
const { fitView } = useReactFlow();
...
const handleKeyPress = useCallback<KeyboardEventHandler>(
(event) => {
const slide = slides[currentSlide];
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowRight':
const direction = event.key.slice(5).toLowerCase();
const target = slide[direction];
if (target) {
event.preventDefault();
setCurrentSlide(target);
fitView({ nodes: [{ id: target }] });
}
}
},
[currentSlide, fitView],
);
return (
<ReactFlow
...
onKeyPress={handleKeyPress}
/>
);
}
入力の手間を省くため、押されたキーから方向を抽出します。ユーザーが`'ArrowLeft'`を押した場合は`'left'`を取得し、同様です。そして、その方向に実際に接続されているスライドがあれば、現在のスライドを更新し、`fitView`を呼び出してそのスライドに移動します!
また、矢印キーのデフォルト動作を無効化することで、ウィンドウの上下スクロールを防いでいます。このチュートリアルでは、キャンバスがページの一部に過ぎないため、これが必要ですが、キャンバスがビューポート全体であるアプリでは、この処理は不要かもしれません。
これで全てです!最後に結果を確認し、学んだことを振り返ってみましょう。
最終的な考察
次のPreziを作る予定がなくても、このチュートリアルではReact Flowのいくつかの便利な機能を見てきました。
-
`fitView`メソッドにアクセスするための
useReactFlow
フック。 -
フロー内のすべてのノードのクリックを検知するための
onNodeClick
イベントハンドラー。 -
キャンバス全体のキーボードイベントを検知するための
onKeyPress
イベントハンドラー。
また、シンプルなレイアウトアルゴリズムを独自に実装する方法についても見てきました。レイアウトは、非常に頻繁に寄せられる質問ですが、ニーズがそれほど複雑でない場合は、独自のソリューションでかなりうまくいくことができます!
このプロジェクトを拡張する方法を探している場合は、レイアウトアルゴリズムで指摘した問題に対処したり、より洗練された`Slide`コンポーネントを異なるレイアウトで作成したり、全く別のことを試すことができます。
完成したソースコードを起点として使用することも、今日作成したものを基に構築し続けることもできます。皆さんによる作品を見るのが楽しみです。ぜひ、DiscordサーバーまたはTwitterで共有してください。