使用流程图插件 vue-super-flow 制作文字拖拽填充效果

背景

最近做了一个在线考试系统,其中有个题型是流程图类型的,考试端大概效果就是把答案选项拖拽到流程图框中,要求能拖动左侧选项,但是不能编辑右侧流程框和连接线。

需要满足简单流程图编辑,最主要是要满足在后台中流程图题型动态配置性,在考试端流程图的展示和答案选项的拖拽。

最开始的想法是把流程图当作背景,然后通过 CSS 相对定位一个个调整元素位置,使用拖拽工具给元素绑定拖拽事件,效果也还能凑合,但是如果流程图类型太多,难以维护,并且不支持后台动态配置。

如果是流程图的拖拽、编辑,基本大多数流程图工具都支持,但是这个拖拽选项到流程图框上填充内容的小小需求没有找到解决方案,那就只能自己解决了,选择的是vue-super-flow这个工具,因为只需要简单的流程图编辑能力,并且这个工具可以在引用时灵活可配置。

觉得挺有意思,记录一下考试端实现过程
效果图

参考

vue-super-flow
vue-super-flow的使用

实现过程

主要讲一下这个 flowChart 组件,上代码

// flowChart.vue
<template>
  <div class="f-super-flow">
    <div class="f-node-container">
      <div
        class="f-node-item"
        :class="!item.label ? 'f-node-item-empty' : ''"
        v-for="(item, index) in nodeItemList"
        :key="index"
        @mousedown="evt => nodeItemMouseDown(evt, item.value, index)"
      >
        {{ item.label }}
      </div>
    </div>
    <div class="f-flow-container" ref="flowContainer">
      <super-flow
        ref="superFlow"
        :draggable="false"
        :linkAddable="false"
        :linkEditable="false"
        :hasMarkLine="false"
        :link-desc="linkDesc"
        :node-list="nodeList"
        :link-list="linkList"
      >
        <template v-slot:node="{ meta }">
          <div 
            v-if="meta.name" 
            class="f-node-del" 
            :style="meta.type == 'judge' ? 'top: 10%;' : ''" 
            @mouseup="evt => nodeMouseUp(evt, meta)"
          >x</div>
          <div 
            class="f-flow-node"
            :class="meta.type? `f-flow-node-${meta.type}`: ''"
          >
            <div class="f-node-content" :title="meta.name">{{ meta.name }}</div>
          </div>
        </template>
      </super-flow>
    </div>
  </div>
</template>

<script>
/**
 * 流程图题
 */
import SuperFlow from "vue-super-flow";
import "vue-super-flow/lib/index.css";

