React FlowのTips

最近、React Flowを使って draw.io ライクなwebアプリを作っているので、そこで獲得できた知見をまとめていきます。
以下の内容は[Github](https://github.com/Shiba-You/qiita_react_flow)に公開しています。

## ReactFlowのstateをstoreで管理する
React Flowは状態管理ライブラリに標準でzustandが利用されています。
このZustandで状態を明示的にstoreで管理し、ローカルコンポーネントで完結しない複数のコンポーネントにまたがる複雑な状態管理を行うことができます。

まず、以下のようにstoreの定義を行います。
```src/common/store/reactFlowStore.ts
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は以下のように呼び出し使います。
```src/components/DropZone/index.tsx
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を作成する方法です。
![画面収録-2024-08-31-16.44.10_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412378/64e9a60b-66d2-07f4-84dc-11d2902caf3f.gif)

まずはドラッグの開始点となる領域から、定義していきます。
```src/components/SideBar/index.tsx
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` に関する内容は[こちら](https://developer.mozilla.org/ja/docs/Web/API/DataTransfer)をご覧ください。

次にReactFlow側でgetするためのイベントをstoreで定義します。

```src/common/store/reactFlowStore.ts
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コンポーネントで呼び出します。

```src/components/DropZone/index.tsx
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に関するメニューを表示させる方法です。
![画面収録-2024-08-31-17.52.42.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412378/8c24e151-a465-9a72-c214-04675877a762.gif)

まずは表示させるメニューコンポーネントを作成します。

```src/components/ContextMenu/index.tsx

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コンポーネントで呼び出します。

```src/components/DropZone/index.tsx

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をグルーピングする
![画面収録-2024-09-01-13.17.00.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412378/6a4b16e6-1d85-c12e-1f26-fed9e53f753c.gif)


ReactFlowには標準でグルーピングの方法が提供されていますが、その詳細はPro版ライセンスホルダーしか閲覧することができません。
このライセンス料が非常に高いので、個人開発では手が出せないので私なりにグルーピングする方法を考えてみました。
まずは、storeに`onNodeDragStop`イベントを追加します。

```src/common/store/reactFlowStore.ts
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`は以下のように定義しています。

```src/common/utils/Node.ts
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に追記すれば完成です。

```src/components/DropZone/index.tsx
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もバンバン更新予定です。