Creating Virtual Canvas Functionality with React and TypeScript
2024 年 4 月 2 日

Miro和Figma是在线协作画布类型工具,在大流行期间变得非常流行。

现在,您可以在虚拟画布上添加虚拟便条(以及令人眼花缭乱的其他元素),而不是将便条贴在物理墙上。这使团队能够以在物理世界中熟悉的方式进行虚拟协作。

Figma和Miro是大型成熟产品,它们完全绕过了HTML和CSS。由于其极高的性能要求,它们使用WebAssembly、WebGL、C++等技术。

但是,我们可以使用React、TypeScript和几个包创建类似的虚拟画布类型功能,而不需要复杂性。我们将支持一种“卡片”类型,它只包含简单文本,以使本指南简洁,但很容易扩展解决方案以支持更复杂的用例。

我们要实现的功能是:

  • 拖动卡片在画布上移动
  • 在画布上添加新卡片
  • 平移和缩放画布

本文是一份逐步指南,描述如何创建这些功能,并在GitHub上附有现场演示的代码。

我们自己构建的解决方案可能不像Figma或Miro那样快速,但如果您的需求更简单,它可能已经足够了。

项目概览 #

我们将使用DndKit (opens new window)进行拖放操作,使用D3 Zoom (opens new window)进行平移和缩放。我发现这两个工具都很好用。

代码在GitHub上 (opens new window),并且有在线演示 (opens new window),您可以尝试一下。

只有4个组件(AppCanvasDraggableAddable),大约250行代码。

本指南面向中级水平的React / TypeScript开发人员。如果您已经了解组件 (opens new window)hooks (opens new window)如何以React思考 (opens new window)状态 (opens new window)可变引用 (opens new window)等,那么理解起来会更容易。

part-3

屏幕截图显示最终画布解决方案

好了,现在让我们看看如何实际构建这个,让我们开始吧。

步骤1 - 如何在画布上拖动卡片 #

首先,我们将使用DndKit (opens new window)在画布上拖动卡片。我们将安装构建项目所需的工具,创建一个简单的Card类型,并创建简单的AppCanvasDraggable 组件。

App 组件存储 card 状态并渲染 Canvas

Canvas 组件与 DndKit 集成,更新 card 状态并将 cards 渲染为 Draggable 组件。

Draggable 组件与 DndKit 集成,并使用 CSS 样式来正确定位在画布上。

GitHub 上的第一步代码 (opens new window)

这是我们在本部分将要构建的屏幕截图,还有一个 实时演示 (opens new window) 您可以自行尝试:

屏幕截图显示了画布解决方案的第 1 部分

项目设置 #

我们使用以下命令创建一个新项目并安装 DndKit:

npm create vite@latest figma-miro-canvas -- --template react-ts
npm install
npm install @dnd-kit/core

App.tsx #

App 组件存储 card 状态并渲染 Canvas

我们添加了一个 Card 类型,在这个演示中,卡片将简单地包含一些文本。UniqueIdentifier 来自 DndKit,看起来很可怕,但它只是一个 String | numberCoordinates 也来自 DndKit,包含 x 和 y 位置。

export type Card = {  
  id: UniqueIdentifier;
  coordinates: Coordinates;
  text: string;
};

然后我们需要创建一些卡片,并将它们传递给画布。

export const App = () => {
  const [cards, setCards] = useState<Card[]>([
    { id: "Hello", coordinates: { x: 0, y: 0 }, text: "Hello" },
  ]);
  
  return (<Canvas cards={cards} />);
}

GitHub 上的 App.tsx (opens new window)

Canvas.tsx #

Canvas 组件与 DndKit 集成,更新 card 状态并将 cards 渲染为 Draggable 组件。

它接受 cardssetCards 作为 props,因为状态存储在更高层的 App 组件中。虽然现在并不是必需的,但在以后的步骤中会很有用。

type Props = {
  cards: Card[];
  setCards: (cards: Card[]) => void;
}

我们添加一个函数,在拖放操作完成后调用 setCards 进行更新。这只是将拖动距离/增量添加到正在被拖动的卡片的 x 和 y 值中。

DragEndEvent 来自 DndKit,包括正在拖动的 active 项,因此我们可以使用它来确定要更新哪个 card

const updateDraggedCardPosition = ({ delta, active }: DragEndEvent) => {
  if (!delta.x && !delta.y) return;

  setCards(
    cards.map((card) => {
      if (card.id === active.id) {
        return {
          ...card,
          coordinates: {
            x: card.coordinates.x + delta.x,
            y: card.coordinates.y + delta.y,
          },
        };
      }
      return card;
    })
  );
};

我们添加一个 div 代表画布,一个 DndContext(来自 DndKit),并为每张卡片渲染一个 Draggable。我们连接 更新我们的新功能,使用DndContext中的onDragEnd事件,以便在成功拖动操作后更新card状态。

<div className="canvas">
  <DndContext onDragEnd={updateDraggedCardPosition}>
    {cards.map((card) => (
      <Draggable card={card} key={card.id} />
    ))}
  </DndContext>
