jsPlumb实现元素之间连线功能

4,961 阅读2分钟

「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战」。

前言

由于项目需要做到元素之间相互连线,我们选用了jsPlumb来实现。

引用 jsPlumb

框架我使用vue3来搭建,jsPlumb我使用2.x。
我们使用npm install jsplumb来安装。安装完成之后我们就进行初始化。

首先我们看一个简单例子

<template>
  <div id="diagramContainer">
    <div id="source" class="item">source</div>
    <div id="target" class="item" style="margin-left:250px;">target</div>
  </div>
</template>

<script setup>
import { onMounted, nextTick } from 'vue'
import { jsPlumb, jsPlumbInstance } from 'jsplumb'

let plumbIns

const jsPlumbInit = () => {
  plumbIns.connect({
    source: 'source', // 源节点
    target: 'target', // 目标节点
    endpoint: 'Dot', // 端点的样式,可以设置Dot、Rectangle、image、Blank
    connector: ['Bezier'], // 连接线 Bezier(贝塞尔曲线) Straight(直线) Flowchart(垂直或水平线组成的连接) StateMachine
    anchor: ['Left', 'Right'], // 锚点位置
    endpointStyle:{ fill: "yellow", radius: 5 }
  });
}

onMounted(() => {
  plumbIns = jsPlumb.getInstance()
  jsPlumbInit()
})

</script>

<style>
  .item {
    width: 150px;
    height: 50px
  }
  #source {
    border: 2px solid red;
  }
  #target {
    border: 2px solid blue;
  }
</style>

image.png

这个例子我们通过简单初始化配置,实现了元素之间的连线,下面我们进行简单的项目实践来深入学习。

jsPlumb在项目中的应用

首先我们写一个简单的页面,左边是一个菜单列表右边是一个画布容器,实现节点拖入到画布以及节点在画布里的拖拽。

<div class="main">
    <ul class="main-left">
      <li v-for="item in menuList" :key="item.id" :draggable="true" @dragend="handleDragend($event, item)">{{item.label}}</li>
    </ul>
    <div class="main-right" @dragover.prevent ref="efContainerRef" id="efContainer">
      <div v-for="(item, index) in nodeList" :key="item.nodeId" class="node-info flow-node-drag"
        :style="item.nodeContainerStyle" :id="item.nodeId" :ref="el => nodeRef[index] = el"
        @mouseup="handleMouseup($event, item)">
        <div class="node-info-label">{{item.label}}</div>
      </div>
    </div>
  </div>
  • 对于左边的菜单列表,我们设置draggable属性,让其可以进行拖拽;其次,我们添加dragend方法,在它拖拽结束的时候,我们记录下它的位置等信息,并将这些节点放在nodeList中收集起来。

  • 对于画布,我们需要设置一个id和你配置的jsPlumb的容器id是一样的;然后我们需要设置mouseup方法,在画布里面拖拽节点时,也要重新去设置他的位置,最后通过样式来展示节点 所在的位置。

const handleDragend = (ev, node) => {
  // 拖拽进来相对于地址栏偏移量
  const evClientX = ev.clientX
  const evClientY = ev.clientY
  const efContainer = efContainerRef.value
  const containerRect = efContainer.getBoundingClientRect()
  let left = evClientX - efContainer.offsetLeft
  let top = evClientY - efContainer.offsetTop
   // 居中
  left -= 51
  top -= 19
  const nodeId = `${node.id}_${Date.now()}`
  const nodeInfo = {
    label: node.label,
    id: nodeId,
    nodeId,
    nodeContainerStyle: {
      left: left + 'px',
      top: top + 'px'
    }
  }
  nodeList.value.push(nodeInfo)
  nextTick(() => {
    plumbIns.makeSource(nodeId, deff.jsplumbSourceOptions)
    plumbIns.makeTarget(nodeId, deff.jsplumbSourceOptions)
    plumbIns.draggable(nodeId)
  })
}

