2023/01/10
使用 React Flow 建構心智圖應用程式

在本教學中,您將學習使用 React Flow 建立一個簡單的心智圖工具,可用於集思廣益、組織想法,或以視覺方式繪製您的想法。為了建構這個應用程式,我們將使用狀態管理、自訂節點和邊緣等等。
示範時間!
在我們開始動手實作之前,我想先向您展示在本教學結束時我們將擁有的心智圖工具
如果您想冒險直接進入程式碼,您可以在 Github 上找到原始碼。
開始使用
若要完成本教學,您需要具備一些 React 和 React Flow 的知識(嗨,那就是我們! 它是一個用於建構基於節點的 UI(例如工作流程工具、ETL 管道和更多)的開源函式庫。)
我們將使用 Vite 來開發我們的應用程式,但您也可以使用 Create React App 或您喜歡的任何其他工具。若要使用 Vite 搭建新的 React 應用程式,您需要執行
npm create vite@latest reactflow-mind-map -- --template react
如果您想使用 Typescript
npm create vite@latest reactflow-mind-map -- --template react-ts
在初始設定之後,您需要安裝一些套件
npm install reactflow zustand classcat nanoid
我們使用 Zustand 來管理我們應用程式的狀態。它有點像 Redux,但更小,而且要編寫的樣板程式碼更少。React Flow 也使用 Zustand,因此安裝不會產生額外成本。(在本教學中,我們使用 Typescript,但您也可以使用普通的 Javascript。)
為了保持簡單,我們將所有程式碼都放在 src/App
資料夾中。為此,您需要建立 src/App
資料夾並新增一個包含以下內容的索引檔案
src/App/index.tsx
import { ReactFlow, Controls, Panel } from '@xyflow/react';
// we have to import the React Flow styles for it to work
import '@xyflow/react/dist/style.css';
function Flow() {
return (
<ReactFlow>
<Controls showInteractive={false} />
<Panel position="top-left">React Flow Mind Map</Panel>
</ReactFlow>
);
}
export default Flow;
這將是我們用於渲染心智圖的主要元件。目前還沒有節點或邊緣,但我們新增了 React Flow 的 Controls
元件和一個 Panel
來顯示我們應用程式的標題。
為了能夠使用 React Flow 鉤子,我們需要在我們的 main.tsx(vite 的進入檔案)中以 ReactFlowProvider
元件包裝應用程式。我們也導入了新建立的 App/index.tsx
並在 ReactFlowProvider
內部渲染它。您的主檔案應如下所示
src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ReactFlowProvider } from '@xyflow/react';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ReactFlowProvider>
<App />
</ReactFlowProvider>
</React.StrictMode>,
);
React Flow 元件的父容器需要寬度和高度才能正常運作。我們的應用程式是全螢幕應用程式,因此我們將這些規則新增至 index.css
檔案中
src/index.css
body {
margin: 0;
}
html,
body,
#root {
height: 100%;
}
我們將應用程式的所有樣式都新增至 index.css
檔案中(您也可以使用 CSS-in-JS 函式庫,例如 Styled Components 或 Tailwind)。現在您可以透過 npm run dev
啟動開發伺服器,您應該會看到以下內容
節點和邊緣的儲存空間
如上所述,我們使用 Zustand 進行狀態管理。為此,我們在 src/App
資料夾中建立一個名為 store.ts
的新檔案
src/App/store.ts
import {
Edge,
EdgeChange,
Node,
NodeChange,
OnNodesChange,
OnEdgesChange,
applyNodeChanges,
applyEdgeChanges,
} from '@xyflow/react';
import { createWithEqualityFn } from 'zustand/traditional';
export type RFState = {
nodes: Node[];
edges: Edge[];
onNodesChange: OnNodesChange;
onEdgesChange: OnEdgesChange;
};
const useStore = createWithEqualityFn<RFState>((set, get) => ({
nodes: [
{
id: 'root',
type: 'mindmap',
data: { label: 'React Flow Mind Map' },
position: { x: 0, y: 0 },
},
],
edges: [],
onNodesChange: (changes: NodeChange[]) => {
set({
nodes: applyNodeChanges(changes, get().nodes),
});
},
onEdgesChange: (changes: EdgeChange[]) => {
set({
edges: applyEdgeChanges(changes, get().edges),
});
},
}));
export default useStore;
看起來程式碼很多,但大部分是類型 狀態管理函式庫指南中閱讀更多相關資訊。)
儲存區會追蹤節點和邊緣,並處理變更事件。當使用者拖曳節點時,React Flow 會觸發變更事件,然後儲存區會套用變更並呈現更新的節點。(您可以在我們的如您所見,我們從一個位於 { x: 0, y: 0 }
且類型為 'mindmap' 的初始節點開始。若要將儲存區與我們的應用程式連接,我們使用 useStore
鉤子
src/App/index.tsx
import { ReactFlow, Controls, Panel, NodeOrigin } from '@xyflow/react';
import { shallow } from 'zustand/shallow';
import useStore, { RFState } from './store';
// we have to import the React Flow styles for it to work
import '@xyflow/react/dist/style.css';
const selector = (state: RFState) => ({
nodes: state.nodes,
edges: state.edges,
onNodesChange: state.onNodesChange,
onEdgesChange: state.onEdgesChange,
});
// this places the node origin in the center of a node
const nodeOrigin: NodeOrigin = [0.5, 0.5];
function Flow() {
// whenever you use multiple values, you should use shallow to make sure the component only re-renders when one of the values changes
const { nodes, edges, onNodesChange, onEdgesChange } = useStore(
selector,
shallow,
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeOrigin={nodeOrigin}
fitView
>
<Controls showInteractive={false} />
<Panel position="top-left">React Flow Mind Map</Panel>
</ReactFlow>
);
}
export default Flow;
我們從儲存區存取節點、邊緣和變更處理常式,並將其傳遞至 React Flow 元件。我們也使用 fitView
屬性來確保初始節點位於檢視畫面的中心,並將節點原點設定為 [0.5, 0.5]
,以將原點設定為節點的中心。在此之後,您的應用程式應如下所示
您可以移動節點並放大和縮小,我們正在朝著目標前進
現在,讓我們新增更多功能。自訂節點和邊緣
我們想對節點使用名為 'mindmap' 的自訂類型。我們需要為此新增一個新元件。讓我們在 src/App
下建立一個名為 MindMapNode
的新資料夾,其中包含一個索引檔案,其內容如下
src/App/MindMapNode/index.tsx
import { Handle, NodeProps, Position } from '@xyflow/react';
export type NodeData = {
label: string;
};
function MindMapNode({ id, data }: NodeProps<NodeData>) {
return (
<>
<input defaultValue={data.label} />
<Handle type="target" position={Position.Top} />
<Handle type="source" position={Position.Bottom} />
</>
);
}
export default MindMapNode;
我們使用輸入來顯示和編輯心智圖節點的標籤,以及兩個用於連接它們的控制點。這對於 React Flow 的運作是必要的;這些控制點用作邊緣的起點和終點。
我們也將一些 CSS 新增至 index.css
檔案,以使節點看起來更漂亮
src/index.css
.react-flow__node-mindmap {
background: white;
border-radius: 2px;
border: 1px solid transparent;
padding: 2px 5px;
font-weight: 700;
}
(如需更多相關資訊,您可以閱讀我們文件中關於自訂節點的指南。)
讓我們為自訂邊緣做同樣的事情。在 src/App
下建立一個名為 MindMapEdge
的新資料夾,其中包含一個索引檔案
src/App/MindMapEdge/index.tsx
import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react';
function MindMapEdge(props: EdgeProps) {
const { sourceX, sourceY, targetX, targetY } = props;
const [edgePath] = getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
return <BaseEdge path={edgePath} {...props} />;
}
export default MindMapEdge;
我將在下一節更詳細地介紹自訂節點和邊緣。目前重要的是,我們可以透過將以下程式碼加入我們的 Flow
元件中,在我們的應用程式中使用新的類型:
import MindMapNode from './MindMapNode';
import MindMapEdge from './MindMapEdge';
const nodeTypes = {
mindmap: MindMapNode,
};
const edgeTypes = {
mindmap: MindMapEdge,
};
然後將新建立的類型傳遞給 React Flow 元件。
太棒了!我們已經可以透過點擊輸入欄位並輸入內容來變更節點的標籤了。
新增節點
我們希望使用者能夠非常快速地建立新的節點。使用者應該能夠透過點擊一個節點並拖曳到要放置新節點的位置來新增節點。此功能並未內建於 React Flow 中,但我們可以透過使用 onConnectStart
和 onConnectEnd
處理器來實作它。
我們使用開始處理器來記住點擊的節點,並使用結束處理器來建立新的節點。
加入到 src/App/index.tsx
const connectingNodeId = useRef<string | null>(null);
const onConnectStart: OnConnectStart = useCallback((_, { nodeId }) => {
connectingNodeId.current = nodeId;
}, []);
const onConnectEnd: OnConnectEnd = useCallback((event) => {
// we only want to create a new node if the connection ends on the pane
const targetIsPane = (event.target as Element).classList.contains(
'react-flow__pane',
);
if (targetIsPane && connectingNodeId.current) {
console.log(`add new node with parent node ${connectingNodeId.current}`);
}
}, []);
由於我們的節點是由 store 管理的,因此我們建立一個 action 來新增新的節點及其邊緣。以下是我們的 addChildNode
action 的樣子:
src/store.ts 中的新 action
addChildNode: (parentNode: Node, position: XYPosition) => {
const newNode = {
id: nanoid(),
type: 'mindmap',
data: { label: 'New Node' },
position,
parentNode: parentNode.id,
};
const newEdge = {
id: nanoid(),
source: parentNode.id,
target: newNode.id,
};
set({
nodes: [...get().nodes, newNode],
edges: [...get().edges, newEdge],
});
};
我們使用傳遞的節點作為父節點。通常,此功能用於實作分組或子流程。在這裡,我們使用它在移動父節點時移動所有子節點。這使我們能夠清理和重新排序心智圖,而無需手動移動所有子節點。讓我們在我們的 onConnectEnd
處理器中使用新的 action:
src/App/index.tsx 中的調整
const store = useStoreApi();
const onConnectEnd: OnConnectEnd = useCallback(
(event) => {
const { nodeLookup } = store.getState();
const targetIsPane = (event.target as Element).classList.contains(
'react-flow__pane',
);
if (targetIsPane && connectingNodeId.current) {
const parentNode = nodeLookup.get(connectingNodeId.current);
const childNodePosition = getChildNodePosition(event, parentNode);
if (parentNode && childNodePosition) {
addChildNode(parentNode, childNodePosition);
}
}
},
[getChildNodePosition],
);
首先,我們透過 store.getState()
從 React Flow store 取得 nodeLookup
。nodeLookup
是一個包含所有節點及其目前狀態的映射。我們需要它來取得點擊節點的位置和尺寸。然後,我們檢查 onConnectEnd 事件的目標是否為 React Flow 面板。如果是,我們要新增一個新的節點。為此,我們使用我們的 addChildNode
和新建立的 getChildNodePosition
輔助函式。
src/App/index.tsx 中的輔助函式
const getChildNodePosition = (event: MouseEvent, parentNode?: Node) => {
const { domNode } = store.getState();
if (
!domNode ||
// we need to check if these properites exist, because when a node is not initialized yet,
// it doesn't have a positionAbsolute nor a width or height
!parentNode?.computed?.positionAbsolute ||
!parentNode?.computed?.width ||
!parentNode?.computed?.height
) {
return;
}
const panePosition = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// we are calculating with positionAbsolute here because child nodes are positioned relative to their parent
return {
x:
panePosition.x -
parentNode.computed?.positionAbsolute.x +
parentNode.computed?.width / 2,
y:
panePosition.y -
parentNode.computed?.positionAbsolute.y +
parentNode.computed?.height / 2,
};
};
此函式會傳回我們想要新增到 store 的新節點的位置。我們使用 project
函式將螢幕座標轉換為 React Flow 座標。如先前所述,子節點的位置相對於其父節點。這就是為什麼我們需要從子節點位置減去父節點位置的原因。這需要吸收很多資訊,讓我們來看看實際運作情況:
若要測試新功能,您可以從一個 handle 開始連線,然後在面板上結束連線。您應該會看到一個新的節點被新增到心智圖中。
保持資料同步
我們已經可以更新標籤,但我們沒有更新節點的資料物件。這對於保持應用程式同步非常重要,如果我們想要將節點儲存到伺服器上也是如此。為了達成這個目的,我們新增一個名為 updateNodeLabel
的新 action 到 store。此 action 接受一個節點 id 和一個標籤。實作非常簡單:我們迭代現有的節點並使用傳遞的標籤更新相符的節點。
src/store.ts
updateNodeLabel: (nodeId: string, label: string) => {
set({
nodes: get().nodes.map((node) => {
if (node.id === nodeId) {
// it's important to create a new object here, to inform React Flow about the changes
node.data = { ...node.data, label };
}
return node;
}),
});
},
讓我們在我們的 MindmapNode
元件中使用新的 action:
src/App/MindmapNode/index.tsx
import { Handle, NodeProps, Position } from '@xyflow/react';
import useStore from '../store';
export type NodeData = {
label: string;
};
function MindMapNode({ id, data }: NodeProps<NodeData>) {
const updateNodeLabel = useStore((state) => state.updateNodeLabel);
return (
<>
<input
// from now on we can use value instead of defaultValue
// this makes sure that the input always shows the current label of the node
value={data.label}
onChange={(evt) => updateNodeLabel(id, evt.target.value)}
className="input"
/>
<Handle type="target" position={Position.Top} />
<Handle type="source" position={Position.Top} />
</>
);
}
export default MindMapNode;
太快了!自訂節點的輸入欄位現在顯示節點的目前標籤。您可以取得您的節點資料,將其儲存到伺服器上,然後再次載入。
更簡單的 UX 和更美觀的樣式
在功能方面,我們的心智圖應用程式已完成!我們可以新增新的節點、更新其標籤並移動它們。但是 UX 和樣式可以進行一些改進。讓我們更容易拖曳節點和建立新節點!
1. 將節點作為 handle
讓我們使用整個節點作為 handle,而不是顯示預設的 handle。這使得建立節點更容易,因為您可以開始新連線的區域變大了。我們需要將來源 handle 的樣式設定為節點的大小,並在視覺上隱藏目標 handle。React Flow 仍然需要它來連接節點,但我們不需要顯示它,因為我們是透過在面板上放置邊緣來建立新節點。我們使用簡單的 CSS 來隱藏目標 handle 並將其放置在節點的中心。
src/index.css
.react-flow__handle.target {
top: 50%;
pointer-events: none;
opacity: 0;
}
為了使整個節點成為 handle,我們也更新來源的樣式:
src/index.css
.react-flow__handle.source {
top: 0;
left: 0;
transform: none;
background: #f6ad55;
height: 100%;
width: 100%;
border-radius: 2px;
border: none;
}
這樣做是可行的,但我們無法再移動節點,因為來源 handle 現在是整個節點並覆蓋了輸入欄位。我們使用 dragHandle
節點選項來修正這個問題。它允許我們指定 DOM 元素的選擇器,該元素應該用作拖曳 handle。為此,我們對自訂節點進行一些調整:
src/App/MindmapNode/index.tsx
import { Handle, NodeProps, Position } from '@xyflow/react';
import useStore from '../store';
export type NodeData = {
label: string;
};
function MindMapNode({ id, data }: NodeProps<NodeData>) {
const updateNodeLabel = useStore((state) => state.updateNodeLabel);
return (
<>
<div className="inputWrapper">
<div className="dragHandle">
{/* icon taken from grommet https://icons.grommet.io */}
<svg viewBox="0 0 24 24">
<path
fill="#333"
stroke="#333"
strokeWidth="1"
d="M15 5h2V3h-2v2zM7 5h2V3H7v2zm8 8h2v-2h-2v2zm-8 0h2v-2H7v2zm8 8h2v-2h-2v2zm-8 0h2v-2H7v2z"
/>
</svg>
</div>
<input
value={data.label}
onChange={(evt) => updateNodeLabel(id, evt.target.value)}
className="input"
/>
</div>
<Handle type="target" position={Position.Top} />
<Handle type="source" position={Position.Top} />
</>
);
}
export default MindMapNode;
我們新增一個具有類別名稱 inputWrapper
的包裝 div 和一個具有類別名稱 dragHandle
的 div,該 div 作為拖曳 handle(驚喜!)。現在我們可以為新的元素設定樣式:
src/index.css
.inputWrapper {
display: flex;
height: 20px;
z-index: 1;
position: relative;
}
.dragHandle {
background: transparent;
width: 14px;
height: 100%;
margin-right: 4px;
display: flex;
align-items: center;
}
.input {
border: none;
padding: 0 2px;
border-radius: 1px;
font-weight: 700;
background: transparent;
height: 100%;
color: #222;
}
2. 在焦點時啟動輸入
我們快完成了,但我們需要調整一些細節。我們想要從節點的中心開始新的連線。為此,我們將輸入的指標事件設定為「none」,並檢查使用者是否在節點的頂部放開按鈕。只有這樣,我們才要啟動輸入欄位。我們可以使用我們的 onConnectEnd
函式來達成此目的:
src/App/index.tsx
const onConnectEnd: OnConnectEnd = useCallback(
(event) => {
const { nodeLookup } = store.getState();
const targetIsPane = (event.target as Element).classList.contains(
'react-flow__pane',
);
const node = (event.target as Element).closest('.react-flow__node');
if (node) {
node.querySelector('input')?.focus({ preventScroll: true });
} else if (targetIsPane && connectingNodeId.current) {
const parentNode = nodeLookup.get(connectingNodeId.current);
const childNodePosition = getChildNodePosition(event, parentNode);
if (parentNode && childNodePosition) {
addChildNode(parentNode, childNodePosition);
}
}
},
[getChildNodePosition],
);
如您所見,如果使用者在節點頂部放開滑鼠按鈕,我們就會將焦點放在輸入欄位上。我們現在可以新增一些樣式,以便輸入欄位只有在獲得焦點時才被啟動 (pointerEvents: all):
/* we want the connection line to be below the node */
.react-flow .react-flow__connectionline {
z-index: 0;
}
/* pointer-events: none so that the click for the connection goes through */
.inputWrapper {
display: flex;
height: 20px;
position: relative;
z-index: 1;
pointer-events: none;
}
/* pointer-events: all so that we can use the drag handle (here the user cant start a new connection) */
.dragHandle {
background: transparent;
width: 14px;
height: 100%;
margin-right: 4px;
display: flex;
align-items: center;
pointer-events: all;
}
/* pointer-events: none by default */
.input {
border: none;
padding: 0 2px;
border-radius: 1px;
font-weight: 700;
background: transparent;
height: 100%;
color: #222;
pointer-events: none;
}
/* pointer-events: all when it's focused so that we can type in it */
.input:focus {
border: none;
outline: none;
background: rgba(255, 255, 255, 0.25);
pointer-events: all;
}
3. 動態寬度和自動焦點
快完成了!我們希望節點的寬度能夠根據文字的長度動態變化。為了簡化,我們根據文字的長度進行計算:
在 src/app/MindMapNode.tsx 中新增的 effect
useLayoutEffect(() => {
if (inputRef.current) {
inputRef.current.style.width = `${data.label.length * 8}px`;
}
}, [data.label.length]);
我們也希望在建立節點後立即將焦點放在節點上/啟動節點:
在 src/app/MindMapNode.tsx 中新增的 effect
useEffect(() => {
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus({ preventScroll: true });
}
}, 1);
}, []);
現在,當您調整節點標籤時,節點的寬度也會相應調整。您也可以建立新的節點,並且它會立即獲得焦點。
4. 置中的邊緣和樣式細節
您可能已經注意到邊緣沒有置中。我們在開始時為此建立了一個自訂邊緣,現在我們可以稍微調整它,使邊緣從節點的中心開始,而不是從 handle 的頂部開始(預設行為):
src/App/MindMapEdge.tsx
import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react';
function MindMapEdge(props: EdgeProps) {
const { sourceX, sourceY, targetX, targetY } = props;
const [edgePath] = getStraightPath({
sourceX,
sourceY: sourceY + 20,
targetX,
targetY,
});
return <BaseEdge path={edgePath} {...props} />;
}
export default MindMapEdge;
我們將所有 props 傳遞給 getStraightPath
輔助函式,但調整 sourceY,使其位於節點的中心。
此外,我們希望標題更為細微,並為我們的背景選擇一種顏色。我們可以透過調整面板的顏色(我們新增了類別名稱 "header"
)和 body 元素的背景顏色來執行此操作:
body {
margin: 0;
background-color: #f8f8f8;
height: 100%;
}
.header {
color: #cdcdcd;
}
做得好!
您可以在這裡找到最終程式碼:最後的想法
真是一段旅程!我們從一個空的面板開始,並以一個功能齊全的心智圖應用程式結束。如果您想繼續,您可以處理以下一些功能:
- 透過點擊面板新增新的節點
- 儲存和還原按鈕,將目前狀態儲存到本機儲存空間
- 匯出和匯入 UI
- 協作編輯
我希望您喜歡這個教學並學到了一些新東西!如果您有任何問題或意見反應,請隨時在 Twitter 上與我聯絡,或加入我們的 Discord 伺服器。React Flow 是一家由其使用者資助的獨立公司。如果您想支持我們,您可以在 Github 上贊助我們,或訂閱我們的其中一個 Pro 方案。