export default {
  name: "flow-chart",
  components: {
    SuperFlow,
  },
  props: {
    optionList: {
      type: Array,
      default: () => [],
    },
    flowChartNode: {
      type: Array,
      default: () => [],
    },
    flowChartLink: {
      type: Array,
      default: () => [],
    }
  },
  data() {
    return {
      // flowChart 节点
      nodeList: [],

      // 线条
      linkList: [],

      // 左侧列表
      nodeItemList: [],

      dragConf: {
        isDown: false,
        isMove: false,
        offsetTop: 0,
        offsetLeft: 0,
        clientX: 0,
        clientY: 0,
        ele: null,
        info: null,
      },

      resetNodeItem: {
        label: '',
        value: {
          meta: {
            label: '',
            name: '',
            type: ''
          }
        }
      }
    }
  },
  computed: {
    
  },
  mounted() {
	// 测试数据
	this.nodeItemList = [
      {
        label: 1,
        value: {
          meta: {
            label: 1,
            name: 1
          }
        }
      },
      {
        label: 3,
        value: {
          meta: {
            label: 3,
            name: 3
          }
        }
      },
      {
        label: 2,
        value: {
          meta: {
            label: 2,
            name: 2
          }
        }
      },
      {
        label: 5,
        value: {
          meta: {
            label: 5,
            name: 5
          }
        }
      },
      {
        label: 4,
        value: {
          meta: {
            label: 4,
            name: 4
          }
        }
      }
    ]
    this.nodeList = [
      {
        id: "N1",
        width: 180,
        height: 50,
        coordinate: [100, 32],
        meta: {
          label: "",
          name: "",
          type: "startAndEnd",
        },
      },
      {
        id: "N2",
        width: 180,
        height: 50,
        coordinate: [100, 139],
        meta: {
          label: "",
          name: "",
          type: "process",
        },
      },
      {
        id: "N3",
        width: 180,
        height: 180,
        coordinate: [100, 236],
        meta: {
          label: "",
          name: "",
          type: "judge",
        },
      },
      {
        id: "N4",
        width: 180,
        height: 50,
        coordinate: [100, 500],
        meta: {
          label: "",
          name: "",
          type: "process",
        },
      },
      {
        id: "N5",
        width: 180,
        height: 50,
        coordinate: [360, 300],
        meta: {
          label: "",
          name: "",
          type: "process",
        },
      },
      {
        id: "N6",
        width: 180,
        height: 50,
        coordinate: [100, 596],
        meta: {
          label: "",
          name: "",
          type: "startAndEnd",
        },
      },
    ]
    this.linkList = [
      {
        id: "linkUkwVhocoTp08AbLQ",
        startId: "N1",
        endId: "N2",
        startAt: [60, 40],
        endAt: [100, 0],
        meta: null,
      },
      {
        id: "linkS27wPzJ1Z7plttsR",
        startId: "N3",
        endId: "N5",
        startAt: [168, 84],
        endAt: [0, 20],
        meta: { desc: "NO" },
      },
      {
        id: "linka8cGGQAPQTtXuYID",
        startId: "N5",
        endId: "N2",
        startAt: [100, 0],
        endAt: [200, 20],
        meta: null,
      },
      {
        id: "linkdNLEL6EcIVijSQx4",
        startId: "N2",
        endId: "N3",
        startAt: [100, 40],
        endAt: [84, 0],
        meta: null,
      },
      {
        id: "link3heD5DMOJbmxcLHu",
        startId: "N3",
        endId: "N4",
        startAt: [84, 168],
        endAt: [100, 0],
        meta: { desc: "YES" },
      },
      {
        id: "linkVpTa6NUNNcG2NKeY",
        startId: "N4",
        endId: "N6",
        startAt: [100, 40],
        endAt: [60, 0],
        meta: null,
      },
    ]

    document.addEventListener("mousemove", this.docMousemove);
    document.addEventListener("mouseup", this.docMouseup);
    this.$once("hook:beforeDestroy", () => {
      document.removeEventListener("mousemove", this.docMousemove);
      document.removeEventListener("mouseup", this.docMouseup);
    });
  },
  methods: {
    linkDesc(link) {
      return link.meta ? link.meta.desc : "";
    },
    docMousemove({ clientX, clientY }) {
      const conf = this.dragConf;
      if (conf.isMove) {
        conf.ele.style.top = clientY - conf.offsetTop + "px";
        conf.ele.style.left = clientX - conf.offsetLeft + "px";
      } else if (conf.isDown) {
        // 鼠标移动量大于 5 时 移动状态生效
        conf.isMove =
          Math.abs(clientX - conf.clientX) > 5 ||
          Math.abs(clientY - conf.clientY) > 5;
      }
    },
    docMouseup({ clientX, clientY }) {
      const conf = this.dragConf;
      conf.isDown = false;

      if (conf.isMove) {
        const { top, right, bottom, left } = this.$refs.flowContainer.getBoundingClientRect();

        // 判断鼠标是否进入 flow container
        if (
          clientX > left &&
          clientX < right &&
          clientY > top &&
          clientY < bottom
        ) {
          // 获取拖动元素左上角相对 super flow 区域原点坐标
          const coordinate = this.$refs.superFlow.getMouseCoordinate( clientX, clientY );
          for(let i = 0;i<this.nodeList.length;i++) {
            let nodeData = this.nodeList[i]
            let xStartPostion = nodeData.coordinate[0]
            let yStartPostion = nodeData.coordinate[1] * 0.5
            let xEndPostion = +nodeData.width + nodeData.coordinate[0]
            let yEndPostion = +nodeData.height + nodeData.coordinate[1]
            
            if((coordinate[0] >= xStartPostion  && coordinate[0] <= xEndPostion) && (coordinate[1] >= yStartPostion  && coordinate[1] <= yEndPostion)) {
              nodeData.meta.label = conf.info.meta.label
              nodeData.meta.name = conf.info.meta.name
              nodeData.meta.index = conf.info.meta.index
              this.$set(this.nodeList,i,nodeData)
              this.nodeItemList[conf.info.meta.index] = this.resetNodeItem
              break; 
            }
          }
        }
        conf.isMove = false;
      }
      if (conf.ele) {
        conf.ele.remove();
        conf.ele = null;
      }
    },
    nodeItemMouseDown(evt, infoFun, index) {
      const { clientX, clientY, currentTarget } = evt;

      const { top, left } = evt.currentTarget.getBoundingClientRect();

      const conf = this.dragConf;
      const ele = currentTarget.cloneNode(true);

      infoFun.meta.index = index
      Object.assign(this.dragConf, {
        offsetLeft: clientX - left,
        offsetTop: clientY - top,
        clientX: clientX,
        clientY: clientY,
        info: infoFun,
        ele,
        isDown: true,
      });

      ele.style.position = "fixed";
      ele.style.margin = "0";
      ele.style.top = clientY - conf.offsetTop + "px";
      ele.style.left = clientX - conf.offsetLeft + "px";

      this.$el.appendChild(this.dragConf.ele);
    },
    
    nodeMouseUp(evt, meta) {
      evt.preventDefault()
      const metaData = { ...meta }
      let selectIndex = this.nodeList.findIndex(item => item.meta.name === meta.name)
      let nodeItem = this.nodeList[selectIndex]
      nodeItem.meta.label = ""
      nodeItem.meta.name = ""
      
      this.$set(this.nodeList, selectIndex, nodeItem)
      this.$set(this.nodeItemList, metaData.index, {
        label: metaData.label,
        value: {
          meta: {
            label: metaData.label,
            name: metaData.name,
          }
        }
      })
    },

    getNodeList() {
      let list = this.$refs.superFlow.toJSON().nodeList
      let arr = []
      list.map(item => {
        arr.push({
          id: item.id,
          name: item.meta.name
        })
      })
      // console.log(arr);
      return arr
    },
  },
};