const handleMouseup = (ev, data) => { // 在图表中拖拽节点时,设置他的新的位置
  nodeRef.value.forEach(node => {
    if (node.id === data.nodeId) {
      data.nodeContainerStyle.left = node.style.left
      data.nodeContainerStyle.top = node.style.top
    }
  })
}

在这里面我没有去设置拖拽的范围,大家可以自己添加判断去控制在画布内拖拽。

image.png

下面我们实现一下元素间的连线~

我们添加一个端点设置class要和配置里面的相同,然后从端点按住鼠标可以进行动态连线~

image.png image.png

image.png

好了到这里,我们了解了jsPlumb简单用法,实现了简单的节点元素连线功能。

整体实现代码如下:

<template>
  <div class="main">
    <ul class="main-left">
      <li v-for="item in menuList" :key="item.id" :draggable="true" @dragend="handleDragend($event, item)">{{item.label}}</li>
    </ul>
    <div class="main-right" @dragover.prevent ref="efContainerRef" id="efContainer">
      <div v-for="(item, index) in nodeList" :key="item.nodeId" class="node-info flow-node-drag"
        :style="item.nodeContainerStyle" :id="item.nodeId" :ref="el => nodeRef[index] = el"
        @mouseup="handleMouseup($event, item)">
        <div class="node-info-label">{{item.label}}</div>
        <span class="node-drag"></span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { onMounted, nextTick, ref } from 'vue'
import { jsPlumb, jsPlumbInstance } from 'jsplumb'

const menuList = ref([  {    label: 'abc',    id: '1'  },  {    label: 'bcd',    id: '2'  },  {    label: 'cde',    id: '3'  },  {    label: 'def',    id: '4'  }])

