「AntV」如何用AntV X6实现跟ProcessOn一样的思维导图?

4,239 阅读9分钟

最近有个需求,需要用x6实现一个思维导图,交互类似于processOn,并封装成组件(阅读此文章前,请了解一些x6的知识)。 技术站点:AntV x6,vue,javascript, css

注意:此文章不会提供全部源码,只是提供解决思路跟部分关键代码,很多坑,里面有提到一部分,其他的大家实践中可以自己探索一下。然后代码跟样式都是最初版本,没有经过优化,大家看着去优化优化 思维导图.png 效果图: 非编辑模式(两种连接器) 1689577997992(1).jpg 1689578048081(1).jpg 编辑模式 1689578022179(1).jpg

1689578151572(1).jpg

组件具体需求

  • 1:传入数据渲染一个树形结构的数据
  • 2:传入数据支持修改节点文字颜色,文字大小等属性(简单暂不说)
  • 3:支持节点文字编辑 (双击编辑节点)
  • 4:支持几种子节点的布局算法(TB,BT,LR,RL)
  • 5:支持几种连接器类型(path曲线,连接两个节点)
  • 7:支持增加子节点,删除节点(每个节点有个+,点击可以新增节点,删除可以按backspace或者右击出现操作面板,选择删除操作)
  • 8:支持调整子节点的顺序(节点拖拽到目标节点可跟目标节点相互交换数据)
  • 9:支持节点的样式定义
  • 10:支持画布拖拽、缩放
  • 11:编辑模式跟非编辑模式(非编辑模式,仅支持查看,新增删除,编辑节点等操作都不支持)
  • 12:支持节点收缩
  • 以上具体交互请参考processOn

需求一:如何渲染一个树形结构?

一棵树由传入组件的数据,节点(父节点:type = 'parent',子节点:type="child",以及另一种特殊节点,双击编辑文案时,出现的输入框),边,以及连接器组成(数据模式已修改成只需要配置父节点类型,子节点不需要设置type字段)

1.数据

<template> 
<div style="height: 300px; width: 100%"> 
   <Mindmap :mindData="mindData" lineStroke="#A2B1C3" ></Mindmap> 
</div> 
</template> 
<script> 
export default { name: "MindmapDemo", 
data() {
return 
{
mindData: { 
id: "1", 
type: "parent", 
label: "中心主题", 
children: [ 
    { 
    id: "1-1", label: "分支主题1", 
     children: [ 
        { id: "1-1-1", label: "子主题1" }, 
        { id: "1-1-2", label: "子主题2" },
        ], 
     },
    { id: "1-2", label: "分支主题2"}, 
 ], },
 } 
 }
}; 
</script>

2. 节点创建

X6是基于 SVG 的渲染引擎,内置节点有Rect,CircleHTML等各种类型,但是因为我的需求对样式要求比较高,所以我选择的是html节点进行注册

// 节点注册
// nodeName: 节点名称,对应节点类型
export const creatHtmlDom = (nodeName) => {
  if (nodeName in Shape.HTML.shapeMaps) {
    return;
  }
  Shape.HTML.register({
    shape: nodeName,
    // propsData: 组件传入的所有属性,相当于this.$props
    // data: mindData,传入组件的节点数据
    effect: ["data", "propsData"],
    html: (cell) => {
      const { nodeData, propsData } = cell.getData();
      if (nodeName == "edit") {
        // 创建编辑节点输入框,设置样式 
        return editDom(nodeData, propsData);
      } else if (nodeName == "child") {
      // 子节点样式
        return domChildContent(nodeData, propsData);
      } else if (nodeName == "parent") {
      // 父节点样式
        return domParentContent(nodeData, propsData);
      }
    },
  });
};

mounted中使用(节点名称可以加组件前缀优化,暂时先这样)

creatHtmlDom("parent");
creatHtmlDom("child");
creatHtmlDom("edit");

3. 边

注意:边要加key,key唯一,不然当你更改子组件布局方向时(比如横向布局改成纵向布局),由于你首次注册的是横向的布局,当你更改成纵向时,没有对应的连接器,会导致边的走向出现问题!!

