2024/01/07

使用 React Flow 创建幻灯片演示

Hayleigh Thompson
软件工程师

我们最近发布了我们 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 年底的调查应用程序由许多静态节点和按钮组成,用于在它们之间导航。

在本教程结束时,您将构建一个带有以下功能的演示应用程序:

  • 支持 Markdown 幻灯片
  • 键盘在视窗周围导航
  • 自动布局
  • 点击拖动平移导航(类似于 Prezi)

在此过程中,您将了解一些关于布局算法的基础知识、创建静态流程和自定义节点的知识。

完成后,应用程序将如下所示!

要学习本教程,我们假设您对 ReactReact Flow 有基本了解,但是如果您在学习过程中遇到问题,请随时在 Discord 上联系我们!

以下是包含最终代码的 仓库,如果您想跳过步骤或在学习过程中参考它,可以点击它。

让我们开始吧!

设置项目

我们建议在开始新的 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 钩子)!当 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}
  />;
}

请注意,我们已在 <ReactFlow /> 组件中添加了 minZoom 属性。我们的幻灯片非常大,默认的最小缩放级别不足以缩小以查看多张幻灯片。

在上面的节点数组中,我们通过使用 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 钩子在组件内部定义内容,但只计算一次。

因为我们现在有了“初始”幻灯片的概念,所以我们还使用 fitViewOptions 来确保初始幻灯片是画布首次加载时聚焦的幻灯片。

到目前为止,我们的演示已经布局在网格中,但我们必须手动平移画布才能查看每个幻灯片,这对于演示来说并不实用!我们将添加三种在幻灯片之间导航的不同方法

  • 单击以聚焦节点,通过单击节点跳转到不同的幻灯片。

  • 每个幻灯片上的导航按钮,用于在任何有效方向上按顺序移动幻灯片。

  • 使用箭头键进行键盘导航,用于在不使用鼠标或直接与幻灯片交互的情况下移动演示文稿。

单击聚焦

<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://flow.reactjs.ac.cn/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 获得专业示例、优先级 Bug 报告、维护人员的一对一支持等更多功能。