const deff = {
    jsplumbSetting: {
      // 动态锚点、位置自适应
      Anchors: ['Right', 'Left'],
      anchor: ['Right', 'Left'],
      // 容器ID
      Container: 'efContainer',
      // 连线的样式,直线或者曲线等,可选值:  StateMachine、Flowchart,Bezier、Straight
      // Connector: ['Bezier', {curviness: 100}],
      // Connector: ['Straight', { stub: 20, gap: 1 }],
      Connector: ['Flowchart', { stub: 30, gap: 1, alwaysRespectStubs: false, midpoint: 0.5, cornerRadius: 10 }],
      // Connector: ['StateMachine', {margin: 5, curviness: 10, proximityLimit: 80}],
      // 鼠标不能拖动删除线
      ConnectionsDetachable: false,
      // 删除线的时候节点不删除
      DeleteEndpointsOnDetach: false,
      /**
         * 连线的两端端点类型:圆形
         * radius: 圆的半径,越大圆越大
         */
      // Endpoint: ['Dot', { radius: 5, cssClass: 'ef-dot', hoverClass: 'ef-dot-hover' }],
      /**
         * 连线的两端端点类型:矩形
         * height: 矩形的高
         * width: 矩形的宽
         */
      // Endpoint: ['Rectangle', {height: 20, width: 20, cssClass: 'ef-rectangle', hoverClass: 'ef-rectangle-hover'}],
      /**
         * 图像端点
         */
      // Endpoint: ['Image', {src: 'https://www.easyicon.net/api/resizeApi.php?id=1181776&size=32', cssClass: 'ef-img', hoverClass: 'ef-img-hover'}],
      /**
         * 空白端点
         */
      Endpoint: ['Blank', { Overlays: '' }],
      // Endpoints: [['Dot', {radius: 5, cssClass: 'ef-dot', hoverClass: 'ef-dot-hover'}], ['Rectangle', {height: 20, width: 20, cssClass: 'ef-rectangle', hoverClass: 'ef-rectangle-hover'}]],
      /**
         * 连线的两端端点样式
         * fill: 颜色值,如:#12aabb,为空不显示
         * outlineWidth: 外边线宽度
         */
      EndpointStyle: { fill: '#1879ffa1', outlineWidth: 1 },
      // 是否打开jsPlumb的内部日志记录
      LogEnabled: true,
      /**
         * 连线的样式
         */
      PaintStyle: {
        // 线的颜色
        stroke: '#E0E3E7',
        // 线的粗细,值越大线越粗
        strokeWidth: 1,
        // 设置外边线的颜色,默认设置透明,这样别人就看不见了,点击线的时候可以不用精确点击,参考 https://blog.csdn.net/roymno2/article/details/72717101
        outlineStroke: 'transparent',
        // 线外边的宽,值越大,线的点击范围越大
        outlineWidth: 10,
      },
      DragOptions: { cursor: 'pointer', zIndex: 2000 },
      ConnectionOverlays: [
        ['Custom', {
          create() {
            const el = document.createElement('div')
            // el.innerHTML = '<select id=\'myDropDown\'><option value=\'foo\'>foo</option><option value=\'bar\'>bar</option></select>'
            return el
          },
          location: 0.7,
          id: 'customOverlay',
        }],
      ],
      /**
         *  叠加 参考: https://www.jianshu.com/p/d9e9918fd928
         */
      Overlays: [
        // 箭头叠加
        ['Arrow', {
          width: 10, // 箭头尾部的宽度
          length: 8, // 从箭头的尾部到头部的距离
          location: 1, // 位置,建议使用01之间
          direction: 1, // 方向,默认值为1(表示向前),可选-1(表示向后)
          foldback: 0.623, // 折回,也就是尾翼的角度,默认0.623,当为1时,为正三角
        }],
        // ['Diamond', {        //     events: {        //         dblclick: function (diamondOverlay, originalEvent) {        //             console.log('double click on diamond overlay for : ' + diamondOverlay.component)        //         }        //     }        // }],
        ['Label', {          label: '',          location: 0.1,          cssClass: 'aLabel',        }],

      ],
      // 绘制图的模式 svg、canvas
      RenderMode: 'canvas',
      // 鼠标滑过线的样式
      HoverPaintStyle: { stroke: '#b0b2b5', strokeWidth: 1 },
      // 滑过锚点效果
      // EndpointHoverStyle: {fill: 'red'}
      Scope: 'jsPlumb_DefaultScope', // 范围,具有相同scope的点才可连接
    },
    /**
     * 连线参数
     */
    jsplumbConnectOptions: {
      isSource: true,
      isTarget: true,
      // 动态锚点、提供了4个方向 Continuous、AutoDefault
      // anchor: 'Continuous',
      // anchor: ['Continuous', { faces: ['left', 'right'] }],
      // 设置连线上面的label样式
      labelStyle: {
        cssClass: 'flowLabel',
      },
      // overlays: [
      //   ['Custom', {
      //     create(component) {
      //       const el = document.createElement('div')
      //       el.innerHTML = '<select id=\'myDropDown\'><option value=\'foo\'>foo</option><option value=\'bar\'>bar</option></select>'
      //       return el
      //     },
      //     location: 0.7,
      //     id: 'customOverlay',
      //   }],
      // ],
    },
    /**
     * 源点配置参数
     */
    jsplumbSourceOptions: {
      // 设置可以拖拽的类名,只要鼠标移动到该类名上的DOM,就可以拖拽连线
      filter: '.node-drag',
      filterExclude: false,
      anchor: ['Continuous', { faces: ['right'] }],
      // 是否允许自己连接自己
      allowLoopback: false,
      maxConnections: -1,
    },
    // 参考 https://www.cnblogs.com/mq0036/p/7942139.html
    jsplumbSourceOptions2: {
      // 设置可以拖拽的类名,只要鼠标移动到该类名上的DOM,就可以拖拽连线
      filter: '.node-drag',
      filterExclude: false,
      // anchor: 'Continuous',
      // 是否允许自己连接自己
      allowLoopback: true,
      connector: ['Flowchart', { curviness: 50 }],
      connectorStyle: {
        // 线的颜色
        stroke: 'red',
        // 线的粗细,值越大线越粗
        strokeWidth: 1,
        // 设置外边线的颜色,默认设置透明,这样别人就看不见了,点击线的时候可以不用精确点击,参考 https://blog.csdn.net/roymno2/article/details/72717101
        outlineStroke: 'transparent',
        // 线外边的宽,值越大,线的点击范围越大
        outlineWidth: 10,
      },
      connectorHoverStyle: { stroke: 'red', strokeWidth: 2 },
    },
    jsplumbTargetOptions: {
      // 设置可以拖拽的类名,只要鼠标移动到该类名上的DOM,就可以拖拽连线
      filter: '.node-drag',
      filterExclude: false,
      // 是否允许自己连接自己
      anchor: ['Continuous', { faces: ['left'] }],
      allowLoopback: false,
      dropOptions: { hoverClass: 'ef-drop-hover' },
    },
  }

