前端通过draggable结合fabricjs实现拖拽至画布生成元素自定义编排功能

前端通过draggable结合fabricjs实现拖拽自定义编排功能

太久没有更新了,主要最近行情不太好失业了一段时间,一度到怀疑人生,然后就是做的东西大多没有什么含金量,没什么好分享的就很尴尬。

刚好最近遇到一个奇葩的需求,一个基地管理的需求,由于项目中的基地很偏,地图上都定位不到,只能通过一个图片作为底图,然后在上面绘制一些图层,需要做一个自定义编排的需求,先上图:

上面是实现的demo,首先上html结构代码(技术栈:V3+TS+elementplus)

html 复制代码
<div class="massif-box">
          <!-- 左侧栏 -->
          <div class="asidebox">
              <div class="topbar">
                  <div
                  class="tabitem" 
                  @click="changetype(item)"
                  v-for="item in typelist" 
                  :key="item.code" 
                  :class="{cur:item.code == curtype}">
                  {{ item.name }}</div>
              </div>
              <div class="barcontent">
                  <div class="searchbar">
                      <el-input
                          v-model="searchvalue"
                          style="width:calc(100% - 20px)"
                          placeholder="请输入关键字"
                          :suffix-icon="Search"
                      />
                  </div>
                  <div class="block-content">
                      <div class="block-item" v-for="(item) in curlist" :key="item.id" :class="{cur:item.id == cur!.id}" @click.capture="selectitem(item)">
                          <div class="imgbox"></div>
                          <div class="text">{{ item.name }}</div>
                          <el-tag type="success" class="tag">陆基</el-tag>
                          <template v-if="item.id == cur.id">
                              <el-icon class="icon" @click.stop="editattr"><EditPen /></el-icon>
                              <el-icon class="icon" @click.stop="removevnode"><Delete /></el-icon>
                          </template>
                      </div>
                  </div>
                  <button @click="tojson">画布转json</button>
                  <button @click="tocanvas">json回显画布</button>
              </div>
          </div>
          <!-- 右边内容区域 -->
          <div class="basecontent" ref="basecontent" @drop="drop" @dragover="dragOver">
              <!-- 画布容器 -->
              <canvas id="canvas"></canvas>
              <!-- 可拖拽元素 -->
              <div class="toolone" @dragstart.capture="onStart">
                  <el-tooltip
                      class="box-item"
                      effect="dark"
                      content="地块"
                      placement="right"
                  >
                      <div class="item-one">
                          <img :src="massifimg" alt="" :draggable="true"/>
                      </div>
                  </el-tooltip>
                  <el-tooltip
                      class="box-item"
                      effect="dark"
                      content="塘口"
                      placement="right"
                  >
                      <div class="item-two">
                          <img :src="pondimg" alt="" :draggable="true"/>
                      </div>
                  </el-tooltip>
                  <el-tooltip
                      placement="right-start"
                      class="custom-tooltip"
                      effect="light"
                  >
                      <template #content>
                          <div class="tip-box" @dragstart.stop="onStart">
                              <div class="device-one tip-item">
                                  <img :src="video" alt="" :draggable="true"/>
                                  <div>xxx</div>
                              </div>
                              <div class="device-two tip-item">
                                  <img :src="onedevice" alt="" :draggable="true"/>
                                  <div>yyyy</div>
                              </div>
                              <div class="device-three tip-item">
                                  <img :src="video" alt="" :draggable="true"/>
                                  <div>mmmm</div>
                              </div>
                          </div>
                      </template>
                      <div class="item-three">
                          <img :src="deviceimg" alt=""/>
                      </div>
                  </el-tooltip>
              </div>
              <!-- 右下角工具元素 -->
              <div class="tooltwo">
                  <div class="top">
                      <img :src="layerimg" alt="" @click="openlyer"/>
                  </div>
                  <div class="center">
                      <img :src="daohangimg" alt="" />
                      <img :src="screenimg" alt="" />
                      <img :src="reductionimg" alt=""/>
                  </div>
                  <div class="bottom">
                      <img :src="addimg" alt="" @click="zoomIn" />
                      <img :src="minusimg" alt="" @click="zoomOut"/>
                  </div>
              </div>
              <ponddialog :pondparams="circleparams" ref="pondDialog" @get-value="updatecanvas"/>
              <massifdialog :massifparams="rectparams" ref="massifDialog" @get-value="updatecanvas"/>
              <devicedialog :deviceparams="deviceparams" ref="deviceDialog" @get-value="updatecanvas"/>
              <layerdialog :layerlist="layerlist" ref="layerDialog" @get-visible="updatevisible"/> 
          </div>
      </div>

