基于乐吾乐meta2d从零实现可视化流程图编辑器(十)

1,170 阅读6分钟

概要

可视化编辑器已成为前端发展趋势,相关产品层出不穷,但是用户较难根据自身需求去完整实现一个功能较为全面的可视化编辑器,我将采用乐吾乐开源的meta2d.js可视化库来实现一个简单的流程图编辑器,通过这个案例来介绍meta2d的相关功能,并向读者展示如何用meta2d从零出发搭建一个较为完整的项目,让我们在实际项目中来体验meta2d的强大之处吧。

$MUTB]6JJ667KR$SC95VH(1.png

什么是乐吾乐meta2d.js

meta2d是乐吾乐开源的2D图元组成的可视化引擎,集实时数据展示、动态交互、数据管理等一体的全功能2D可视化引擎。能够快速实现数字孪生、大屏可视化、Web组态、SCADA等解决方案。具有实时监控、多样、变化、动态交互、高效、可扩展、支持自动算法、跨平台等特点,最大程度减少研发和运维的成本,并致力于普通业务人员 0 代码开发实现物联网、工业互联网、电力能源、水利工程、智慧农业、智慧医疗、智慧城市等可视化解决方案。

乐吾乐已将其meta2d核心库完全免费开源,本系列教程就是基于meta2d从零实现web端可视化流程图编辑器。

乐吾乐 meta2d开源项目地址:github.com/le5le-com/m…

乐吾乐 meta2d官方文档:doc.le5le.com/document/11…

项目地址

此可视化流程图编辑器项目地址:github.com/Grnetsky/me…

在线体验地址: editor.xroot.top/

往期教程

  1. 基本环境搭建: juejin.cn/spost/72617…
  2. 主界面布局及其初始化: juejin.cn/post/726219…
  3. Meta2d核心库图元注册流程及相关概念: juejin.cn/spost/72629…
  4. 侧边栏功能开发:juejin.cn/post/726441…
  5. Nav组件功能实现:juejin.cn/post/726495…
  6. Nav组件扩展-添加工具栏:juejin.cn/post/726569…
  7. setting组件框架搭建及其Map组件实现:juejin.cn/post/726741…
  8. 引入自定义图元库详解:juejin.cn/post/726794…
  9. Global全局配置组件实现:juejin.cn/post/726934…

10.初识PenProps及其Appearance组件实现

初始PenProps

PenProps主要与图元相关属性有关,针对的是单个图元,它包含了图元的外观、事件、动画效果相关内容,这些内容分别对应了Appearance组件、Event组件、Animate组件,我们可以利用这些属性对单个图元做定制化的编辑,meta2d提供了setValue方法来设置图元属性,并且默认情况下会自动触发meta2d.render函数,下面是官方解释:

image.png

我们只需要调用setValue函数就能立即更新图元数据及图纸界面,由于图元属性较多,防止内容冗余,这里就不一一列举下来,请查看官网,在这里我们只实现部分属性的修改,下面,让我们来看看如何实现吧

Appearance组件实现

Appearance组件作用是展现图元的外观相关内容,他包含了图元的位置大小、样式设置、文字内容和禁止内容四个部分,在动手实现之前让我们先来分析分析要达到什么目的以及如何设计代码,首先与之前编写Global配置组件一样,我们同样需要封装一个函数去操作setvalue来设置属性来避免代码冗余,另一方面我们需要额外注意的是,若用户对单个图元进行点击编辑我们当然很好处理,只需要把关于该图元的信息在Appearance组件中渲染即可,可是对于用户框选行为,进行批量选择的时候我们应该展示哪些字段给用户批量设置?可选方案很多,你完全可以去展示所选图元的公有属性,或者展示固定属性,在这里为了简化逻辑,我们将展示固定的属性,我们引入一个标记位来作为该字段是否应该在多选时进行显示,说的很绕,但是熟悉代码的你一看应该就会明白,我们来看看代码 (此处代码较长,为了读者更全面的知道相关属性怎么做所以全部摘录下来了)

<script setup>
import Form from "../Form.vue";
import {computed, onMounted, reactive, ref, toRaw, watch} from "vue";
import {appearanceProps} from "../../data/defaultsConfig.js";
import {mergeProps} from "../../data/utils.js";  // 合并图元属性
import {deepClone} from "@meta2d/core";

// 记录是否有选中多个图元
const multiPen = ref(false)
const defaultConfig = deepClone(appearanceProps)  //深拷贝保存默认配置
let m = reactive(appearanceProps) // 响应式数据源
let activePen = {}

// 更新属性方法
function updateFunc(prop){
  return (value)=>{
    if(multiPen.value){
      for(let i of activePen){
        meta2d.setValue({
          id:i.id,
          [prop]:value
        },{render:false})
      }
      meta2d.render()
    }else{
      meta2d.setValue({
        id:activePen.id,
        [prop]:value
      })
    }
  }
}

onMounted(()=>{
  meta2d.on('active',(args)=>{
    // 只修改一个
    if(args.length>=1){
      multiPen.value = args.length > 1;
      if(multiPen.value){ // 批量修改
        activePen = reactive(args)
        // 以最后一个图元信息为主
        for(let i of activePen){
          mergeProps(m,i)
        }
      }else{  // 修改一个
        activePen=reactive(args[0])
        mergeProps(m,defaultConfig)
        mergeProps(m,activePen)
        const penRect = meta2d.getPenRect(toRaw(activePen))
        Object.assign(m,penRect)
      }
    }
  })
  // 更新数据  合并多个事件(meta2d并未提供一个监听图元的状态改变的事件,需要我们自行处理)
  meta2d.on('update',()=>{
    meta2d.emit('editPen')
  })
  meta2d.on('resizePens',()=>{
    meta2d.emit('editPen')
  })
  meta2d.on('rotatePens',()=>{
    meta2d.emit('editPen')
  })
  meta2d.on('valueUpdate',()=>{
    meta2d.emit('editPen')
  })
  meta2d.on('editPen',()=>{
    if(multiPen.value){
      // 若有多个图元,则展示数据以最后一个图元为主
      for(let i of activePen){
        mergeProps(m,i)
      }
    }else {
      mergeProps(m,activePen)
    }
  })
})

const map = [  {    title:"位置与大小",    multiShow:false,   // 多选时是否显示    children:[      {        title:"x",        type:"number",        prop:"x",        option:{          placeholder:"px"        },        bindProp:m,        event:"change",        func(value){  // 此属性不能直接通过setValue有效修改          meta2d.setPenRect(toRaw(activePen),{x:value,y:activePen.y,width:activePen.width,height:activePen.height},false)          meta2d.canvas.calcActiveRect()          mergeProps(m,activePen)          meta2d.render()        }      },      {        title:"y",        type:"number",        prop:"y",        option:{          placeholder:"px"        },        bindProp:m,        event:"change",        func(value){   // 此属性不能直接通过setValue有效修改          meta2d.setPenRect(toRaw(activePen),{x:activePen.x,y:value,width:activePen.width,height:activePen.height},false)          meta2d.canvas.calcActiveRect()          mergeProps(m,activePen)          meta2d.render()        }      },      {        title:"宽度",        type:"number",        prop:"width",        bindProp:m,        option: {          min: 0        },        event:"change",        func(value){          if(activePen.ratio){  // 手动实现锁定宽高比            meta2d.setValue({                   id:activePen.id,              width:value,              height:value / activePen.width * activePen.height            })          }else{            meta2d.setValue({              id:activePen.id,              width:value            })          }          mergeProps(m,activePen)        }      },      {        title:"高度",        type:"number",        prop:"height",        bindProp:m,        event:"change",        func(value){          if(activePen.ratio){   // 手动实现锁定宽高比            meta2d.setValue({              id:activePen.id,              height:value,              width:value / activePen.height * activePen.width            })          }else{            meta2d.setValue({              id:activePen.id,              height:value            })          }          mergeProps(m,activePen)        }      },      {        title:"锁定宽高比",        type:"switch",        prop:"ratio",        bindProp:m,        event:"change",        func(value){          activePen.ratio = value          meta2d.render()          mergeProps(m,activePen)        }      },      {        title:"圆角",        type:"number",        prop:"borderRadius",        bindProp:m,        event:"change",        option:{          placeholder: "<1为比例",          min:0        },        func:updateFunc("borderRadius")      },      {        title:"旋转",        type:"number",        prop:"rotate",        bindProp:m,        event:"change",        option:{          placeholder: "角度",        },        func:updateFunc("rotate")      },      {        title:"内边距上",        type:"number",        prop:"paddingTop",        bindProp:m,        event:"change",        option:{          placeholder: "px",        },        func:updateFunc("paddingTop")      },      {        title:"内边距下",          type:"number",          prop:"paddingBottom",          bindProp:m,          event:"change",          option:{            placeholder: "px",          },        func:updateFunc("paddingBottom")      },      {        title:"内边距左",        type:"number",        prop:"paddingLeft",        bindProp:m,        event:"change",        option:{          placeholder: "px",        },        func:updateFunc("paddingLeft")      },      {        title:"内边距右",        type:"number",        prop:"paddingRight",        bindProp:m,        event:"change",        option:{          placeholder: "px",        },        func:updateFunc("paddingRight")      },      {        title:"进度",        type:"number",        prop:"progress",        bindProp:m,        event:"change",        option:{          placeholder: "px",          min:0,          step:0.1,          max:1        },        func:updateFunc("progress")      },      {        title:"垂直进度",        type:"switch",        prop:"verticalProgress",        bindProp:m,        event:"change",        func:updateFunc("verticalProgress")      },      {        title:"水平翻转",        type:"switch",        prop:"flipX",        bindProp:m,        event:"change",        func:updateFunc("flipX")      },      {        title:"垂直翻转",        type:"switch",        prop:"flipY",        bindProp:m,        event:"change",        func:updateFunc("flipY")      },    ]
  },
  {
    title:"样式",
    multiShow:true,
    children:[
      {
        title:"线条样式",
        type:"select",
        multiShow:true,
        prop:"dash",
        option:{
          placeholder:"线条样式",
          list:[
            {
              label:"直线",
              template:"<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" style=\"height: 20px;width: 80px;\">\n" +
                  "                  <g fill=\"none\" stroke=\"black\" stroke-width=\"1\">\n" +
                  "                    <path d=\"M0 9 l85 0\"></path>\n" +
                  "                  </g>\n" +
                  "                </svg>",
              value:0
            },
            {
              label:"虚线",
              template:"<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" style=\"height: 20px;width: 80px;\">\n" +
                  "                  <g fill=\"none\" stroke=\"black\" stroke-width=\"1\">\n" +
                  "                    <path stroke-dasharray=\"5 5\" d=\"M0 9 l85 0\"></path>\n" +
                  "                  </g>\n" +
                  "                </svg>",
              value:1
            },
            {
              label:"点横线",
              template:"<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" style=\"height: 20px;width: 80px;\">\n" +
                  "                  <g fill=\"none\" stroke=\"black\" stroke-width=\"1\">\n" +
                  "                    <path stroke-dasharray=\"10 10 2 10\" d=\"M0 9 l85 0\"></path>\n" +
                  "                  </g>\n" +
                  "                </svg>",
              value:2
            },

          ]
        },
        bindProp:m,
        event:"change",
        func(value){
          const dash = [              [0,0],
              [5,5],
              [10,10,2,10]
          ]
          if(multiPen.value){
            for(let i of activePen){
              meta2d.setValue({
                id:i.id,
                lineDash:dash[value]
              },{render:false})
            }
            meta2d.render()
          }else{
            activePen.dash = value
            meta2d.setValue({
              id:activePen.id,
              lineDash:dash[value]
            })
          }
        }
      },
      {
        title:"连接样式",
        type:"select",
        multiShow:true,
        option:{
          placeholder: "连接样式",
          list:[
            {
              label:"默认",
              value:"miter"
            },
            {
              label:"圆形",
              value: "round"
            },
            {
              label:"斜角",
              value:"bevel"
            }
          ]
        },
        prop:"lineJoin",
        bindProp:m,
        event:"change",
        func:updateFunc("lineJoin")
      },
      {
        title:"末端样式",
        type:"select",
        multiShow:true,
        option:{
          placeholder: "末端样式",
          list:[
            {
              label:"默认",
              value:"butt"
            },
            {
              label:"圆形",
              value: "round"
            },
            {
              label:"方形",
              value:"square"
            }
          ]
        },
        prop:"lineCap",
        bindProp:m,
        event:"change",
        func:updateFunc("lineCap")
      },
      {
        title:"颜色",
        type:"color",
        multiShow:true,
        prop:"color",
        bindProp:m,
        event:"change",
        func:updateFunc("color")
      },
      {
        title:"浮动颜色",
        type:"color",
        multiShow:true,
        prop:"hoverColor",
        bindProp:m,
        event:"change",
        func:updateFunc("hoverColor")
      },
      {
        title:"选中颜色",
        type:"color",
        multiShow:true,
        prop:"activeColor",
        bindProp:m,
        event:"change",
        func:updateFunc("activeColor")
      },
      {
        title:"线条宽度",
        type:"number",
        multiShow:true,
        prop:"lineWidth",
        bindProp:m,
        event:"change",
        func:updateFunc("lineWidth")
      },
      {
        title:"背景颜色",
        type:"color",
        multiShow:true,
        prop:"background",
        bindProp:m,
        event:"change",
        func:updateFunc("background")
      },
      {
        title:"浮动背景颜色",
        type:"color",
        multiShow:true,
        prop:"hoverBackground",
        bindProp:m,
        event:"change",
        func:updateFunc("hoverBackground")
      },
      {
        title:"选中背景颜色",
        type:"color",
        multiShow:true,
        prop:"activeBackground",
        bindProp:m,
        event:"change",
        func:updateFunc("activeBackground")
      },
      {
        title:"透明度",
        type:"number",
        multiShow:true,
        prop:"globalAlpha",
        bindProp:m,
        option:{
          min:0,
          step:0.1,
          max:1
        },
        event:"change",
        func:updateFunc("globalAlpha")
      },
      {
        title:"锚点颜色",
        type:"color",
        prop:"anchorColor",
        bindProp:m,
        event:"change",
        func:updateFunc("anchorColor")
      },
      {
        title:"锚点半径",
        type:"number",
        prop:"anchorRadius",
        bindProp:m,
        option:{
          min:0,
          step:1,
          max:10
        },
        event:"change",
        func:updateFunc("anchorRadius")
      },
      {
        title:"阴影颜色",
        type:"color",
        prop:"shadowColor",
        bindProp:m,
        event:"change",
        func:updateFunc("shadowColor")
      },
      {
        title:"阴影模糊",
        type:"number",
        prop:"shadowBlur",
        bindProp:m,
        option:{
          min:0,
          step:1,
          max:Infinity
        },
        event:"change",
        func:updateFunc("shadowBlur")
      },
      {
        title:"阴影x偏移",
        type:"number",
        prop:"shadowOffsetX",
        bindProp:m,
        event:"change",
        func:updateFunc("shadowOffsetX")
      },
      {
        title:"阴影y偏移",
        type:"number",
        prop:"shadowOffsetY",
        bindProp:m,
        event:"change",
        func:updateFunc("shadowOffsetY")
      },
      {
        title:"文字阴影",
        type:"switch",
        prop:"textHasShadow",
        bindProp:m,
        event:"change",
        func:updateFunc("textHasShadow")
      },
      ]
  },
  {
    title:"文字",
    multiShow:true,
    children:[
      {
        title:"字体名",
        type:"select",
        multiShow:true,
        prop:"fontFamily",
        option:{
          placeholder:"请选择字体",
          list:[
            {
              label:"宋体",
              value:"宋体"
            },{
              label: "黑体",
              value: "黑体"
            }
          ]
        },
        bindProp:m,
        event:"change",
        func:updateFunc("fontFamily")

      }, {
        title:"字体大小",
        type:"number",
        multiShow:true,
        prop:"fontSize",
        bindProp:m,
        event:"change",
        func:updateFunc("fontSize")

      },
      {
        title:"字体颜色",
        type:"color",
        multiShow:true,
        prop:"textColor",
        bindProp:m,
        event:"change",
        func:updateFunc("textColor")

      },
      {
        title:"浮动字体颜色",
        type:"color",
        multiShow:true,
        prop:"hoverTextColor",
        bindProp:m,
        event:"change",
        func:updateFunc("hoverTextColor")

      },
      {
        title:"选中字体颜色",
        type:"color",
        multiShow:true,
        prop:"activeTextColor",
        bindProp:m,
        event:"change",
        func:updateFunc("activeTextColor")

      },
      {
        title:"文字背景颜色",
        type:"color",
        multiShow:true,
        prop:"textBackground",
        bindProp:m,
        event:"change",
        func:updateFunc("textBackground")

      },
      {
        title:"水平对齐",
        type:"select",
        multiShow:true,
        prop:"textAlign",
        option:{
          placeholder:"请选择对齐方式",
          list:[
            {
              label:"左对齐",
              value:"left"
            },{
              label:"居中对齐",
              value:"center"
            },{
              label:"右对齐",
              value:"right"
            }
          ]
        },
        bindProp:m,
        event:"change",
        func:updateFunc("textAlign")

      },
      {
        title:"垂直对齐",
        type:"select",
        multiShow:true,
        prop:"textBaseline",
        option:{
          placeholder:"请选择对齐方式",
          list:[
            {
              label:"顶部对齐",
              value:"top"
            },{
              label:"居中对齐",
              value:"center"
            },{
              label:"底部对齐",
              value:"bottom"
            }
          ]
        },
        bindProp:m,
        event:"change",
        func:updateFunc("textBaseline")
      },
      {
        title:"行高",
        type:"number",
        multiShow:true,
        option:{
          step:0.1
        },
        prop:"lineHeight",
        bindProp:m,
        event:"change",
        func:updateFunc("lineHeight")

      },
      {
        title:"换行",
        type:"select",
        multiShow:true,
        prop:"whiteSpace",
        option:{
          placeholder:"请选择换行方式",
          list:[
            {
              label:"默认",
              value:"nowrap"
            },{
              label:"不换行",
              value:"nowrap"
            },{
              label:"回车换行",
              value:"pre-line"
            },
            {
              label:"永远换行",
              value:"break-all"
            }
          ]
        },                                                                                                                                                                      
        bindProp:m,
        event:"change",
        func:updateFunc("whiteSpace")

      },
      {
        title:"文字宽度",
        type:"number",
        multiShow:true,
        option:{
          min:0,
        },
        prop:"textWidth",
        bindProp:m,
        event:"change",
        func:updateFunc("textWidth")

      },
      {
        title:"文字高度",
        type:"number",
        multiShow:true,
        option:{
          min:0,
        },
        prop:"textHeight",
        bindProp:m,
        event:"change",
        func:updateFunc("textHeight")

      },
      {
        title:"超出省略",
        type:"switch",
        prop:"ellipsis",
        bindProp:m,
        event:"change",
        func:updateFunc("ellipsis")

      },
      {
        title:"隐藏文字",
        type:"switch",
        prop:"hiddenText",
        bindProp:m,
        event:"change",
        func:updateFunc("hiddenText")
      },
      {
        title:"文字内容",
        type:"input",
        option:{
          type:"textarea"
        },
        prop:"text",
        bindProp:m,
        event:"input",
        func:updateFunc("text")
      }
    ]
  },
  {
    title:"禁止",
    multiShow:false,
    children:[
      {
        title:"禁止输入",
        type:"switch",
        prop:"disableInput",
        bindProp:m,
        event:"change",
        func:updateFunc("disableInput")

      },
      {
        title:"禁止旋转",
        type:"switch",
        prop:"disableRotate",
        bindProp:m,
        event:"change",
        func:updateFunc("disableRotate")

      },
      {
        title:"禁止缩放",
        type:"switch",
        prop:"disableSize",
        bindProp:m,
        event:"change",
        func:updateFunc("disableSize")

      },
      {
        title:"禁止锚点",
        type:"switch",
        prop:"disableAnchor",
        bindProp:m,
        event:"change",
        func:updateFunc("disableAnchor")

      }
    ]
  },
]

// 根据用户是否多选动态计算展示字段列表
let showMap = computed(()=>{
  if(multiPen.value){
    return map.filter(i=>{
       i.multiShow?i.children = i.children.filter(item=>item.multiShow):""
      return i.multiShow
    })
  }
  return map
})

</script>

<template>
  <div class="appearanceProps">
    <Form :form-list="showMap"></Form>
  </div>
</template>

<style scoped>
</style>

与之前Global组件不同的是,我们的展示列表是通过计算属性得到的,这样可以灵活根据用户选择状态进行展示模式的切换。我们的预期效果是当用户修改图元属性时,或者拖动旋转缩放图元时,Appearance组件能够实时更新图元属性数据,该功能的实现需要我们监听自定义事件editPen,然后在回调函数中进行相关属性的写入,而在进行多选时,应该展示最后一个选择的图元的属性,meta2d并未提供一个监听图元状态改变的事件,需要我们自行组合事件自定义事件ediytPen。

meta2d.on('update',()=>{
    meta2d.emit('editPen')
  })
  meta2d.on('resizePens',()=>{
    meta2d.emit('editPen')
  })
  meta2d.on('rotatePens',()=>{
    meta2d.emit('editPen')
  })
  meta2d.on('valueUpdate',()=>{
    meta2d.emit('editPen')
  })
  meta2d.on('editPen',()=>{
    if(multiPen.value){
      // 若有多个图元,则展示数据以最后一个图元为主
      for(let i of activePen){
        mergeProps(m,i)
      }
    }else {
      mergeProps(m,activePen)
    }
  })
})