</script>

<style lang="scss" scoped>
.f-super-flow {
  position: relative;
  display: flex;
  background: #f5f5f5;
  height: 100%;

  .f-node-container {
    width: 20%;
    text-align: center;
    background-color: #ffffff;
    overflow: auto;

    .f-node-item {
      margin: 20px 20%;
      padding: 6px 0;
      border: 2px solid #333;
      border-radius: 6px;
      min-height: 40px;
      font-size: 18px;
      color: #333;
      box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.3);
      cursor: pointer;
      user-select: none; // 防止鼠标左键拖动选中页面的文字
      &:hover {
        box-shadow: 1px 1px 8px rgba(0, 0, 0, 0.4);
      }

      &.f-node-item-empty {
        border: 1px solid rgba(118, 118, 118, 0.3);
        pointer-events: none;
        background: rgba(239, 239, 239, 0.3);
        cursor: no-drop;
      }
    }
  }
  .f-flow-container {
    flex: 1;
    // padding: 10px;

    .super-flow {
      overflow: auto;
    }
  }
  
  /deep/.super-flow__node {
    display: table;
    border: none;
    background: none;
    box-shadow: none;

    &:hover {
      .f-node-del {
        display: block;
      }
    }

    .f-node-del {
      display: none;
      position: absolute;
      right: -10px;
      top: -10px;
      width: 26px;
      height: 26px;
      line-height: 26px;
      font-size: 20px;
      text-align: center;
      color: #666;
      border-radius: 50%;
      background: rgba(150,150,150,.5);
      z-index: 1;
    }

    .f-flow-node {
      position: relative;
      display: table-cell;
      width: 100%;
      height: 100%;
      vertical-align: middle;
      font-size: 16px;
      color: #333;
      font-weight: bold;
      box-sizing: border-box;
      background: #fff;

      .f-node-content {
        text-align: center;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: pre-wrap;
      }
    }

    .f-flow-node-startAndEnd {
      border: 2px solid rgba(39, 107, 232, .6);
      border-radius: 22px;
    }

    .f-flow-node-process {
      border: 2px solid rgba(39, 107, 232, .6);
    }

    .f-flow-node-judge {
      padding: 0 40px;
      border: 2px solid rgba(39, 107, 232, .6);
      transform: rotate(45deg) scale(.7);
      .f-node-content {
        transform: rotate(-45deg) scale(1.4);
      }
    }
    
    .f-flow-node-quote {
      padding: 0 40px;
      border: 2px solid rgba(39, 107, 232, .6);
      border-radius: 50%;
    }
  }
}
</style>

代码解析

因为不需要对流程图进行操作,先流程图的编辑功能相关配置设置为falsevue-super-flow Demo 里有绑定鼠标事件,不过不支持拖拽到流程图框上面替换,利用已有的鼠标事件做修改。

自定义流程图框型样式

<template v-slot:node="{ meta }">
  <div 
    v-if="meta.name" 
    class="f-node-del" 
    :style="meta.type == 'judge' ? 'top: 10%;' : ''" 
    @mouseup="evt => nodeMouseUp(evt, meta)"
  >x</div>
  <div 
    class="f-flow-node"
    :class="meta.type? `f-flow-node-${meta.type}`: ''"
  >
    <div class="f-node-content" :title="meta.name">{{ meta.name }}</div>
  </div>
</template>

左侧选项拖拽到流程图上填充

鼠标是否进入 flow container 之后,循环当前节点列表,判断当前鼠标位置是否在流程图框的范围内,在范围内时设置nodeList节点内容,并重置nodeItemList选项禁止选中拖拽

