最近、React Flowを使って draw.io ライクなwebアプリを作っているので、そこで獲得できた知見をまとめていきます。
以下の内容はGithubに公開しています。
ReactFlowのstateをstoreで管理する
React Flowは状態管理ライブラリに標準でzustandが利用されています。
このZustandで状態を明示的にstoreで管理し、ローカルコンポーネントで完結しない複数のコンポーネントにまたがる複雑な状態管理を行うことができます。
まず、以下のようにstoreの定義を行います。
import { create } from "zustand";
import { StoreType } from "../types/storeTypes";
....
const useGraphStore = create<StoreType>((set, get) => ({
nodes: [],
edges: [],
addNode: (node) => {
set({ nodes: [...get().nodes, node] });
},
getNodeById: (id) => {
return get().nodes.find((nd) => nd.id === id);
},
onNodesChange: (changes) => {
set({
nodes: applyNodeChanges(changes, get().nodes),
});
},
....
}));
上記のようにzustandのcreateメソッドでnodeやedgeの状態に加えて、以後ReactFlowで使用するイベントを全てまとめて管理することができます。
またこのstoreは以下のように呼び出し使います。
const selector = (state: StoreType) => ({
nodes: state.nodes,
edges: state.edges,
onNodesChange: state.onNodesChange,
});
export const DropZone: FC = () => {
const {
nodes,
edges,
onNodesChange,
} = useGraphStore(useShallow(selector));
return (
<div>
<ReactFlow
nodes={nodes}
nodeTypes={nodeTypes}
edges={edges}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
....
>
....
</ReactFlow>
</div>
);
};
以後ReactFlowのイベントは基本的にこのstoreに記述していきます。
参考:https://reactflow.dev/learn/advanced-use/state-management#create-a-store
DropZone外の領域からNodeをドラッグ&ドロップで作成する
下記のようにDropZone外の領域からドラッグ&ドロップでNodeを作成する方法です。
まずはドラッグの開始点となる領域から、定義していきます。
const DraggableObject = ({ icon }: { icon: ResourceType }) => {
const onDragStart = (
event: {
dataTransfer: {
setData: (arg0: string, arg1: any) => void;
effectAllowed: string;
};
},
icon: ResourceType
) => {
event.dataTransfer.setData("application/reactflow/url", icon.url);
event.dataTransfer.setData("application/reactflow/resourceName", icon.name);
event.dataTransfer.effectAllowed = "move";
};
return (
<div
className="icon"
onDragStart={(event) => onDragStart(event, icon)}
draggable
>
<img src={icon.url} alt={icon.name} />
<span>{icon.name}</span>
</div>
);
};
export const Sidebar = () => {
return (
<div className="sidebar">
{awsIcons.map((icon) => (
<DraggableObject key={icon.id} icon={icon} />
))}
</div>
);
};
....
上記のコンポーネントで特筆すべき点はonDragStartメソッドです。
このメソッドは目標の要素をdraggableにし、eventを引数にonDragStartで発火させます。
処理内容としては、 Web APIの標準メソッドである setData
メソッドを使用して、ドラッグ&ドロップしているデータを、DropZoneコンポーネントから呼び出せる dataTransfer
オブジェクトに保管しています。
なお、 dataTransfer
に関する内容はこちらをご覧ください。
次にReactFlow側でgetするためのイベントをstoreで定義します。
const useGraphStore = create<StoreType>((set, get) => ({
....
onDrop: (event, position) => {
event.preventDefault();
const url: string = event.dataTransfer.getData("application/reactflow/url");
const resourceName: string = event.dataTransfer.getData(
"application/reactflow/resourceName"
);
const newNode = {
id: randomId(),
position,
data: { name: resourceName, url },
};
set({ nodes: [...get().nodes, newNode] });
},
....
}));
この時の event.preventDefault()
はデフォルトのドロップ時の挙動を停止させるように機能します。
また、 dataTransfer
オブジェクトに格納されていたデータを getData
メソッドで取得し、storeにsetするように定義します。
ここで定義した onDrop
イベントをDropZoneコンポーネントで呼び出します。
const selector = (state: StoreType) => ({
nodes: state.nodes,
edges: state.edges,
onDrop: state.onDrop,
onNodesChange: state.onNodesChange,
});
export const DropZone: FC = () => {
const {
nodes,
edges,
onDrop,
onNodesChange,
} = useGraphStore(useShallow(selector));
const wrappedOnDrop = useCallback(
(event: any) => {
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
onDrop(event, position);
},
[onDrop, screenToFlowPosition]
);
return (
<div>
<ReactFlow
nodes={nodes}
nodeTypes={nodeTypes}
edges={edges}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onDrop={onDrop}
....
>
....
</ReactFlow>
</div>
);
};
ここで、storeで定義したものを wrappedOnDrop
としてラップしているのは、ドロップ先の座標がDropZoneコンポーネントからしか取得できないという問題があるためです。
上記でReact Flowにドラッグ&ドロップでNodeを追加することができます。
React Flow D&D:https://reactflow.dev/examples/interaction/drag-and-drop
dataTransfer:https://developer.mozilla.org/ja/docs/Web/API/DataTransfer
右クリックでNodeのメニューを開く
下記のように左クリックでNodeに関するメニューを表示させる方法です。
まずは表示させるメニューコンポーネントを作成します。
const selector = (state: StoreType) => ({
deleteNode: state.deleteNode,
deleteEdge: state.deleteEdge,
setZIndexNode: state.setZIndexNode,
});
type ContextMenuType = {
id: string;
top: string;
left: string;
right: string;
bottom: string;
};
const ContextMenu: FC<ContextMenuType> = ({
id,
top,
left,
right,
bottom,
...props
}) => {
const { deleteNode, deleteEdge, setZIndexNode } = useStore(
useShallow(selector)
);
const deleteNd = useCallback(() => {
deleteNode(id);
deleteEdge(id);
}, [deleteNode, id, deleteEdge]);
const addZIndexNode = useCallback(() => {
setZIndexNode(1, id);
}, [id, setZIndexNode]);
return (
<div
style={{ top, left, right, bottom, backgroundColor: "white" }}
className="context-menu"
{...props}
>
<p style={{ margin: "0.5em" }}>
<small>node: {id}</small>
</p>
<button onClick={deleteNd}>削除</button>
<button onClick={addZIndexNode}>一つ前</button>
</div>
);
};
このようにNode, Edgeを対象にするコンポーネントが出てきた時にstoreで管理しているメリットが享受されますね。
selectorでstoreに接続することで、Node, EdgeをDropZoneコンポーネントと同じ感覚で操作できます。
このConetextMenuはDropZoneコンポーネントで呼び出します。
export const DropZone: FC = () => {
....
const [menu, setMenu] = useState<any>(null);
const onPaneClick = useCallback(() => setMenu(null), [setMenu]);
const onNodeContextMenu = useCallback(
(event: any, node: CustomNode) => {
event.preventDefault();
const pane = reactFlowRef.current?.getBoundingClientRect() as DOMRect;
setMenu({
id: node.id,
top: event.clientY < pane.height - 200 && event.clientY - 50,
left: event.clientX < pane.width - 200 && event.clientX - 100,
right:
event.clientX >= pane.width - 200 && pane.width - event.clientX + 100,
bottom:
event.clientY >= pane.height - 200 &&
pane.height - event.clientY + 50,
});
},
[setMenu]
);
return (
<div>
<ReactFlow
nodes={nodes}
nodeTypes={nodeTypes}
edges={edges}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onDrop={onDrop}
onNodeContextMenu={onNodeContextMenu}
....
>
{menu &&
<ContextMenu onClick={onPaneClick} {...menu} />
}
....
</ReactFlow>
</div>
);
};
onNodeContextMenu
に関してもonDrop
と同様にDropZoneコンポーネントの座標を取得するため、storeでの定義は行なっていません。
擬似的にNodeをグルーピングする
ReactFlowには標準でグルーピングの方法が提供されていますが、その詳細はPro版ライセンスホルダーしか閲覧することができません。
このライセンス料が非常に高いので、個人開発では手が出せないので私なりにグルーピングする方法を考えてみました。
まずは、storeにonNodeDragStop
イベントを追加します。
const useGraphStore = create<StoreType>((set, get) => ({
....
onNodeDragStop: (_, node) => {
const parents = getParents(get().nodes, node);
if (parents) {
set({
nodes: get().nodes.map((prevNd) =>
prevNd.id === node.id
? {
...prevNd,
data: {
...prevNd.data,
parents: handleChildNodeData(parents),
},
}
: prevNd
),
});
}
},
....
}));
なおgetParents
, handleChildNodeData
は以下のように定義しています。
export const handleChildNodeData = (p: CustomNode[]) => {
return p.reduce((acc: { [x: string]: string }, pa) => {
acc[pa.data.name] = pa.id;
return acc;
}, {});
};
const checkIsHover = (p: CustomNode, c: CustomNode) => {
const width = p.measured?.width ?? 0;
const height = p.measured?.height ?? 0;
return (
p.position.x <= c.position.x &&
c.position.x <= p.position.x + width &&
p.position.y <= c.position.y &&
c.position.y <= p.position.y + height
);
};
export const getParents = (nodes: CustomNode[], child: CustomNode) => {
const parents: CustomNode[] = nodes
.filter(
(nd: CustomNode) => nd.id !== child.id && nd.type === "resource-group"
)
.filter((nd: CustomNode) => nd.id !== child.id && checkIsHover(nd, child));
return parents;
};
まず、getParents
でNodeTypeが親になりうる要素(今回の例ではVPCやsubnetをresource-group
と定義)をフィルタし、子要素の中心が親要素にホバーしているNodeを抽出します。
その後storeのNodeで子要素のparentキーの内容を更新します。
上記の一連のイベントをDropZoneに追記すれば完成です。
export const DropZone: FC = () => {
....
return (
<div>
<ReactFlow
nodes={nodes}
nodeTypes={nodeTypes}
edges={edges}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onDrop={onDrop}
onNodeContextMenu={onNodeContextMenu}
onNodeDragStop={onNodeDragStop}
....
>
{menu &&
<ContextMenu onClick={onPaneClick} {...menu} />
}
....
</ReactFlow>
</div>
);
};
続くTipsもバンバン更新予定です。