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个组件(App
、Canvas
、Draggable
和Addable
),大约250行代码。
本指南面向中级水平的React / TypeScript开发人员。如果您已经了解组件 (opens new window)、hooks (opens new window)、如何以React思考 (opens new window)、状态 (opens new window)、可变引用 (opens new window)等,那么理解起来会更容易。
屏幕截图显示最终画布解决方案
好了,现在让我们看看如何实际构建这个,让我们开始吧。
步骤1 - 如何在画布上拖动卡片 #
首先,我们将使用DndKit (opens new window)在画布上拖动卡片。我们将安装构建项目所需的工具,创建一个简单的Card
类型,并创建简单的App
、Canvas
和 Draggable
组件。
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 | number
。Coordinates
也来自 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
组件。
它接受 cards
和 setCards
作为 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样式(position
,top
和left
)在画布上正确定位自身。
useDraggable
来自DndKit,我们将其返回的attributes
,listeners
和setNodeRef
盲目传递给我们的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 这个演示非常简单,但包含了许多虚拟画布功能,可以很容易地将其作为基础,并在其上构建更复杂的内容。
通过使用四个组件(App
、Canvas
、Draggable
和Addable
),仅需大约250行代码即可实现所有功能,这看起来是一个非常适中的数量。
学习免费编程。freeCodeCamp的开放源代码课程已经帮助超过40,000人获得开发人员的工作。开始学习 (opens new window)