const nodeList = ref([])
const efContainerRef = ref()
const nodeRef = ref([])

let plumbIns

const jsPlumbInit = () => {
  plumbIns.importDefaults(deff.jsplumbSetting)
}

const handleDragend = (ev, node) => {
  // 拖拽进来相对于地址栏偏移量
  const evClientX = ev.clientX
  const evClientY = ev.clientY
  const efContainer = efContainerRef.value
  const containerRect = efContainer.getBoundingClientRect()
  let left = evClientX - efContainer.offsetLeft
  let top = evClientY - efContainer.offsetTop
   // 居中
  left -= 51
  top -= 19
  const nodeId = `${node.id}_${Date.now()}`
  const nodeInfo = {
    label: node.label,
    id: nodeId,
    nodeId,
    nodeContainerStyle: {
      left: left + 'px',
      top: top + 'px'
    }
  }
  nodeList.value.push(nodeInfo)
  nextTick(() => {
    plumbIns.makeSource(nodeId, deff.jsplumbSourceOptions)
    plumbIns.makeTarget(nodeId, deff.jsplumbTargetOptions)
    plumbIns.draggable(nodeId)
  })
}

const handleMouseup = (ev, data) => { // 在图表中拖拽节点时,设置他的新的位置
  nodeRef.value.forEach(node => {
    if (node.id === data.nodeId) {
      data.nodeContainerStyle.left = node.style.left
      data.nodeContainerStyle.top = node.style.top
    }
  })
}

onMounted(() => {
  plumbIns = jsPlumb.getInstance()
  jsPlumbInit()
})

</script>

<style>
body {
  margin: 0;
}
  .item {
    width: 150px;
    height: 50px
  }
  #source {
    border: 2px solid red;
  }
  #target {
    border: 2px solid blue;
  }
  .main {
    display: flex;
  }
  ul {
    list-style: none;
    padding-left: 0;
    width: 120px;
    background: #eee;
    text-align: center;
  }
  ul > li {
    height: 40px;
    line-height: 40px;
  }
  .main-right {
    border: 1px solid #ccc;
    flex: 1;
    margin-left: 15px;
    position: relative;
    background: #f4f4f4;
  }
  .node-info {
    position: absolute;
  }
  .node-info-label {
    width: 100px;
    height: 36px;
    line-height: 36px;
    text-align: center;
    border: 1px solid #e5e7eb;
    background: #fff;
    border-radius: 4px;
  }
  .node-info-label:hover {
    cursor: pointer;
    background: #f4eded;
  }
  .node-info-label:hover + .node-drag {
    /* background: red; */
    display: inline-block;
  }
  .node-drag {
    display: none;
    width: 6px;
    height: 6px;
    border-radius: 6px;
    border: 1px solid #ccc;
    position: absolute;
    right: -7px;
    top: 14px;
  }
  .node-drag:hover {
    display: inline-block;
  }
</style>