2024/01/07

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

ヘイリー・トンプソン
ソフトウェアエンジニア

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

Screenshot of slides layed out on an infinite canvas, each with information pulled from a survey of React Flow users
2023年末の調査アプリは、多くの静的なノードと、それらの間を移動するためのボタンで構成されていました。

このチュートリアルの最後までに、以下の機能を備えたプレゼンテーションアプリを作成します。

  • マークダウンスライドのサポート
  • ビューポート内でのキーボードナビゲーション
  • 自動レイアウト
  • クリック&ドラッグによるパンナビゲーション(Prezi風)

その過程で、レイアウトアルゴリズムの基本、静的フローの作成、カスタムノードについて少し学習します。

完了したら、アプリは次のようになります!

このチュートリアルに従うには、ReactReact 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インスタンスにアクセスできるようにします。

main.tsx
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 ComponentsTailwind CSSなど、CSSを書く以外の方法でアプリをスタイル設定する場合は、index.cssへのインポートをスキップできます。

💡

アプリのスタイル設定方法は任意ですが、React Flowのスタイルは**必ず**含める必要があります!デフォルトのスタイルが必要ない場合は、最低限reactflow/dist/base.cssの基本スタイルを含める必要があります。

プレゼンテーションのスライドごとにキャンバス上のノードを作成しますので、各スライドのレンダリングに使用されるカスタムノードである新しいファイルSlide.tsxを作成しましょう。

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型もスタブ化して、コンポーネントのプロップを適切に型指定できるようにしています。

最後に、新しいカスタムノードを登録して、画面に何かを表示します。

App.tsx
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 />コンポーネントを使用して、マークダウンコンテンツの文字列をスライドにレンダリングできます。

Slide.tsx
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 />コンポーネントに渡すことで、表示するマークダウンコンテンツを保存しています。いくつかのマークダウンコンテンツでハードコードされたノードを更新して、実際に動作を確認してみましょう。

App.tsx
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型を更新する必要があります。

Slide.tsx
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というファイルに、以下を追加します。

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'を含めて、レイアウトがどのように更新されるかを確認してみてください。

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

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

読み取り専用

このデモを少し時間をかけて試してみると、このアルゴリズムの2つの制限に気付くでしょう。

  1. 2つのスライドが同じ位置に重なるレイアウトを作成することが可能です。

  2. アルゴリズムは、初期スライドから到達できないノードを無視します。

これらの欠点を修正することは可能ですが、このチュートリアルの範囲を超えています。試行錯誤された方は、Discordサーバーであなたの解決策を共有してください!

レイアウトアルゴリズムを作成したので、App.tsxに戻り、新しいslidesToElements関数を使用してハードコードされたノード配列を削除できます。

App.tsx
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フックを使用してアクセスできます。

App.tsx
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コールバックも事前に作成します。

Slide.tsx
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プロパティのみが必要であることがわかります。

https://reactflow.dokyumento.jp/api-reference/types/fit-view-options
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はそれをどのように処理するかを認識します!

Slide.tsx
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>
  );
}
export default function App() {
  const data: string = "world"

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

読み取り専用

キーボードナビゲーション

最後のピースは、プレゼンテーションにキーボードナビゲーションを追加することです。常にスライドをクリックして次のスライドに移動する必要があるのは不便なので、キーボードショートカットを追加して簡単にします。React Flowを使用すると、onKeyDownのようなハンドラーを介して<ReactFlow />コンポーネントのキーボードイベントをリッスンできます。

これまで、現在フォーカスされているスライドはキャンバスの位置によって暗黙的に決定されていましたが、キャンバス全体でキーを押下を処理するには、現在のスライドを明示的に追跡する必要があります。これは、矢印キーが押されたときにどのスライドに移動するかを知る必要があるためです!

App.tsx
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`を追加し、ノードがクリックされるたびに更新するようにしています。次に、キャンバス上のキーボードイベントを処理するためのコールバックを作成します。

App.tsx
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で共有してください。

React Flow Proでは、Pro向けの例、優先的なバグレポート、メンテナによる1対1のサポートなどを提供しています。