</div>

在GitHub上查看Canvas.tsx (opens new window)

Draggable.tsx #

Draggable组件与DndKit集成,并使用CSS样式(positiontopleft)在画布上正确定位自身。

useDraggable来自DndKit,我们将其返回的attributeslistenerssetNodeRef盲目传递给我们的div,使其能够响应onClick事件等。

我们还使用DndKit中的transform来应用CSS,以便在拖动进行中将卡片呈现在修改后的位置。有点令人困惑的是,CSS属性名称也称为transform

export const Draggable = ({ card }: { card: Card }) => {
  // hook up to DndKit
  const { attributes, listeners, setNodeRef, transform } = useDraggable({
    id: card.id,
  });

  return (
    <div
      className="card"
      style={{
        // position card on canvas
        position: "absolute",
        top: `${card.coordinates.y}px`,
        left: `${card.coordinates.x}px`,
        // temporary change to this position when dragging
        ...(transform
          ? {
              transform: `translate3d(${transform.x}px, ${transform.y}px, 0px)`,
            }
          : {}),
      }}
      ref={setNodeRef}
      {...listeners}
      {...attributes}
    >
      {card.text}
    </div>
  );
};

在GitHub上查看Draggable.tsx (opens new window)

第2步 - 如何向画布添加新卡片 #

在这一步中,我们将创建一个新的Addable组件,表示当前不在画布上的卡片,但可以拖动到画布上。

我们将更新App以添加一个“托盘”div来包含这些新的Addable卡片。我们还将添加 另一个 DndContext(这个新的DndContext将处理从托盘到画布的拖放,而Canvas中的现有DndContext将处理在画布周围拖动卡片),并连接到其事件。这将让我们在Addable卡片被拖动/放置到画布上时更新状态。

我们将更新Canvas以使其成为一个DndKit的放置目标,以便Addable卡片可以放置在上面。

Step 2代码在GitHub上 (opens new window) ### App.tsx

现在,App组件需要一个“托盘”div来容纳Addable卡片。

它还需要_另一个_ DndContext(这个新的DndContext将处理从托盘到画布的拖放,Canvas中现有的DndContext处理在画布周围拖动卡片)。它需要连接到它的事件,以便在Addable卡片被拖放到画布上时更新状态。

我们添加一个卡片列表,显示在托盘上:

const trayCards = [
  // the coordinates aren't used for the tray cards, we could create a new type without them
  { id: "World", coordinates: { x: 0, y: 0 }, text: "World" },
];

我们添加一个函数来计算拖放操作结束时在画布上的位置。这个函数必须知道卡片的初始位置、拖动距离/增量和画布的左上角位置。所有这些细节都由 DndKit 的 DragEndEvent 提供。

const calculateCanvasPosition = (
  initialRect: ClientRect,
  over: Over,
  delta: Translate
): Coordinates => ({
  x: initialRect.left + delta.x - (over?.rect?.left ?? 0),
  y: initialRect.top + delta.y - (over?.rect?.top ?? 0),
});

我们添加状态来存储正在拖动的托盘卡片,以及一个函数,在从托盘拖放后更新cards状态。DragEndEvent来自 DndKit,并包括正在拖动的active项,因此我们可以使用它来创建一个新的card并将其添加到cards数组中。我们还进行一些检查,确保“canvas”是放置目标,并且我们拥有所需的所有数据。

const [draggedTrayCardId, setDraggedTrayCardId] = useState<UniqueIdentifier | null>(null);

const addDraggedTrayCardToCanvas = ({ over, active, delta }: DragEndEvent) => {
  setDraggedTrayCardId(null);

  if (over?.id !== "canvas") return;
  if (!active.rect.current.initial) return;

  setCards([
    ...cards,
    {
      id: active.id,
      coordinates: calculateCanvasPosition(
        active.rect.current.initial,
        over,
        delta
      ),
      text: active.id.toString(),
    },
  ]);
};

我们添加新的DndContext、一个代表托盘的div和一个DragOverlay

DragOverlay组件来自 DndKit,我们在其中渲染正在拖动的托盘卡片。它会在拖动时显示托盘卡片,非常方便用于拖放,但在仅拖动时不太方便,这就是为什么我们在之前拖动卡片时没有使用它的原因。

<DndContext
  onDragStart={({ active }) => setDraggedTrayCardId(active.id)} 
  onDragEnd={addDraggedTrayCardToCanvas} 