结构分为左侧菜单栏和右侧画布区域,通过左上角的图标拖拽到画布上生成图形,选中图形弹出属性设置框,可以调制样式或更新数据。整个画布也可以转成json存储,通过json也可以回显画布。

typescript 复制代码
  import { EditPen, Plus, Delete, Search } from '@element-plus/icons-vue';
  EditPen 
  Plus  
  Delete 
  Search
  //弹框组件
  import ponddialog from './ponddialog.vue';
  import massifdialog from './massifdialog.vue';
  import devicedialog from './devicedialog.vue';
  import layerdialog from './layerdialog.vue';
  //图片
  import massifimg from'@/assets/imgs/massif/massif.png';
  import pondimg from'@/assets/imgs/massif/pond.png';
  import deviceimg from'@/assets/imgs/massif/device.png';
  import addimg from'@/assets/imgs/massif/add.png';
  import minusimg from'@/assets/imgs/massif/minus.png';
  import screenimg from'@/assets/imgs/massif/screen.png';
  import reductionimg from'@/assets/imgs/massif/reduction.png';
  import daohangimg from'@/assets/imgs/massif/daohang.png';
  import layerimg from'@/assets/imgs/massif/layer.png';
  import video from'@/assets/imgs/massif/video.png';
  import onedevice from'@/assets/imgs/massif/onedevice.png';
  import basemap from'@/assets/imgs/massif/basemap2.png';
  //画布插件
  import * as fabric from 'fabric';
  //生成唯一id方法
  import { generateUUID } from '@/utils'
  //参数类型声明
  import {Rectparams,Circleparams,DeviceParams,p, Curparams} from './types'
  // 画布区域的父级元素  
  const basecontent = ref<HTMLElement>();
  //canvas实例
  let canvas: fabric.Canvas;
  //搜索值
  let searchvalue = ref<string>('');
  
  //以下是左侧列表的相关数据
  //图层的类型
  const typelist = ref<{name:string,code:string}[]>([{name:'地块',code:'Rect'},{name:'塘口',code:'Circle'},{name:'设备',code:'Device'}]);
  //当前图层类型
  const curtype = ref<string>('Rect');
  //所有图层数组
  let vnodelist = ref<Curparams[]>([])
  //选择类型
  const changetype = (item:{name:string,code:string})=>{
      curtype.value = item.code;
  }
  //根据类型过滤出当前列表
  let curlist = computed(()=>{
      let list =  vnodelist.value.filter(item => (item.types == curtype.value && item.name.startsWith(searchvalue.value)));
      return list
  })
  
  //生命周期初始化画布
  onMounted(() => {  
      initFabricCanvas(drawbasemap);//drawbasemap是绘制地图的
  });
  
  //图层弹框数据
  let layerlist = ref<Array<fabric.Object & p >>([] as Array<fabric.Object & p >)
  watch(()=>vnodelist.value,()=>{
      layerlist.value = canvas!.getObjects() as Array<fabric.Object & p>
  },{
      deep:true
  })
  
  //画布初始化操作
  function initFabricCanvas(callback) {  
    if (!basecontent.value) return;  
    canvas = new fabric.Canvas('canvas', {  
      width: basecontent.value.offsetWidth,  
      height: basecontent.value.offsetHeight,
      preserveObjectStacking:true
    });
    callback && callback()
  }
  
  //绘制底图  地图就是最底层的假地图图片,所以需要默认先绘制
  const drawbasemap = ()=>{
      const img = new Image();  
      img.src = basemap; 
      let id = generateUUID(); 
      img.onload = () => {  
          const imgLayer = new fabric.Image(img, {  
              selectable:false,
              hasControls:false,  
              left: 0,  
              top: 0,  
              scaleX: canvas!.width / img.width,  
              scaleY: canvas!.height / img.height,  
              z: 1,
              id,
              types:'Base'  
          });  
          canvas!.add(imgLayer);
      }
  }
  
  //开始拖拽事件,根据classname判断拖拽的元素,不同的classname传递不同的type
  function onStart(e){
      let classname = ref<string>('')
      classname.value = e.target.parentElement.className.split(' ')[0];
      switch (classname.value) {
          case 'item-one':
              e.dataTransfer.setData('type', 'Rect');
              break;
          case 'item-two':
              e.dataTransfer.setData('type', 'Circle');
              break;
          case 'device-one':
              e.dataTransfer.setData('type', 'device-one');
              break;
          case 'device-two':
              e.dataTransfer.setData('type', 'device-two');
              break;
          case 'device-three':
              e.dataTransfer.setData('type', 'device-three');
              break;
          default:
              break;
      }
  }
  
  //拖拽过程中阻止默认事件
  function dragOver(e){
      e.preventDefault();
  }
  
  //拖拽完成绘制图形
  function drop(e) {
      let types = ref<string>('');
      types.value = e.dataTransfer.getData('type');//这里拿到拖拽开始事件传递过来的type
      let vnode:fabric.Object;
      let id = generateUUID();
      switch (types.value) {//根据type绘制不同的图形
          case 'Rect':
              let objone = {
                  selectable: true, // 是否可选
                  hasControls:false,
                  top:(e.pageY - (e.pageY - e.offsetY))/scale.value,
                  left:(e.pageX - (e.pageX - e.offsetX))/scale.value,//创建对象的x坐标
                  width: 150, //宽和高
                  height: 300,
                  fill:'rgba(73, 120, 236,0.6)', //填充颜色
                  stroke:'rgba(38, 162, 234,1)', //线条颜色
                  strokeWidth: 4, //线条宽度
                  strokeOpacity:0.5,
                  types:types.value,
                  id,
                  name:'地块',
                  z:2,
                  classify:'a',
                  area:2,
                  zoomX:scale.value,
                  zoomY:scale.value,
                  angle:0,
                  visible:true
              }
              vnode = new fabric.Rect(objone) // 开始绘制
              canvas!.add(vnode); //添加到画布中去
              vnodelist.value.push(objone);
          break;
          case 'Circle':
              let objtwo = {
                  selectable: true, // 是否可选
                  hasControls:false,
                  top:(e.pageY - (e.pageY - e.offsetY))/scale.value,
                  left:(e.pageX - (e.pageX - e.offsetX))/scale.value,//创建对象的x坐标
                  rx: 25,    // 圆的水平半径
                  ry: 25,    // 圆的垂直半径
                  fill: 'rgba(73, 120, 236,0.6)', // 填充颜色  
                  stroke: 'rgba(255,255,255,1)', // 描边颜色  
                  strokeWidth: 1, // 描边宽度  
                  types:types.value,
                  id,
                  name:'塘口',
                  z:3,
                  zoomX:scale.value,
                  zoomY:scale.value,
                  visible:true
              }
              vnode = new fabric.Ellipse(objtwo);
              canvas!.add(vnode);
              vnodelist.value.push(objtwo);
              break;
          case 'device-one':
              const imgone = new Image();  
              imgone.src = video;
              let oneparams = drawdevice(e,id,'视频监控');
              imgone.onload = () => {  
                  const imgerone = new fabric.Image(imgone, oneparams);  
                  canvas!.add(imgerone); 
                  vnodelist.value.push(oneparams);
              } 
              break;
          case 'device-two':
              const imgtwo = new Image();  
              imgtwo.src = onedevice;  
              let twoparams = drawdevice(e,id,'一体设备');
              imgtwo.onload = () => {  
                  const imgertwo = new fabric.Image(imgtwo,twoparams);  
                  canvas!.add(imgertwo);
                  vnodelist.value.push(twoparams);  
              } 
              break;
          case 'device-three':
              const imgthree = new Image();  
              imgthree.src = video;
              let threeparams = drawdevice(e,id,'安防视频');
              imgthree.onload = () => {  
                  const imgerthree = new fabric.Image(imgthree, threeparams);  
                  canvas!.add(imgerthree);
                  vnodelist.value.push(threeparams);
              } 
              break;
          default:
              break;
      }  
      reorderObjectsByZ()
  }
  
  //绘制设备类图层参数处理
  function drawdevice(e:DragEvent,id:string,name:string){
      return {
          selectable: true, // 是否可选
          hasControls:false,
          top:(e.pageY - (e.pageY - e.offsetY))/scale.value,
          left:(e.pageX - (e.pageX - e.offsetX))/scale.value,
          z: 4,
          id,
          name,
          refnumber:'0',
          versionid:'',
          types:'Device',
          zoomX:scale.value,
          zoomY:scale.value, 
          visible:true
      }
  }
  
  //循环画布中的元素始终保持层级z有效
  function reorderObjectsByZ() {//因为后绘制的图形层级会高一些,为了跟据z属性保持层级逻辑
      if (canvas) {  
          const objects = canvas!.getObjects().sort((a:fabric.Object & p, b:fabric.Object & p) => a.z - b.z);  //根据z属性排序
          canvas.clear(); // 移除所有现有对象  
          objects.forEach(obj => {
              canvas.add(obj); // 重新添加对象  
          });  
      }  
  } 
  
  //选中激活对应的图形
  let cur = ref<Curparams>({} as Curparams);//记录当前选中的数据
  let rectparams = ref<Rectparams>({} as Rectparams)
  let circleparams = ref<Circleparams>({} as Circleparams)
  let deviceparams = ref<DeviceParams>({} as DeviceParams)
  
  //选择图层获取参数
  function selectitem(item){
      cur.value = item;
      closeall();
      canvas!.getObjects().forEach((obj: fabric.Object & p) => {
          if(cur.value.id == obj.id){//根据唯一id判断选中的哪个元素,获取数据回填表单
              let defaultparam = {
                  id:obj.id,
                  name:obj.name,
                  types:obj.types
              }
              switch(obj.types){
                  case 'Rect':
                      let rectfill = splitRgbaSimple(obj.fill as string);
                      let rectstroke = splitRgbaSimple(obj.stroke as string);
                      rectparams.value = {
                          ...defaultparam,
                          fill:rectfill.color,
                          fillopacity:rectfill.opacity,
                          width: obj.width,  
                          height: obj.height,
                          strokeWidth: obj.strokeWidth,
                          stroke: rectstroke.color, 
                          strokeOpacity:rectstroke.opacity,
                          area:obj.area ? + obj.area : 0,
                          classify:obj.classify + '',
                          angle:obj.angle
                      };          
                  break;
                  case 'Circle':
                      let circlefill = splitRgbaSimple(obj.fill as string);
                      let circlestroke = splitRgbaSimple(obj.stroke as string);
                      circleparams.value = {
                          ...defaultparam,
                          fill:circlefill.color,
                          fillopacity:circlefill.opacity,
                          left: obj.left,  
                          top: obj.top,  
                          rx: obj.rx*2,  
                          ry: obj.ry*2,
                          strokeWidth: obj.strokeWidth,
                          stroke: circlestroke.color,
                          strokeOpacity:circlestroke.opacity,
                      };          
                  break;
                  case 'Device':
                      deviceparams.value = {  
                          ...defaultparam,
                          refnumber:obj.refnumber + '',
                          versionid:obj.versionid + '',
                      };
                  break;
              }
              canvas!.setActiveObject(obj); // 激活选中元素  
              canvas!.renderAll(); //重新渲染画布(虽然选中元素通常会自动触发重绘)
          }
      });  
  }
  
  //将rgba提取为rgb的格式和透明度
  function splitRgbaSimple(rgbaString:string) {  
      const alphaIndex = rgbaString.lastIndexOf(',');  
      const opacity = parseFloat(rgbaString.slice(alphaIndex + 1, -1)) * 100;
      const rgbString = rgbaString.slice(5, alphaIndex); 
      const color = `rgb(${rgbString.replace(/\s+/g,'')})`;
      return { color, opacity };  
  } 
  
  //弹框实例
  const pondDialog = ref();
  const massifDialog = ref();
  const deviceDialog = ref();
  const layerDialog = ref();
  
  //打开修改属性弹框
  function editattr(){
      let mapflag = {
          'Rect': massifDialog,
          'Circle': pondDialog,
          'Device': deviceDialog,
      }
      mapflag[cur.value.types].value.disbled = true;
  }
  
  //回填数据点击确定更新图层
  function updatecanvas(params:any){
      console.log(params);
      canvas!.getObjects().forEach((obj: fabric.Object & p) => {
          if(cur.value.id == obj.id){            
              switch(obj.types){
                  case 'Rect':
                      obj.set({
                          ...params,
                          width:params.width ? +params.width : 50,
                          height:params.height ? +params.height : 100,
                          stroke:rgbToRgba(params.stroke,params.strokeOpacity),
                          fill:rgbToRgba(params.fill,params.fillopacity),
                          angle:+params.angle
                      });
                  break;
                  case 'Circle':
                      obj.set({
                          ...params,
                          rx:params.rx ? (+params.rx)/2 : 25,
                          ry:params.ry ? (+params.ry)/2 : 25,
                          left:+params.left,
                          top:+params.top,
                          stroke:rgbToRgba(params.stroke,params.strokeOpacity),
                          fill:rgbToRgba(params.fill,params.fillopacity)
                      });
                  break;
                  case 'Device':
                      obj.set(params);
                  break;
              }
              updatelist({name:obj.name,id:obj.id,types:obj.types})//更新列表中的数据
              canvas!.requestRenderAll(); // 重新渲染画布(虽然选中元素通常会自动触发重绘)
          }
      }); 
  }
  
  //处理颜色格式 最终显示是rgba的格式
  function rgbToRgba(rgbString, alpha) {  
    const rgbArray = rgbString.replace(/^rgb\(([^)]+)\)$/, '$1').split(',');  
    const r = parseInt(rgbArray[0].trim(), 10);  
    const g = parseInt(rgbArray[1].trim(), 10);  
    const b = parseInt(rgbArray[2].trim(), 10);
    alpha = (parseInt(alpha) / 100).toFixed(1);
    return `rgba(${r}, ${g}, ${b}, ${alpha})`;  
  }  
  
  //控制图层弹框中选的图层控制当前元素的显示与隐藏
  function updatevisible(item){
      canvas!.getObjects().forEach((obj: fabric.Object & p) => {
          if(item.id == obj.id){//查找当前选中的元素            
              obj.visible = item.visible;
              canvas!.requestRenderAll()
          }
      });
  }
  //删除图层
  function removevnode(){
      canvas!.getObjects().forEach((obj: fabric.Object & p) => {
          if(cur!.value!.id == obj?.id){//查找当前选中的元素            
              canvas!.remove(obj);//移除元素
              removelist();//对应的左侧栏数据也移除
          }
      });  
  }
  
  //更新左侧列表数据
  function updatelist(params){    
      let i = vnodelist.value.findIndex(item => item.id == cur.value.id);
      vnodelist.value[i] = params;
  }
  
  //画布转json
  const tojson = ()=>{
      let jsonbefore = ref<{version:string,objects:Array<fabric.Object & p>}>({
          objects:[],
          version:'6.1.0'
      })
      canvas!.getObjects().forEach((obj: fabric.Object & p) => {
          let objadd = obj.toObject();
          ['id','z','name','types','selectable','hasControls','classify','area','refnumber','versionid'].forEach(item=>{
              obj[item] && (objadd[item] = obj[item])
          })
          objadd.hasControls = false;      
          jsonbefore.value.objects.push(objadd)
      });
      //调试专用
      localStorage.setItem('canvas',JSON.stringify(jsonbefore));
      canvas!.clear()
      //调用接口
      //return JSON.stringify(jsonbefore)
  }
  
  //用json回显画布
  const tocanvas = ()=>{
      let json = JSON.parse(localStorage.getItem('canvas') as string); 
      canvas!.loadFromJSON(json._value, () => {          
          canvas!.requestRenderAll();
          setTimeout(()=>{
              scale.value = canvas!.getObjects()[1].zoomX as number;
              vnodelist.value = canvas!.getObjects().map((item:fabric.Object & p) =>{
                  return {
                      id: item.id,
                      types:item.types,
                      name:item.name,
                      visible:item.visible,
                  }
              })
          },100)
      });  
  }
  
  //移除左侧列表数据
  function removelist(){
      closeall()
      let i = vnodelist.value.findIndex(item => item.id == cur.value.id);
      vnodelist.value.splice(i, 1);
  }
  
  //关闭所有弹框
  function closeall(){
      [massifDialog,pondDialog,deviceDialog,layerDialog].forEach(item =>{
          item.value.disbled = false;
      })
  }
  
  //放大缩小事件 最大放大两倍 最小还原1:1
  let scale = ref<number>(1);
  function zoomIn() {  
      if (scale.value < 2) {  
          scale.value += 0.1; // 可以调整步长来平滑缩放  
          canvas!.setZoom(scale.value)
      }  
  }
  
  //缩小
  function zoomOut() {  
      if (scale.value > 1) {  
          scale.value -= 0.1;  
          canvas!.setZoom(scale.value);
      }  
  }
  //打开图层弹框
  function openlyer(){
      closeall();
      layerDialog.value.disbled = true;
  }

