2024/01/07

使用 React Flow 建立投影片簡報

Hayleigh Thompson
軟體工程師

我們最近發布了 2023 年 React Flow 年終調查的結果,並使用 React Flow 本身製作了一個互動式簡報,展示了主要發現。這個投影片應用程式內建了許多有用的功能,因此我們想分享我們是如何建立它的!

Screenshot of slides layed out on an infinite canvas, each with information pulled from a survey of React Flow users
我們的 2023 年終調查應用程式由許多靜態節點和用於在它們之間導覽的按鈕組成。

在本教學結束時,您將建立一個具有以下功能的簡報應用程式

  • 支援 Markdown 投影片
  • 使用鍵盤在視窗周圍導覽
  • 自動配置
  • 點擊拖曳平移導覽(類似 Prezi)

在此過程中,您將學習一些關於配置演算法、建立靜態流程和自訂節點的基本知識。

完成後,應用程式會像這樣!

要跟著本教學進行,我們假設您對ReactReact Flow有基本的了解,但如果您在過程中遇到困難,請隨時在Discord上與我們聯繫!

如果您想跳過或在過程中參考,這是包含最終程式碼的 repo

讓我們開始吧!

設定專案

我們建議在開始新的 React Flow 專案時使用Vite,這次我們也會使用 TypeScript。您可以使用以下命令來建立新的專案

npm create vite@latest -- --template react-ts

如果您想使用 JavaScript 進行,請隨意使用 react 範本。您也可以使用我們的 codesandbox 範本在瀏覽器中進行

除了 React Flow 之外,我們只需要引入一個相依性,react-remark,以幫助我們在投影片中渲染 Markdown。

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 框架或樣式解決方案。如果您要以不同於僅撰寫 CSS 的方式設定應用程式的樣式,例如使用Styled ComponentsTailwind 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 hook)!當 nodeTypes 物件變更時,整個流程會重新渲染。

將基本功能組合在一起後,您可以執行 npm run dev 來啟動開發伺服器,並看到以下內容

還不是很令人興奮,但讓我們加入 Markdown 渲染,並並排建立幾個投影片!

渲染 Markdown

我們希望讓您輕鬆地將內容新增至投影片,因此我們希望能夠在投影片中撰寫Markdown。如果您不熟悉,Markdown 是一種用於建立格式化文字文件的簡單標記語言。如果您曾經在 GitHub 上撰寫過 README,您就已經使用過 Markdown!

多虧了我們稍早安裝的 react-remark 套件,此步驟非常簡單。我們可以使用 <Remark /> 元件將 Markdown 內容字串渲染到我們的投影片中。

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 /> 元件,來儲存要顯示的 Markdown 內容。我們可以更新硬編碼的節點,加入一些 Markdown 內容,來看看實際效果。

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}
  />;
}

請注意,我們已將 minZoom 屬性新增到 <ReactFlow /> 元件。我們的投影片相當大,預設的最小縮放等級不足以縮小並一次查看多張投影片。

在上面的 nodes 陣列中,我們透過使用 SLIDE_WIDTH 常數進行一些手動計算,來確保投影片之間有間隔。在下一節中,我們將提出一種演算法來自動以網格形式佈局投影片。

佈局節點

我們經常被問到如何在流程中自動佈局節點,並且我們在佈局指南中提供了一些關於如何使用像 dagre 和 d3-hierarchy 這樣的常見佈局程式庫的文件。在這裡,你將編寫你自己的超簡單佈局演算法,這有點像在鑽研學術,但請堅持下去!

對於我們的簡報應用程式,我們將從 0,0 開始構建一個簡單的網格佈局,並且每當有新的投影片在左、右、上或下方時,更新 x 或 y 座標。

首先,我們需要更新我們的 SlideData 類型,以包含目前投影片左側、右側、上方和下方投影片的可選 ID。

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_WIDTHSLIDE_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>
}

唯讀

如果你花一些時間玩這個演示,你可能會遇到這個演算法的兩個限制

  1. 有可能建構一個使兩個投影片在相同位置重疊的佈局。

  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 Hook 在元件內部定義內容,但只計算一次。

由於我們現在有了「初始」投影片的概念,我們也使用 fitViewOptions 來確保初始投影片是在首次載入畫布時聚焦的投影片。

到目前為止,我們已將簡報以網格形式佈局,但我們必須手動平移畫布才能查看每張投影片,這對於簡報而言並不實際!我們將新增三種不同的方式來在投影片之間導覽

  • 點擊以聚焦節點,透過點擊節點跳轉到不同的投影片。

  • 每張投影片上的導覽按鈕,用於在任何有效方向上依序移動投影片。

  • 使用方向鍵進行鍵盤導覽,以在簡報中移動,而無需使用滑鼠或直接與投影片互動。

點擊時聚焦

<ReactFlow /> 元素可以接收一個 onNodeClick 回呼,該回呼會在點擊任何節點時觸發。除了滑鼠事件本身之外,我們還會收到一個對點擊節點的參考,並且我們可以利用 fitView 方法來平移畫布。

fitView 是 React Flow 實例上的方法,我們可以透過使用 useReactFlow Hook 來存取它。

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}
    />
  );
}
💡

請務必記得將 fitView 包含在我們的 handleNodeClick 回呼的相依性陣列中。這是因為 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.dev.org.tw/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 的一些實用功能

  • 使用 useReactFlow Hook 來存取 fitView 方法。

  • 使用 onNodeClick 事件處理程序來監聽流程中每個節點上的點擊。

  • 使用 onKeyPress 事件處理程序來監聽整個畫布上的鍵盤事件。

我們還研究了如何自己實作一個簡單的版面配置演算法。版面配置是我們經常被問到的問題,但是如果您的需求不那麼複雜,您可以用自己的解決方案取得相當大的進展!

如果您正在尋找擴展此專案的想法,您可以嘗試解決我們指出的版面配置演算法問題、設計一個具有不同版面配置的更複雜的 Slide 元件,或完全不同的東西。

您可以使用完成的原始程式碼作為起點,或者您可以繼續在我們今天製作的基礎上進行建構。我們很樂意看到您建構的東西,所以請在我們的 Discord 伺服器Twitter 上與我們分享。

透過 React Flow Pro 獲得專業範例、優先錯誤報告、維護人員的一對一支援等等