export const registerEdge = (params, key) => {
  const { lineStroke, strokeWidth ,connector} = params;
  if (connector == "orgEdge") {
    Graph.registerEdge(
      "mindmap-org-edge",
      {
        zIndex: -1,
        attrs: {
          line: {
            sourceMarker: null,
            targetMarker: null,
            stroke: lineStroke,
            strokeWidth: strokeWidth,
          },
        },
      },
      true
    );
  } else {
    Graph.registerEdge(
      "mindmap-edge-" + key,
      {
        inherit: "edge",
        connector: {
          name: "mindmap-" + key,
        },
        attrs: {
          line: {
            targetMarker: "",
            stroke: lineStroke,
            strokeWidth: strokeWidth,
          },
        },
        zIndex: 0,
      },
      true
    );
  }
};

mounted中使用:

registerEdge(this.$props, this.key);

4. 连接器

x6中其实有现成的连接器,但是因为需求需要的线条跟提供的不太一致,所以只能自己写,哭 可以自己去学习一下path

我定义的连接器类型:compact:紧凑连接器(默认),loose:疏松连接器,orgEdge:组织结构连接器(这个用的x6现成的,所以不需要自定义)

export const registerConnector = (params, key) => {
  const { direction, connector } = params;
  // 连接器
  Graph.registerConnector(
    "mindmap-" + key,
    (sourcePoint, targetPoint, routerPoints, options) => {
      const params = {
        sourcePoint: sourcePoint,
        targetPoint: targetPoint,
        routerPoints: routerPoints,
        options: options,
        direction,
      };
      if (connector == "loose") {
        return looseConnector(params);
      } else {
        return compactConnector(params);
      }
    },
    true
  );
};
export const looseConnector = (params) => {
  const { sourcePoint, targetPoint, options, direction } = params;
  const midX = sourcePoint.x;
  const midY = sourcePoint.y;
  let ctrX = (targetPoint.x - midX) / 5 + midX;
  let ctrY = targetPoint.y;

  if (direction == "TB" || direction == "BT") {
    ctrX = targetPoint.x;
    ctrY = (targetPoint.y - midY) / 5 + midY;
  }

  const pathData = `
   M ${sourcePoint.x} ${sourcePoint.y}
   L ${midX} ${midY}
   Q ${ctrX} ${ctrY} ${targetPoint.x} ${targetPoint.y}
  `;
  return options.raw ? Path.parse(pathData) : pathData;
};

export const compactConnector = (params) => {
  const { sourcePoint, targetPoint, direction } = params;
  let hgap = Math.abs(targetPoint.x - sourcePoint.x);

  const path = new Path();
  path.appendSegment(Path.createSegment("M", sourcePoint.x, sourcePoint.y));
  path.appendSegment(Path.createSegment("L", sourcePoint.x, sourcePoint.y));
  let x1 =
    sourcePoint.x < targetPoint.x
      ? sourcePoint.x + hgap / 2
      : sourcePoint.x - hgap / 2;
  let y1 = sourcePoint.y;
  let x2 =
    sourcePoint.x < targetPoint.x
      ? targetPoint.x - hgap / 2
      : targetPoint.x + hgap / 2;
  let y2 = targetPoint.y;

  if (direction == "TB" || direction == "BT") {
    hgap = Math.abs(targetPoint.y - sourcePoint.y);
    x1 = sourcePoint.x;
    y1 =
      sourcePoint.y < targetPoint.y
        ? sourcePoint.y + hgap / 2
        : sourcePoint.y - hgap / 2;
    x2 = targetPoint.x;
    y2 =
      sourcePoint.y < targetPoint.y
        ? targetPoint.y - hgap / 2
        : targetPoint.y + hgap / 2;
  }
  // 水平三阶贝塞尔曲线
  path.appendSegment(
    Path.createSegment("C", x1, y1, x2, y2, targetPoint.x, targetPoint.y)
  );
  // path.appendSegment(Path.createSegment("L", targetPoint.x + 2, targetPoint.y));

  return path.serialize();
};

5. 如何渲染组件接收的数据?

