2024/01/07
使用 React Flow 建立投影片簡報

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

在本教學結束時,您將建立一個具有以下功能的簡報應用程式
- 支援 Markdown 投影片
- 使用鍵盤在視窗周圍導覽
- 自動配置
- 點擊拖曳平移導覽(類似 Prezi)
在此過程中,您將學習一些關於配置演算法、建立靜態流程和自訂節點的基本知識。
完成後,應用程式會像這樣!
要跟著本教學進行,我們假設您對React和React 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 實例;
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 Components或Tailwind 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
hook)!當 nodeTypes
物件變更時,整個流程會重新渲染。
將基本功能組合在一起後,您可以執行 npm run dev
來啟動開發伺服器,並看到以下內容
還不是很令人興奮,但讓我們加入 Markdown 渲染,並並排建立幾個投影片!
渲染 Markdown
我們希望讓您輕鬆地將內容新增至投影片,因此我們希望能夠在投影片中撰寫Markdown。如果您不熟悉,Markdown 是一種用於建立格式化文字文件的簡單標記語言。如果您曾經在 GitHub 上撰寫過 README,您就已經使用過 Markdown!
多虧了我們稍早安裝的 react-remark
套件,此步驟非常簡單。我們可以使用 <Remark />
元件將 Markdown 內容字串渲染到我們的投影片中。
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 內容,來看看實際效果。
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。
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'
,並查看佈局如何更新。
如果你花一些時間玩這個演示,你可能會遇到這個演算法的兩個限制
-
有可能建構一個使兩個投影片在相同位置重疊的佈局。
-
演算法會忽略從初始投影片無法到達的節點。
解決這些缺點是完全可行的,但超出本教學課程的範圍。如果你嘗試一下,請務必在 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
Hook 在元件內部定義內容,但只計算一次。
由於我們現在有了「初始」投影片的概念,我們也使用 fitViewOptions
來確保初始投影片是在首次載入畫布時聚焦的投影片。
在投影片之間導覽
到目前為止,我們已將簡報以網格形式佈局,但我們必須手動平移畫布才能查看每張投影片,這對於簡報而言並不實際!我們將新增三種不同的方式來在投影片之間導覽
-
點擊以聚焦節點,透過點擊節點跳轉到不同的投影片。
-
每張投影片上的導覽按鈕,用於在任何有效方向上依序移動投影片。
-
使用方向鍵進行鍵盤導覽,以在簡報中移動,而無需使用滑鼠或直接與投影片互動。
點擊時聚焦
<ReactFlow />
元素可以接收一個 onNodeClick
回呼,該回呼會在點擊任何節點時觸發。除了滑鼠事件本身之外,我們還會收到一個對點擊節點的參考,並且我們可以利用 fitView
方法來平移畫布。
fitView
是 React Flow 實例上的方法,我們可以透過使用 useReactFlow
Hook 來存取它。
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
回呼,我們將在稍後使用。
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 的一些實用功能
-
使用
useReactFlow
Hook 來存取fitView
方法。 -
使用
onNodeClick
事件處理程序來監聽流程中每個節點上的點擊。 -
使用
onKeyPress
事件處理程序來監聽整個畫布上的鍵盤事件。
我們還研究了如何自己實作一個簡單的版面配置演算法。版面配置是我們經常被問到的問題,但是如果您的需求不那麼複雜,您可以用自己的解決方案取得相當大的進展!
如果您正在尋找擴展此專案的想法,您可以嘗試解決我們指出的版面配置演算法問題、設計一個具有不同版面配置的更複雜的 Slide
元件,或完全不同的東西。
您可以使用完成的原始程式碼作為起點,或者您可以繼續在我們今天製作的基礎上進行建構。我們很樂意看到您建構的東西,所以請在我們的 Discord 伺服器或 Twitter 上與我們分享。