// 获取当前鼠标在 graph 坐标系的坐标
const coordinate = this.$refs.superFlow.getMouseCoordinate( clientX, clientY );
for(let i = 0;i<this.nodeList.length;i++) {
  let nodeData = this.nodeList[i]
  let xStartPostion = nodeData.coordinate[0]
  let yStartPostion = nodeData.coordinate[1] * 0.5
  let xEndPostion = +nodeData.width + nodeData.coordinate[0]
  let yEndPostion = +nodeData.height + nodeData.coordinate[1]
  
  if((coordinate[0] >= xStartPostion  && coordinate[0] <= xEndPostion) && (coordinate[1] >= yStartPostion  && coordinate[1] <= yEndPostion)) {
    nodeData.meta.label = conf.info.meta.label
    nodeData.meta.name = conf.info.meta.name
    nodeData.meta.index = conf.info.meta.index
    this.$set(this.nodeList,i,nodeData)
    this.nodeItemList[conf.info.meta.index] = this.resetNodeItem
    break; 
  }
}

删除流程图框中内容

拖拽到流程图框中的内容支持删除,删除操作绑定mouseup事件上,尝试过绑定click事件无效。

@mouseup="evt => nodeMouseUp(evt, meta)"
// 删除事件
nodeMouseUp(evt, meta) {
  evt.preventDefault()
  const metaData = { ...meta }
  let selectIndex = this.nodeList.findIndex(item => item.meta.name === meta.name)
  let nodeItem = this.nodeList[selectIndex]
  nodeItem.meta.label = ""
  nodeItem.meta.name = ""
  
  this.$set(this.nodeList, selectIndex, nodeItem)
  this.$set(this.nodeItemList, metaData.index, {
    label: metaData.label,
    value: {
      meta: {
        label: metaData.label,
        name: metaData.name,
      }
    }
  })
},

记录

 跨浏览器,可兼容IE7--IE10, FireFox, Chrome, Opera等几大内核的浏览器,且不需要浏览器再加装任何控件。  多系统兼容性、可移植性:由于只包括前台UI,因此二次开发者可很方便将本插件用在任何一种需要流程图的B/S系统应用上,流程图的详细实现逻辑完全交于后台程序开发者自己实现;对于后台,只要能返回/接收能被本插件解析的JSON格式数据即可.所以本插件可用于不同的服务器语言建立的后台上.  跨领域:流程图设计器不止用在电信领域,在其它需要IT进行技术支持的领域中都有重大作用.  以下从纯技术实现层面具体描述:  页面顶部栏、左边侧边栏均可自定义;  当左边的侧边栏设为不显示时,为只读状态,此时的视图区可当作是一个查看器而非编辑器。  侧边工具栏除了基本和一些流程节点按钮外,还自定义新的节点按钮,自定义节点都可以有自有的图标、类型名称,定义后在使用可可在工作区内增加这些自定义节点。  顶部栏可显示流程图数据组的标题,也可提供一些常用操作按钮。  顶部栏的按钮,除了撤销、重做按钮外,其余按钮均可自定义点击事件。  可画直线、折线;折线还可以左右/上下移动其中段。  具有区域划分功能,能让用户更直观地了解哪些节点及其相互间的转换,是属于何种自定义区域内的。  具有标注功能,用橙红色标注某个结点或者转换线,一般用在展示流程进度时。  能直接双击结点、连线、分组区域中的文字进行编辑  在对结点、连线、分组区域的各种编辑操作,如新增/删除/修改名称/重设样式或大小/移动/标注时,均可捕捉到事件,并触发自定义事件,如果自定义事件执行的方法返回FALSE,则会阻止操作。  具有操作事务序列控制功能,在工作区内的各种有效操作都能记录到一个栈中,然后可以进行撤销(undo())或重做(redo()),像典型的C/S软件一样。  0.4版中,加入了只导出在初始载入后被编辑的流程图中,只作了增删改等变更的元素,这样可用于用户快速存储,只保存本次变更过的内容,不用重新保存整个流程。  0.5版中,结点的样式不再受到原有程序的限制,所有样式均默认为淡蓝色长方形;如果要指定为圆形,可在初始化时定义结点类型为”原有类型”+” round”;如果要指定为复合结点,则可在初始化时定义结点类型为”原有类型”+” mix”。”原有类型”+” myType”:myType可为自己写的一种特殊样式类.  0.6版中,修正了一些BUG,改善了用户操作体验,并增加在可编辑状态下时,能用键盘上DELETE按键对元素进行删除功能。  0.7版中,修正了一些BUG,增加了连线变更要连的起始结点或结束结点的功能。  0.8版,取消原来的拟物化页面,变成如今的扁平化页面,并且支持主要位置的颜色自定义功能(如果想沿用原来老版本中的拟物化页面,只需保留原来的GooFlow.css文件即可);修正0.7版中的画线BUG。
评论 15
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值