renderChart(params = {}) {
    // isFirst:首次渲染
    const {isFirst = false } = params;
      if (!this.graph) return;
      let result;
      const _this = this;
      result = Hierarchy.compactBox(this.mindData, {
      // 布局方向(LR左到右,RL右到左,TB上到下,BT下到上)
        direction: this.direction,
        getHeight: (d) => {
          const { newHeight } = displayTextSize(false, d, this.$props);
          return newHeight;
        },
        getWidth: (d) => {
          // 计算节点的宽度
          const { newWidth } = displayTextSize(false, d, _this.$props);
          return newWidth;
        },
        getChildren: (d) => {
          // 此部分是收缩节点时使用
          const hasCollapsed = !!d.collapsed;
          return hasCollapsed ? null : d.children;
        },
        getHGap: () => {
          const flag = this.direction == "LR" || this.direction == "RL";
          return flag ? 40 : 20;
        },
        getVGap: () => {
          const flag = this.direction == "LR" || this.direction == "RL";
          return flag ? 10 : 30;
        },
        getSide: () => {
          return "right";
        },
      });
      const cells = [];

      const traverse = (hierarchyItem) => {
        if (hierarchyItem) {
          const { data, children } = hierarchyItem;
          if (data && Object.keys(data).length) {
            let obj = createNodeData(data, this.$props, this.strokeWidth);
            obj = {
              ...obj,
              x: hierarchyItem.x,
              y: hierarchyItem.y,
            };
            cells.push(this.graph.createNode(obj));

            if (children) {
              children.forEach((item) => {
                const { id } = item;
                setEdges(
                  this.graph,
                  cells,
                  hierarchyItem.id,
                  id,
                  this.direction,
                  this.key,
                  this.connector
                );
                traverse(item);
              });
            }
          }
        }
      };
      traverse(result);
      this.graph.resetCells(cells);
      if (this.connector == "orgEdge") {
        creatOrgEdge(this.graph, this.direction);
      }

      // 重新定位选中元素
      if (cells.length && this.refresh) {
        // 标记位置,当选中一个节点时,节点样式数据会进行变动,触发renderChart刷新,
        // 所以此时需要将上一次选择的节点进行重新定位
        this.resetChoosedNode(isFirst);
      }
      this.refresh = true;
    },

6.计算节点宽高

 // 渲染时计算节点宽高(封装的公共方法)
 export const displayTextSize = (newType, data, props) => {
  const span = document.createElement("span");
  let height = 0;
  let width = 0;

  if (newType !== "parent") {
    span.className = "mindmap__text-child";
  }
  span.style.visibility = "hidden";
  span.style.font = `${data.labelSize || props.labelSize}px Roboto`;
  span.style.display = "inline-block";
  span.innerText = data.label;
  document.body.appendChild(span);
  height = span.getBoundingClientRect().height;
  width = span.getBoundingClientRect().width;
  if (newType == "parent") {
    const borderWidth = data.borderWidth || props.strokeWidth;
    height = height + borderWidth * 2 + 20;
  }

  let iconWidth = 0;
  if (props.isEdit) {
    const iconSize = data.iconSize || props.iconSize;
    iconWidth = Number(iconSize.split("px")[0]);
  }

  const _width = width + iconWidth;
  // _width + 2 : 节点交换时,会添加边框,超出原本的宽度,导致文字换行,所以这边需要加宽2px
  let newWidth = newType == "parent" ? _width + 2 + 20 : _width;

  span.remove();
  return {
    newHeight: height,
    newWidth: newWidth,
  };
};

需求二:如何处理双击编辑节点功能

思路:监听双击事件,双击到目标节点时,将节点的类型替换成edit(前面edit类型节点已经注册,所以替换newType就行) 请看另一篇文章

需求三:节点拖拽交换位置(重点!!!)

其实X6是支持拖拽节点的,所以我原先的想法是想利用x6的节点拖拽,直接将节点拖到目标节点后,给目标节点添加对应的类名,设置对应的样式,但是这方案被老大否了,要求是要跟processOn一样的交互

仔细观察processOn的交互,鼠标按下选中节点,会产生一个透明度不高的节点,即幽灵节点(图一),并随着鼠标的移动而移动,如果拖动到目标节点的上部,节点上边会产生指示器,拖到下面会产生指示器,拖到目标节点中间,只是边框变色(图二)。

