1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React FlowのTips

Posted at

最近、React Flowを使って draw.io ライクなwebアプリを作っているので、そこで獲得できた知見をまとめていきます。
以下の内容はGithubに公開しています。

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

まずはドラッグの開始点となる領域から、定義していきます。

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 に関する内容はこちらをご覧ください。

次に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

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

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

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もバンバン更新予定です。

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?