以上是全部代码,上述代码中解决了以下问题

1、拖拽是基于html5的新特性draggable结合其自带的拖拽方法拿到xy坐标,计算位于目标元素xy坐标

2、fabric画布元素的层级问题,无论元素创建的先后始终保证自定义层级有效(reorderObjectsByZ方法)

3、fabric画布转json自定义参数丢失的问题(tojson 方法)

4、fabric画布放大或缩小后xy坐标偏移的问题 (记录scale缩放比,始终计算left与top值)

5、ts中fabric画布元素类型如何兼容自定义属性(自定义P类型,与fabric.object交叉声明)

相关推荐
浏览器爱好者几秒前
如何在AWS上部署一个Web应用?
前端·云计算·aws
xiao-xiang17 分钟前
jenkins-通过api获取所有job及最新build信息
前端·servlet·jenkins
C语言魔术师33 分钟前
【小游戏篇】三子棋游戏
前端·算法·游戏
匹马夕阳2 小时前
Vue 3中导航守卫(Navigation Guard)结合Axios实现token认证机制
前端·javascript·vue.js
你熬夜了吗?2 小时前
日历热力图,月度数据可视化图表(日活跃图、格子图)vue组件
前端·vue.js·信息可视化
桂月二二8 小时前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
hunter2062069 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb9 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角9 小时前
CSS 颜色
前端·css
九酒10 小时前
从UI稿到代码优化,看Trae AI 编辑器如何帮助开发者提效
前端·trae