1. 思路

    1. 监听节点mousedown事件,生成一个新节点(幽灵节点),绝对定位,节点插入到当前选中节点之前,拥有共同父节点。
  • 2.监听鼠标的mousemove事件,更改虚拟节点的left,top的定位 ,
  • 3.寻找目标节点(离当前虚拟节点最近的节点)
  • 4.分析拖动到目标节点的位置dropType,前面(before),后面(after),自己(inner),这部分需要注意横向跟纵向布局
  • 5.针对不同的dropType,对目标节点添加不同的兄弟节点,兄弟节点的样式覆盖目标节点,产生一个位置指示器
  • 6.拖动完成后,监听mouseup事件,分析如何换数据(交换数据规则:1. 子节点拖至父节点只能作为父节点的子级,不可成为兄弟节点 2. dropType为inner,拖动节点成为目标节点的子级;dropType为before,拖动节点插入到目标节点之前;dropType为after,拖动节点插入到目标节点之后 2.同级且相邻节点交换时,比如左右布局时,后节点拖动到前节点下面,不予处理,相对的,前节点拖动到后节点上面不予处理,因为本来拖动节点就在dropType相对应的位置 )3.当拖动节点为父节点,目标节点为子节点时,不予处理

图一:

1689061848202.jpg 图二:

image.png 效果图(样式有点丑,哈哈哈):

image.png

image.png

2. 监听mousedown

draggable-mixin.js
 data() {
    return {
      dragState: {
        dragging: false, // 拖动状态
        initial: {}, // 初始化数据记录
      },
    };
  },
  mounted() {
    // 幽灵节点
    this.dragState.cloneNode = null;
    // 指示器节点
    this.dragState.lastActiveName = "";
    // 除幽灵节点ghost之外mindmap的节点
    this.allNotGhostNodes = null;
    //除拖动节点外的节点位置信息
    this.allNotGhostNodesConfig = {};
  },
  beforeDestroy() {
    document.removeEventListener("mousemove", this.mousemoveHandler);
    document.removeEventListener("mouseup", this.mouseupHandler);
  },
  
// isEdit是否为编辑模式,非编辑模式不予监听
this.isEdit &&
this.graph.on("node:mousedown", (evt) => {
// 注意:鼠标进行拖动之前需要关闭画布平移操作,不然画布平移事件会导致节点移动功能冲突
  this.graph.disablePanning();
  this.mousedownHandler(evt);
});
mousedownHandler(evt) {
  const { node, e } = evt;
      const isInput = e.target.classList.contains("edit-input");
      const id = node.id;
      // 编辑节点文字时,输入框不可拖动
      if (id && !isInput) {
        // 节点属性赋值
        const el_ = e.target;
        const type = el_.dataset.type;

        if (type == "child") {
          // 处理节点拖拽
          this.dropType = "";
          this.dragState.dragging = true;
          const childNodes = this.$el.querySelectorAll(".mindmap__text-child");
          // 子节点查找
          const target = Array.from(childNodes).find((item) => {
            return item.dataset.id == id;
          });
          if (target) {
            const t = target.getBoundingClientRect();
            const cloneEl = target.cloneNode(true);
            // 生成幽灵节点
            cloneEl.classList.add("mindmap__ghost-node"); // 浮动
            e.target.parentElement.appendChild(cloneEl); // 加入节点

            this.dragState.cloneNode = cloneEl;

            const deviationX = e.clientX - (t.x + t.width / 2);
            const h =
              this.direction == "TB" || this.direction == "BT"
                ? t.height / 2
                : this.heightCenter
                ? t.height / 2
                : t.height;
            const deviationY = e.clientY - t.y - h;
            // 让鼠标选中一个节点进行拖拽时,光标始终保持在节点中间的偏移量
            this.dragState.initial.deviationX = deviationX;
            this.dragState.initial.deviationY = deviationY;
            // 点击时光标位置
            this.dragState.initial.clientX = e.clientX;
            this.dragState.initial.clientY = e.clientY;
            // 注意,放大缩小时,路径变化
            this.zoom = 1 / this.graph.zoom();
            const node1 = Array.from(
              this.$el.querySelectorAll(".mindmap__wrap-parent")
            );
            const node2 = Array.from(
              this.$el.querySelectorAll(".mindmap__text-child")
            ).filter((node) => !node.classList.contains("mindmap__ghost-node"));
            this.allNotGhostNodes = node1.concat(node2);
            
            // 获取除幽灵节点以外的所有节点的定位信息,用于计算最近节点跟拖动位置判断
            this.allNotGhostNodes.forEach((item) => {
              this.allNotGhostNodesConfig[item.dataset.id] =
                item.getBoundingClientRect();
            });
          }
          document.addEventListener("mousemove", this.mousemoveHandler);
          document.addEventListener("mouseup", this.mouseupHandler);
        }
      }
 }

