在React 中基于 X6 实现一个 ER 图编辑器

2022/8/23 reactx6

X6 是 AntV 旗下的图编辑引擎,提供了一系列开箱即用的交互组件和简单易用的节点定制能力,方便我们快速搭建流程图、DAG 图、ER 图等图应用。

在 X6 文档的图表示例中有一个 ER 图 (opens new window) 的例子,例子实现了怎么通过数据以及 X6 的配置生成一个 ER 图,结合例子以及文档来看看怎么实现一个 ER 图的简单编辑器。编辑器的布局直接用经典的左中右布局方式。

编辑器布局

# 组件列表区域

借助 x6 提供的 拖拽 Dnd (opens new window) 插件可以方便的通过拖拽交互往画布中添加节点。Dnd 是 Addon 命名空间中的一个插件,提供了基础的拖拽能力;Stencil 是 Addon 命名空间中的一个插件,是在 Dnd 基础上的进一步封装,提供了一个类似侧边栏的 UI 组件,并支持分组、折叠、搜索等能力。

由于设定是组件列表中显示的组件和拖入到画布之后的组件区别较大,Stencil 默认把很多东西都封装好了反而不方便进行改造且左侧区域功能较为简单,所以直接使用 Dnd 进行操作。

# 初始化 Dnd

export const Toolbox = ({ target }: { target: Graph }) => {
  const widgets = [{ type: "entity", name: "Entity" }];
  const dnd = useMemo(
    () =>
      new Addon.Dnd({
        target,
        scaled: false,
        getDropNode(node) { // 拖拽结束时,获取放置到目标画布的节点,默认克隆代理节点。
          const type = node.getData();
          // 创建实际要放到画布上的节点
          return Node.create({ shape: type, data: { name: "" } });
        },
      }),
    [target]
  );

  return (
    <div className="toolbox">
      <Collapse className={"collapse"} defaultActiveKey={["general"]}>
        <Panel key={"general"} header={"通用"}>
          <div className={"panel-content"}>
            {widgets.map((widget) => (
              <Widget key={widget.type} widget={widget} dnd={dnd} />
            ))}
          </div>
        </Panel>
      </Collapse>
    </div>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

主要就是创建一个折叠面板把支持的组件渲染到面板上,同时创建一个 Dnd 的实例用于进行拖拽,创建实体的时候定义了 getDropNode 用于创建实际要放到画布上的节点,使用 shape 指定了创建节点的图像是 type 类型的(注意:这个类型要在这之前定义好)。

# 开始拖拽

通过 widget-item 定义组件在列表中展示的样式,在 MouseDown 事件中执行 dnd.start(node, e) 开始拖拽 node 节点到画布上,拖拽的流程可以参考 拖拽细节 (opens new window)

const Widget = ({ dnd, widget }: { dnd: Addon.Dnd; widget: IWidget }) => {
  const onMouseDown = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
    const target = e.currentTarget;
    const type = target.getAttribute("data-type")!;
    dnd.start(
      Node.create({ // 创建一个拖拽过程中显示的节点
        width: 100,
        height: 40,
        attrs: {
          label: {
            text: "Rect",
            fill: "#6a6c8a",
          },
          body: {
            stroke: "#31d0c6",
            strokeWidth: 2,
          },
        },
        data: type,
      }),
      e.nativeEvent
    );
  };
  return (
    <div
      data-type={widget.type}
      className={classNames("widget-item", widget.type)}
      onMouseDown={onMouseDown}
    >
      {widget.name}
    </div>
  );
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

根据上面的拖拽细节我们会发现整体拖拽流程是:源节点 -> 拖拽节点 -> 放置节点,默认是将源节点克隆一份变为拖拽节点,拖拽节点克隆一份变为放置节点

从上述的描述中可以发现,一个节点从组件列表到最后放到画布中是由三个过程中,任意一个过程进行了改变都有可能会影响到最后在画布上的结果(因为默认行为是克隆,比如在 拖拽节点 的时候返回一个其他的节点,最后在画布上的就是 拖拽节点 返回的那个节点)。

假如有一个需求是拖拽的过程中显示的要和最终放到画布上的不是同一个节点,那就需要在 源节点拖拽节点 生成一个过程中的节点,在 放置节点 中才生成最终画布上的节点。

# 画布区域

# 创建画布

useEffect(() => {
  if (graphRef.current) {
    // 最简单的配置,实际配置根据文档来
    const graph = new Graph({
      container: graphRef.current,
    });
    setGraph(graph);
    return () => {
      // React18中会执行两次,所以一定要销毁
      graph?.dispose();
    };
  }
}, []);

// 挂载画布的节点一定要有宽高
// 如果在创建画布的时候没有指定宽高最后创建的结果就是宽高为0的画布
<div className="graph" ref={graphRef}></div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

在 X6 中,Graph 是图的载体,它包含了图上的所有元素(节点、边等),同时挂载了图的相关操作(如交互监听、元素操作、渲染等)。

参考 画布 Graph (opens new window) 在创建画布的时候设置为需要的配置即可。X6 提供了完善的事件系统,很多操作要基于事件系统来进行,比如节点/边的选中、添加节点等。同时,也可以借助 X6 的事件系统触发和监听自定义的事件。

graph.on("node:unselected", () => {
  setSelectedNode(null);
});
graph.on("node:added", ({ node }) => {
  graph.resetSelection(node);
});

// 自定义事件
graph.trigger("custom:test");
graph.on("custom:test", handler);
graph.off("custom:test", handler);
1
2
3
4
5
6
7
8
9
10
11

# 创建节点

# 定义 ER 图节点

ER 图中用到的_er-entity 节点_需要根据 自定义节点 (opens new window) 中给出的内容进行自定义。

调用继承的静态方法 config(options) 来配置 节点选项 (opens new window) 的默认值、自定义选项 (opens new window)自定义属性 (opens new window),最常用选项的是通过 markup (opens new window) 来指定节点默认的 SVG/HTML 结构,通过 attrs (opens new window) 来指定节点的默认属性样式。

markup 指定了渲染节点/边时使用的 SVG/HTML 片段,使用 JSON 格式描述。tagName 指的是 SVG/HTML 元素标签名;selector 指的是 SVG/HTML 元素的唯一标识,通过该唯一标识可以在 attr 中为该元素指定属性样式。

attrs 的选项是一个复杂对象, Key 是节点中 SVG 元素的选择器(Selector),对应的值是应用到该 SVG 元素的 SVG 属性值(如 fill 和 stroke),选择器(Selector)通过节点的 markup 来定义的。创建节点/边后,我们可以调用实例上的 attr() 方法来修改节点属性样式。

// 通过 / 分割的路径修改样式
// label 选择器对应到 <text> 元素,text 则是该元素的属性名,'hello' 是新的属性值
rect.attr('text/text', 'hello')

// 等同于
rect.attr('text', {
  text: 'hello'
})

// 等同于
rect.attr({
  text: {
    text: 'hello'
  }
})

// 当传入的属性值为 null 时可以移除该属性
rect.attr('text/text', null)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

链接桩 (opens new window) 是节点上的固定连接点。推荐的 最佳实践 (opens new window) 是:基于群组将链接桩的通用选项定义为节点的默认选项,在节点中预定义好链接桩的部分属性,用的时候只需要配置差异部分即可。在给出的 ER 图 (opens new window) 的例子中,每个属性就是用 port 定义的,从而可以在不同实体的属性中间拉上连接线。

根据上述内容就可以定义一个名为 er-entity 的自定义节点了

// 自定义布局算法
Graph.registerPortLayout(
  "erPortPosition",
  (portsPositionArgs) => {
    return portsPositionArgs.map((_, index) => {
      return {
        position: {
          x: 0,
          y: (index + 1) * LINE_HEIGHT,
        },
        angle: 0,
      };
    });
  },
  true
);

// 便捷方法注册节点
Graph.registerNode(
  "er-entity",
  {
    inherit: "rect",
    width: 150,
    height: 24,
    // markup: [ // rect 节点默认配置,不需要改
    //   {
    //     tagName: "rect",
    //     selector: "body",
    //   },
    //   {
    //     tagName: "text",
    //     selector: "label",
    //   },
    // ],
    attrs: {
      body: { // 定义 body 选择器的样式
        strokeWidth: 1,
        stroke: "#5F95FF",
        fill: "#5F95FF",
      },
      label: { // 定义 label 选择器的样式
        fontWeight: "bold",
        fill: "#ffffff",
        fontSize: 12,
      },
    },
    ports: { // 定义瞄点
      groups: {
        list: {
          markup: [
            {
              tagName: "rect",
              selector: "portBody",
            },
            {
              tagName: "text",
              selector: "portNameLabel",
            },
            {
              tagName: "text",
              selector: "portTypeLabel",
            },
          ],
          attrs: {
            portBody: {
              width: NODE_WIDTH,
              height: LINE_HEIGHT,
              strokeWidth: 1,
              stroke: "#5F95FF",
              fill: "#EFF4FF",
              magnet: true,
            },
            portNameLabel: {
              ref: "portBody",
              refX: 6,
              refY: 6,
              fontSize: 10,
            },
            portTypeLabel: {
              ref: "portBody",
              refX: 95,
              refY: 6,
              fontSize: 10,
            },
          },
          position: "erPortPosition", // 标签位置
        },
      },
    },
  },
  true
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92

有一个需要关注的点是 Rect 节点的默认值配置中通过 propHooks 这个钩子将 自定义选项 (opens new window) label 应用到 "attrs/text/text" 属性上,所以在后续的操作中只需要更新 attrs 中的 "text/text" 就可以了。

# 属性配置区域

使用 FormRender (opens new window) 作为属性配置面板,只需要处理 schema 定义以及 form 的 setValues 即可。

<div className="node-settings">
  <FormRender
    form={form}
    schema={settingSchema}
    watch={{
      "#": (v) => setTimeout(() => onDataChange(v), 0),
    }}
    />
</div>
1
2
3
4
5
6
7
8
9

X6 的 Cell 中定义了 data 属性用于存放节点/边关联的业务数据,我们同理把业务数据放到 data 中,选中节点改变的时候,从新的节点中获取 data 作为 form 的 value 传给配置面板的 form。

  useEffect(() => {
    if (selectedNode) {
      setSettingSchema({
        type: "object",
        displayType: "column",
        properties: getSchema(selectedNode.shape), // 根据选中节点切换 schema
      });
      setTimeout(() => { // 由于 schema 是异步的,可以重新组织一下结构
        form.setValues(selectedNode.data); // 选中节点的 data 中业务数据作为 value
      }, 0);
    }
  }, [selectedNode]);
1
2
3
4
5
6
7
8
9
10
11
12

在配置后修改后,需要把配置保存到 data 中,如果配置面板中有数据是显示在图中的还要同时更新图中的数据。

 const onDataChange = (value: any) => {
   selectedNode?.setData(value); // 配置存到 data 中
   selectedNode?.attr("text/text", value.name); // 修改 ER 图显示的实体名称
};
1
2
3
4

通过上面这些操作就把示例中直接由数据生成的 ER 图变成了一个由编辑器生成的 ER 图了。可以访问在线Demo (opens new window)查看演示效果,顺便附上Github (opens new window)地址。

# 总结

上述方案虽然实现了一个 ER 图编辑器,但是实际上存在诸如画布调整、工具栏缺失、选中属性编辑很奇怪、连线无法配置、没有悬浮提示、怎么进行数据存取等问题。

X6 提供了丰富的 API 进行各种操作,可以从很多方面进行改进,比如直接用 react 定义一个实体节点可操作性是不是更强呢?连接点的定义是不是可以跳转更好用呢?连接线可以进行更多配置呢?

API 能力丰富意味着能做得事情可以有很多,同时想做好的也不容易,怎么更好的实现还需要对 X6 的机制有更深的理解。

最后在相关的文档上发现了XFlow (opens new window),看着ER 建模解决方案 (opens new window)的效果演示,实现一个ER 图编辑器好像更简单了!