>
  <div className="tray">
    {trayCards.map((trayCard) => {
      // this line removes the card from the tray if it's on the c 在这最后一步中,我们将安装d3-zoom,将其连接到画布,然后更新一些计算和样式,以确保一切显示在正确的位置上。

我们将更新`App`,以存储d3-zoom的`transform`(画布的平移和缩放),并更新`DragOverlay`和`calculateCanvasPosition`的样式,以考虑`transform`。

我们将更新`Canvas`以与d3-zoom集成,并使用CSS样式以考虑`transform`。

我们将更新`Draggable`,使用CSS样式以考虑`transform`,无论是在静止状态还是在拖动时。

d3-zoom自动处理平移和缩放的鼠标和指针事件,因此我们不需要添加任何代码(但如果您想添加“放大”按钮或类似功能,也很容易实现)。

[第3步代码]() 在此部分中,我们将构建以下内容,并且您还可以尝试[实时演示](https://ceddlyburge.github.io/react-figma-miro-canvas-part-3/):

在继续之前,请确保已安装d3-zoom:

npm install d3-zoom npm install --save-dev @types/d3-zoom


### **App.tsx**

`App`现在需要存储来自d3-zoom的`transform`(画布的平移和缩放),并更新`DragOverlay`和`calculateCanvasPosition`的样式以考虑`transform`。

我们存储来自d3-zoom的当前`transform`。这代表了平移(`transform.x`和`transform.y`)和缩放(`transform.k)。

我们向`DragOverlay`添加CSS,以便从托盘中拖动的卡片在画布上与其在画布上的大小相同。

我们更新calculateCanvasPosition函数,因为现在它需要考虑画布的缩放(`transform.k`)以及左上角位置。

[App.tsx on GitHub](https://github.com/ceddlyburge/react-figma-miro-canvas-part-3/blob/main/src/App.tsx)

### **Canvas.tsx**

`Canvas`现在需要与d3-zoom集成,并使用CSS样式来考虑来自d3-zoom的`transform`。

我们为`transform`和`setTransform`添加props(我们从App.tsx传递这些props)。

我们连接d3-zoom。DndKit和d3-zoom都需要引用元素的引用,因此我们创建`canvasRef`和`updateAndForwardRef`,允许它们都引用同一个`HTMLDivElement`。

d3-zoom是一个JavaScript库,而不是React组件,这就是为什么我们必须使用以下略微奇特的代码,例如`useMemo`和`useLayoutEffect`(尽管您几乎可以在任何合理大小的React代码库中看到这两者)。

[Canvas.tsx on GitHub](https://github.com/ceddlyburge/react-figma-miro-canvas-part-3/blob/main/src/Canvas.tsx) 我们在canvas周围添加一个包装器/窗口。canvas div现在可以平移和缩放(因此会在屏幕上移动),因此我们将其放在另一个具有固定位置和大小并隐藏任何溢出的div中,以便我们有一个显示画布相关部分的“窗口”。

我们还为canvas添加CSS样式以适应平移和缩放,使用新的`updateAndForwardRef`函数并将`ref`从canvas移动到canvas窗口。

```jsx
<div ref={updateAndForwardRef} className="canvasWindow">
  <div
    className="canvas"
    style={{
      // 应用来自d3的变换
      transformOrigin: "top left",
      transform: `translate3d(${transform.x}px, ${transform.y}px, ${transform.k}px)`,
      position: "relative",
      height: "300px",
    }}
  >
    ...
  </div>
</div>

Canvas.tsx on GitHub (opens new window)

Draggable.tsx #

Draggable现在需要不同的CSS样式来考虑d3-zoom的transform,无论是静止还是在被拖动时。

我们为d3-zoom变换添加一个prop,我们称之为canvasTransform,因为我们已经从DndKit使用了一个transform变量。

type Props = {
  card: Card;
  canvasTransform: ZoomTransform;
}

我们更新CSS以适应canvas的平移和缩放。我们必须处理两种情况,一种是当前正在被拖动时,另一种是没有被拖动时。

style={{
  position: "absolute",
  top: `${card.coordinates.y * canvasTransform.k}px`,
  left: `${card.coordinates.x * canvasTransform.k}px`,
  transformOrigin: "top left",
  ...(transform
    ? {
        // 拖动时临时改变这个位置
        transform: `translate3d(${transform.x}px, ${transform.y}px, 0px) scale(${canvasTransform.k})`,
      }
    : {
        // 缩放到画布缩放
        transform: `scale(${canvasTransform.k})`,
      }),
}}

我们还阻止onPointerDown事件冒泡到canvas,否则它将被d3-zoom处理,并被解释为开始拖动的请求,这将导致同时拖动画布和卡片(这是一个有趣但不希望发生的效果!)

onPointerDown={(e) => {
  listeners?.onPointerDown?.(e);
  e.preventDefault();
}}

Draggable.tsx on GitHub (opens new window)

结论 #

各种位置/变换计算存在一定复杂性,但并不太疯狂,而且只需安装两个依赖项。

只有四个c 这个演示非常简单,但包含了许多虚拟画布功能,可以很容易地将其作为基础,并在其上构建更复杂的内容。

通过使用四个组件(AppCanvasDraggableAddable),仅需大约250行代码即可实现所有功能,这看起来是一个非常适中的数量。

学习免费编程。freeCodeCamp的开放源代码课程已经帮助超过40,000人获得开发人员的工作。开始学习 (opens new window)