3. 监听mousemove

image.png
mousemoveHandler(e) {
 let _clientX = e.clientX;
      let _clientY = e.clientY;
      if (this.dragState.dragging && this.dragState.cloneNode) {
        this.dragState.cloneNode.style.visibility = "visible";
        // 处理元素的移动:改变 left top 定位
        this.dragState.cloneNode.style.left =
          (_clientX -
            this.dragState.initial.clientX +
            this.dragState.initial.deviationX) *
            this.zoom +
          "px";
        this.dragState.cloneNode.style.top =
          (_clientY -
            this.dragState.initial.clientY +
            this.dragState.initial.deviationY) *
            this.zoom +
          "px";

        // 找到离拖动节点最近节点
        this.targetNode = findNearestNode(
          this.allNotGhostNodesConfig,
          this.dragState.cloneNode,
          this.allNotGhostNodes
        );
        if (this.targetNode) {
          // 计算拖动类型
          const dropType = calculateDropType(
            this.dragState.cloneNode,
            this.direction,
            this.targetNode
          );
          this.dropType = dropType;
          // 判断是否允许生成指示器
          const indicatorParams = {
            dropType: dropType,
            mindData: this.mindData,
            dropNodeId: this.dragState.cloneNode.dataset.id,
            targetNodeId: this.targetNode.node.dataset.id,
          };
          // 是否允许生成指示器,原因是由于,比如当前拖动的是父级节点,拖动到它自己的子节点上,这是不允许生成指示器的,不符合拖动条件
          const result = allowCreateIndicator(indicatorParams);
          if (result) {
            if (this.dragState.lastActiveName) {
              removeIndicator(this.$el, this.dragState.lastActiveName);
            }
            // 创建指示器节点
            const params = {
              dropType: this.dropType,
              targetNode: this.targetNode,
              direction: this.direction,
              heightCenter: this.heightCenter,
            };
            const activeName = createIndicator(params);

            this.dragState.lastActiveName = activeName;
          } else {
            removeIndicator(this.$el, this.dragState.lastActiveName);
          }
        }
      }
},

4. 监听mouseup

mouseupHandler(e) {
  // 开启平移
      this.graph.enablePanning();
      this.dragState.dragging = false;
      document.removeEventListener("mousemove", this.mousemoveHandler);
      document.removeEventListener("mouseup", this.mouseupHandler);
      if (
        this.dragState.cloneNode &&
        this.targetNode &&
        this.dropType &&
        this.dragState.lastActiveName
      ) {
        const cloneNodeId = this.dragState.cloneNode.dataset.id;
        // 移动节点跟目标节点相同时,不予处理,并清除生成的虚拟节点
        if (
          cloneNodeId == this.targetNode.node.dataset.id &&
          this.dropType == "inner"
        ) {
          this.clearCloneNodeConfig();
          return;
        }
        const params = {
          dropType: this.dropType,
          dropNodeId: cloneNodeId,
          targetNodeId: this.targetNode.node.dataset.id,
          mindData: this.mindData,
        };
        // 交换节点
        exchangeNode(params);
        // 设置选中节点
        const { node } = findItem(this.mindData, cloneNodeId);
        const { node: node2 } = findItem(this.mindData, params.targetNodeId);
        // 看目标节点是否是收缩状态
        const hasCollapsed = !!node2.collapsed;
        if (node2.children) {
          // 获取目标节点的所有子节点数量
          const count = collapsedNodes(node2.children);
          node2.count = count;
        }
        Vue.set(node2, "collapsed", hasCollapsed);
        if (hasCollapsed && this.dropType == "inner") {
          // 这个是选中节点进行编辑样式的数据
          this.currentData = {};
        } else {
          const choosedParams = {
            node: this.dragState.cloneNode,
            el: this.$el,
          };
          this.refresh = false;
          this.setChoosedNode(choosedParams, node);
          this.resetEditNodeConfig();

          // 更新视图
          this.renderChart();
        }
      }
      
      // 移除cloneNode并清除虚拟节点数据
      this.clearCloneNodeConfig();
      this.dropType = "";
},

5. 寻找最近节点

