React Flow + React DND 集成 实现拖拉拽生成关系图

1,956 阅读3分钟

React Flow

React Flow是一个功能强大的 React 组件库,用于构建交互式的可视化图形编辑器和数据流应用程序。它提供了丰富的功能和高度可定制性。我们可以自定义node节点,可以自定义edge边缘。可扩展性很强。官网案例也比较详细。

React DND

用于在 React 应用程序中实现拖放功能。它提供了一套强大的 API,可以帮助开发者轻松地在应用程序中添加拖放功能。官网是说,HTML5的拖放API比较笨重,并且也存来浏览器兼容性问题,这个库是在底层抹平了这些,让开发者更关注与应用开发。

ok,介绍完两个关键库,那么就来说说我们需求是什么, 我们需求是自定义一些组件, 将这些组件拖拽到我们的画布中,然后可以建立互相之间的关系。

  1. 首先我们需要先创建一个基本的布局。
import { Button } from "@nextui-org/react";
import { Canvas, TableBox } from "../../components";

function Index() {
  return (
    <div className="flex">
      <TableBox className="w-fit" />
      <div className="flex flex-col flex-1">
        <div className="border border-solid p-2 flex justify-end">
          <Button color="secondary">生成模型</Button>
        </div>
        <Canvas />
      </div>
    </div>
  );
}

export default Index;

  1. 创建自定义组件,渲染在我们的左侧栏
import TableItem from "./table-item";
import clsx from "clsx";

interface IProps {
  className?: string;
}

function TableBox(props: IProps) {
  const { className } = props;
  return (
    <div
      className={clsx(
        "border border-solid p-4 min-h-full overflow-y-scroll flex flex-col",
        className
      )}
    >
      <div className="grid grid-cols-2 gap-x-4">
        <TableItem id="0" data={{ name: "User" }} />
        <TableItem id="1" data={{ name: "Price" }} />
      </div>
    </div>
  );
}

export { TableBox };

这个组件因为我们是需要拖放到React Flow的画布中,它有固定的props,而我们自定义传值的属性,都是在提供的data对象中。 它提供了 NodeProps ,并接受一个泛型,这个泛型就是我们自定义的data属性, 方便我们定义类型。

Handle组件是我们连接的那个小圆点。

useDrag则是dnd提供的hooks,type是我们当前拖拽的这个类型,而item其中的属性则是我们在Drop时可以获取到的属性。得保证ID唯一。 在画布中,我们会将Props传过来再次渲染这个组件。

import { Card, Image, CardHeader, CardBody } from "@nextui-org/react";
import { useDrag } from "react-dnd";
import { Handle, NodeProps, Position } from "reactflow";
import { DataTableSvg } from "../../assets/icons";

interface DataProps {
  name: string;
  isCanvas?: boolean;
}
type IProps = Partial<NodeProps<DataProps>>;

function TableItem(props: IProps) {
  const { id, isConnectable, data } = props;
  const { name, isCanvas = false } = data || {};
  const [_, drag] = useDrag(() => ({
    type: "table",
    item: { id, ...props.data },
  }));

  return (
    <Card radius="md" ref={isCanvas ? null : drag} className="overflow-visible">
      {isCanvas && (
        <Handle
          type="target"
          position={Position.Top}
          isConnectable={isConnectable}
        />
      )}

      <CardHeader className="pb-0 pt-2 px-4 flex-col items-start">
        <p className="text-tiny uppercase font-bold">{name}</p>
      </CardHeader>
      <CardBody className="overflow-visible py-2">
        <Image
          alt="Card background"
          src={DataTableSvg}
          width={50}
          height={50}
        />
      </CardBody>
      {isCanvas && (
        <Handle
          type="source"
          position={Position.Bottom}
          isConnectable={isConnectable}
        />
      )}
    </Card>
  );
}

export default TableItem;

  1. React-flow 画布组件

这里我使用了自定义node TableItem 和自定义edge modalEdge, 在useDrop的对象drop这个回调函数中,item则是我们刚才在drag传过来的props,然后我们拿到这个props,再去重新渲染这个组件,就形成了闭环。

import { useMemo } from "react";
import { useDrop } from "react-dnd";
import ReactFlow, {
  Controls,
  Background,
  useNodesState,
  useEdgesState,
  addEdge,
  Connection,
} from "reactflow";

import "reactflow/dist/style.css";
import TableItem from "../table-box/table-item";
import ModalEdge from "./modalEdge";

function Canvas() {
  const [nodes, setNodes, onNodesChange] = useNodesState([]);
  const [edges, setEdges, onEdgesChange] = useEdgesState([]);
  const [_, drop] = useDrop(() => ({
    accept: "table",
    drop: (item: any, monitor) => {
      const offset = monitor.getSourceClientOffset();
      const position = {
        x: offset?.x ?? 0,
        y: offset?.y ?? 0,
      };
      setNodes((nds) => [
        ...nds,
        {
          id: item.id,
          type: "talbeItem",
          position,
          data: { isCanvas: true, ...item },
        },
      ]);
    },
  }));

  const onConnect = (params: Connection) => {
    setEdges((eds) => {
      const edge = { ...params, type: "modalEdge" };
      return addEdge(edge, eds);
    });
  };

  const nodeTypes = useMemo(() => ({ talbeItem: TableItem }), []);
  const edgeTypes = useMemo(() => ({ modalEdge: ModalEdge }), []);

  return (
    <div style={{ width: "100%", height: "100vh" }} ref={drop}>
      <ReactFlow
        nodeTypes={nodeTypes}
        edgeTypes={edgeTypes}
        nodes={nodes}
        edges={edges}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onConnect={onConnect}
      >
        <Controls />
        <Background gap={12} size={1} />
      </ReactFlow>
    </div>
  );
}

export { Canvas };

import {
  BaseEdge,
  EdgeLabelRenderer,
  EdgeProps,
  getStraightPath,
  useReactFlow,
} from "reactflow";
import { DeleteSvg } from "../../assets/icons";

type IProps = EdgeProps;

export default function ModalEdge(props: IProps) {
  const { id, sourceX, sourceY, targetX, targetY } = props;
  const { setEdges } = useReactFlow();
  const [edgePath, labelX, labelY] = getStraightPath({
    sourceX,
    sourceY,
    targetX,
    targetY,
  });

  return (
    <>
      <BaseEdge id={id} path={edgePath} />
      <EdgeLabelRenderer>
        <div
          className="flex"
          style={{
            position: "absolute",
            transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
            pointerEvents: "all",
          }}
        >
          <img
            src={DeleteSvg}
            onClick={() => {
              setEdges((es) => es.filter((e) => e.id !== id));
            }}
          />
        </div>
      </EdgeLabelRenderer>
    </>
  );
}