在设置图元位置信息时,不能直接通过setValue进行设置,参考官方说明,另外,进行图元宽高比锁定时,他仅仅限制了图元的缩放行为,并未限制图元宽高数据修改行为,也就是说,当我们手动去修改图元宽高时,宽高比锁定效果是不生效的,只有在我们用鼠标去缩放图元才生效,所以在代码上我们也需要单独处理

 {
    title:"高度",
    type:"number",
    prop:"height",
    bindProp:m,
    event:"change",
    func(value){
      if(activePen.ratio){   // 手动实现锁定宽高比
        meta2d.setValue({
          id:activePen.id,
          height:value,
          width:value / activePen.height * activePen.width
        })
      }else{
        meta2d.setValue({
          id:activePen.id,
          height:value
        })
      }
      mergeProps(m,activePen)
    }
  },

image.png

另外还需要注意的是,我们的数据源来自图元,可能来自图元pen自身,也可能来自图元pen的calculative属性,所以我们需要编写mergeProps函数去处理数据合并问题,详细代码如下

// utils.js
export function mergeProps(target,resource) {
  for(const i in target){
      if(resource[i]){
          target[i] = resource[i]
      }else {
          target[i] = resource.calculative?.[i]
      }
      // 若无该属性,初始化属性
      if(!target[i]){
          switch (typeof target[i]) {
              case "string":
                  target[i] = ""
                  break
              case "number":
                  target[i] = 0
                  break
              case "boolean":
                  target[i] = false
                  break
          }
      }
  }
}

现在,我们的Appearance组件基本上就讲清楚了,下面让我们来看看实际效果

appearance 00_00_00-00_00_30.gif

由于时长问题,没办法一一验证功能,读者可自行下载源码测试。

总结

在本章,我们介绍了PenProps的相关内容,着手实现了Appearance组件,为图元相关属性提供了展示和修改的界面,然后我们还对多选批量修改做了兼容处理,使得编辑器能够实现更多功能,在API方面,我们了解了meta2d的setValue方法的作用和应用场景,也要注意 并非所有属性都能通过setValue有效修改,详细还得参考官方文档的概述,在下一章,我们将讲解meta2d事件消息相关内容,我们能够通过下一章的学习完成更多复杂且有趣的效果,我们下章见。

Meta2d.js 开源地址

给大家推荐一下 Meta2d.js是一个实时数据响应和交互的2d引擎,可用于Web组态,物联网,数字孪生等场景。

Github:github.com/le5le-com/m…

Gitee: gitee.com/le5le/meta2…

如果本篇文章帮助到了你,欢迎为meta2d项目star点星。