export const findNearestNode = (
  allNotGhostNodesConfig,
  draggingNode,
  nodes
) => {
  const distances = [];

  const draggingNodeConfig = draggingNode.getBoundingClientRect();

  let targetNode;
  targetNode = nodes.find((node) => {
    const nodeConfig = allNotGhostNodesConfig[node.dataset.id];
    const x1 = draggingNodeConfig.x + draggingNodeConfig.width / 2;
    const x2 = nodeConfig.x + nodeConfig.width / 2;

    let y1 = draggingNodeConfig.y + nodeConfig.height / 2;
    let y2 = nodeConfig.y + nodeConfig.height / 2;

    if (
      x1 >= nodeConfig.x &&
      x1 <= nodeConfig.x + nodeConfig.width &&
      y1 >= nodeConfig.y &&
      y1 <= nodeConfig.y + nodeConfig.height
    ) {
      return true;
    }

    const a = Math.abs(x1 - x2);
    const b = Math.abs(y1 - y2);
    let c = Math.sqrt(a * a + b * b);
    c = Number(c.toFixed(3));
    if (node.dataset.id !== draggingNode.dataset.id) {
      distances.push({
        node: node,
        distance: c,
      });
    }
  });
  if (targetNode) {
    targetNode = {
      node: targetNode,
    };
  } else {
    // 找出离拖动节点最近的节点(即目标节点)
    targetNode = distances[0];
    distances.forEach((item) => {
      if (item.distance < targetNode.distance) {
        targetNode = item;
      }
    });
  }

  return targetNode;

6. 获取droptype

这边需要注意的是,横向跟纵向布局的区别,纵向布局时,拖动节点在目标节点左侧,即为before,在目标节点右侧即为after,其他规则一致

思路就是:用拖动节点跟目标节点的x还有y的位置做判断,得到想要的类型 before,after,inner,有一个值得注意的地方,x越往右数值越大,y越往上数值越小,越往下数据越大 此部分不贴代码了

7. 节点交换

export const exchangeNode = (params) => {
  const { dropType, dropNodeId, targetNodeId } = params;
  const { _targetNode, targetParent, dropChildren, targetChildren } =
    getTargetData(params);
  const _dropChildren = cloneDeep(dropChildren);
  // const _targetChildren = targetChildren && cloneDeep(targetChildren);
  // 找出各自节点对应在父节点子级的位置,并进行交换数据
  const { dropIndex, targetIndex } = findTargetIndex(
    dropChildren,
    targetChildren,
    dropNodeId,
    targetNodeId
  );
  if (dropType == "inner") {
    if (_targetNode.node.type == "parent") {
      _targetNode.node.children.push(_dropChildren[dropIndex]);
    } else {
      Vue.set(
        targetChildren[targetIndex],
        "children",
        targetChildren[targetIndex].children || []
      );
      targetChildren[targetIndex].children.push(_dropChildren[dropIndex]);
    }

    dropChildren.splice(dropIndex, 1);
  } else if (dropType == "before" || dropType == "after") {
    // targetChildren中是否包含当前拖动节点
    const index = targetChildren.findIndex((item) => item.id === dropNodeId);
    if (index > -1) {
      if (dropType == "before") {
        targetParent.children.splice(index, 1);
      }

      targetParent.children.splice(
        targetIndex + (dropType == "after" ? 1 : 0),
        0,
        _dropChildren[dropIndex]
      );
      if (dropType == "after") {
        targetParent.children.splice(index, 1);
      }
    } else {
      // 不包含时,在同级添加节点
      targetParent.children.splice(
        targetIndex + (dropType == "after" ? 1 : 0),
        0,
        _dropChildren[dropIndex]
      );
      dropChildren.splice(dropIndex, 1);
    }
  }
};

这地方不知道怎么插入视频,就不放了,只有上面的截图

需求四:节点样式动态处理

1689153756517.png image.png 细看会发现,点击一个节点,会有个蓝色边框将节点包裹住,可以在点击时,给选中节点添加一个类名,设置对应的样式: outline:2px solid $theme-color;并在点击下一个节点时,移除上一次选中的节点

效果图: 1689576105171.png

需求五:支持节点收缩

juejin.cn/post/728379…

需求六:组件封装

组件支持画布拖动,缩放,节点样式定义,画布背景,布局方向等参数支持,这部分需要自己去考虑哪些是否需要,我列了一点 1689576703545.png 1689577677255(1).jpg

最后,感谢大家阅读我的小文章,请帮我点亮一下我的小心心吧!!!

1689577155763(1).png 1689577224795.png 1689577345238(1).png