0


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

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

太久没有更新了,主要最近行情不太好失业了一段时间,一度到怀疑人生,然后就是做的东西大多没有什么含金量,没什么好分享的就很尴尬。
刚好最近遇到一个奇葩的需求,一个基地管理的需求,由于项目中的基地很偏,地图上都定位不到,只能通过一个图片作为底图,然后在上面绘制一些图层,需要做一个自定义编排的需求,先上图:
在这里插入图片描述

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

  1. <divclass="massif-box"><!-- 左侧栏 --><divclass="asidebox"><divclass="topbar"><divclass="tabitem"@click="changetype(item)"v-for="item in typelist":key="item.code":class="{cur:item.code == curtype}">
  2. {{ item.name }}</div></div><divclass="barcontent"><divclass="searchbar"><el-inputv-model="searchvalue"style="width:calc(100% - 20px)"placeholder="请输入关键字":suffix-icon="Search"/></div><divclass="block-content"><divclass="block-item"v-for="(item) in curlist":key="item.id":class="{cur:item.id == cur!.id}"@click.capture="selectitem(item)"><divclass="imgbox"></div><divclass="text">{{ item.name }}</div><el-tagtype="success"class="tag">陆基</el-tag><templatev-if="item.id == cur.id"><el-iconclass="icon"@click.stop="editattr"><EditPen/></el-icon><el-iconclass="icon"@click.stop="removevnode"><Delete/></el-icon></template></div></div><button@click="tojson">画布转json</button><button@click="tocanvas">json回显画布</button></div></div><!-- 右边内容区域 --><divclass="basecontent"ref="basecontent"@drop="drop"@dragover="dragOver"><!-- 画布容器 --><canvasid="canvas"></canvas><!-- 可拖拽元素 --><divclass="toolone"@dragstart.capture="onStart"><el-tooltipclass="box-item"effect="dark"content="地块"placement="right"><divclass="item-one"><img:src="massifimg"alt="":draggable="true"/></div></el-tooltip><el-tooltipclass="box-item"effect="dark"content="塘口"placement="right"><divclass="item-two"><img:src="pondimg"alt="":draggable="true"/></div></el-tooltip><el-tooltipplacement="right-start"class="custom-tooltip"effect="light"><template#content><divclass="tip-box"@dragstart.stop="onStart"><divclass="device-one tip-item"><img:src="video"alt="":draggable="true"/><div>xxx</div></div><divclass="device-two tip-item"><img:src="onedevice"alt="":draggable="true"/><div>yyyy</div></div><divclass="device-three tip-item"><img:src="video"alt="":draggable="true"/><div>mmmm</div></div></div></template><divclass="item-three"><img:src="deviceimg"alt=""/></div></el-tooltip></div><!-- 右下角工具元素 --><divclass="tooltwo"><divclass="top"><img:src="layerimg"alt=""@click="openlyer"/></div><divclass="center"><img:src="daohangimg"alt=""/><img:src="screenimg"alt=""/><img:src="reductionimg"alt=""/></div><divclass="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也可以回显画布。

  1. import{ EditPen, Plus, Delete, Search }from'@element-plus/icons-vue';
  2. EditPen
  3. Plus
  4. Delete
  5. Search
  6. //弹框组件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[]>([])//选择类型constchangetype=(item:{name:string,code:string})=>{
  7. curtype.value = item.code;}//根据类型过滤出当前列表let curlist =computed(()=>{let list = vnodelist.value.filter(item =>(item.types == curtype.value && item.name.startsWith(searchvalue.value)));return list
  8. })//生命周期初始化画布onMounted(()=>{initFabricCanvas(drawbasemap);//drawbasemap是绘制地图的});//图层弹框数据let layerlist =ref<Array<fabric.Object & p >>([]asArray<fabric.Object & p >)watch(()=>vnodelist.value,()=>{
  9. layerlist.value = canvas!.getObjects()asArray<fabric.Object & p>},{
  10. deep:true})//画布初始化操作functioninitFabricCanvas(callback){if(!basecontent.value)return;
  11. canvas =newfabric.Canvas('canvas',{
  12. width: basecontent.value.offsetWidth,
  13. height: basecontent.value.offsetHeight,
  14. preserveObjectStacking:true});
  15. callback &&callback()}//绘制底图 地图就是最底层的假地图图片,所以需要默认先绘制constdrawbasemap=()=>{const img =newImage();
  16. img.src = basemap;let id =generateUUID();
  17. img.onload=()=>{const imgLayer =newfabric.Image(img,{
  18. selectable:false,
  19. hasControls:false,
  20. left:0,
  21. top:0,
  22. scaleX: canvas!.width / img.width,
  23. scaleY: canvas!.height / img.height,
  24. z:1,
  25. id,
  26. types:'Base'});
  27. canvas!.add(imgLayer);}}//开始拖拽事件,根据classname判断拖拽的元素,不同的classname传递不同的typefunctiononStart(e){let classname =ref<string>('')
  28. classname.value = e.target.parentElement.className.split(' ')[0];switch(classname.value){case'item-one':
  29. e.dataTransfer.setData('type','Rect');break;case'item-two':
  30. e.dataTransfer.setData('type','Circle');break;case'device-one':
  31. e.dataTransfer.setData('type','device-one');break;case'device-two':
  32. e.dataTransfer.setData('type','device-two');break;case'device-three':
  33. e.dataTransfer.setData('type','device-three');break;default:break;}}//拖拽过程中阻止默认事件functiondragOver(e){
  34. e.preventDefault();}//拖拽完成绘制图形functiondrop(e){let types =ref<string>('');
  35. types.value = e.dataTransfer.getData('type');//这里拿到拖拽开始事件传递过来的typelet vnode:fabric.Object;let id =generateUUID();switch(types.value){//根据type绘制不同的图形case'Rect':let objone ={
  36. selectable:true,// 是否可选
  37. hasControls:false,
  38. top:(e.pageY -(e.pageY - e.offsetY))/scale.value,
  39. left:(e.pageX -(e.pageX - e.offsetX))/scale.value,//创建对象的x坐标
  40. width:150,//宽和高
  41. height:300,
  42. fill:'rgba(73, 120, 236,0.6)',//填充颜色
  43. stroke:'rgba(38, 162, 234,1)',//线条颜色
  44. strokeWidth:4,//线条宽度
  45. strokeOpacity:0.5,
  46. types:types.value,
  47. id,
  48. name:'地块',
  49. z:2,
  50. classify:'a',
  51. area:2,
  52. zoomX:scale.value,
  53. zoomY:scale.value,
  54. angle:0,
  55. visible:true}
  56. vnode =newfabric.Rect(objone)// 开始绘制
  57. canvas!.add(vnode);//添加到画布中去
  58. vnodelist.value.push(objone);break;case'Circle':let objtwo ={
  59. selectable:true,// 是否可选
  60. hasControls:false,
  61. top:(e.pageY -(e.pageY - e.offsetY))/scale.value,
  62. left:(e.pageX -(e.pageX - e.offsetX))/scale.value,//创建对象的x坐标
  63. rx:25,// 圆的水平半径
  64. ry:25,// 圆的垂直半径
  65. fill:'rgba(73, 120, 236,0.6)',// 填充颜色
  66. stroke:'rgba(255,255,255,1)',// 描边颜色
  67. strokeWidth:1,// 描边宽度
  68. types:types.value,
  69. id,
  70. name:'塘口',
  71. z:3,
  72. zoomX:scale.value,
  73. zoomY:scale.value,
  74. visible:true}
  75. vnode =newfabric.Ellipse(objtwo);
  76. canvas!.add(vnode);
  77. vnodelist.value.push(objtwo);break;case'device-one':const imgone =newImage();
  78. imgone.src = video;let oneparams =drawdevice(e,id,'视频监控');
  79. imgone.onload=()=>{const imgerone =newfabric.Image(imgone, oneparams);
  80. canvas!.add(imgerone);
  81. vnodelist.value.push(oneparams);}break;case'device-two':const imgtwo =newImage();
  82. imgtwo.src = onedevice;let twoparams =drawdevice(e,id,'一体设备');
  83. imgtwo.onload=()=>{const imgertwo =newfabric.Image(imgtwo,twoparams);
  84. canvas!.add(imgertwo);
  85. vnodelist.value.push(twoparams);}break;case'device-three':const imgthree =newImage();
  86. imgthree.src = video;let threeparams =drawdevice(e,id,'安防视频');
  87. imgthree.onload=()=>{const imgerthree =newfabric.Image(imgthree, threeparams);
  88. canvas!.add(imgerthree);
  89. vnodelist.value.push(threeparams);}break;default:break;}reorderObjectsByZ()}//绘制设备类图层参数处理functiondrawdevice(e:DragEvent,id:string,name:string){return{
  90. selectable:true,// 是否可选
  91. hasControls:false,
  92. top:(e.pageY -(e.pageY - e.offsetY))/scale.value,
  93. left:(e.pageX -(e.pageX - e.offsetX))/scale.value,
  94. z:4,
  95. id,
  96. name,
  97. refnumber:'0',
  98. versionid:'',
  99. types:'Device',
  100. zoomX:scale.value,
  101. zoomY:scale.value,
  102. visible:true}}//循环画布中的元素始终保持层级z有效functionreorderObjectsByZ(){//因为后绘制的图形层级会高一些,为了跟据z属性保持层级逻辑if(canvas){const objects = canvas!.getObjects().sort((a:fabric.Object & p, b:fabric.Object & p)=> a.z - b.z);//根据z属性排序
  103. canvas.clear();// 移除所有现有对象
  104. objects.forEach(obj =>{
  105. 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)//选择图层获取参数functionselectitem(item){
  106. cur.value = item;closeall();
  107. canvas!.getObjects().forEach((obj: fabric.Object & p)=>{if(cur.value.id == obj.id){//根据唯一id判断选中的哪个元素,获取数据回填表单let defaultparam ={
  108. id:obj.id,
  109. name:obj.name,
  110. types:obj.types
  111. }switch(obj.types){case'Rect':let rectfill =splitRgbaSimple(obj.fill asstring);let rectstroke =splitRgbaSimple(obj.stroke asstring);
  112. rectparams.value ={...defaultparam,
  113. fill:rectfill.color,
  114. fillopacity:rectfill.opacity,
  115. width: obj.width,
  116. height: obj.height,
  117. strokeWidth: obj.strokeWidth,
  118. stroke: rectstroke.color,
  119. strokeOpacity:rectstroke.opacity,
  120. area:obj.area ?+ obj.area :0,
  121. classify:obj.classify +'',
  122. angle:obj.angle
  123. };break;case'Circle':let circlefill =splitRgbaSimple(obj.fill asstring);let circlestroke =splitRgbaSimple(obj.stroke asstring);
  124. circleparams.value ={...defaultparam,
  125. fill:circlefill.color,
  126. fillopacity:circlefill.opacity,
  127. left: obj.left,
  128. top: obj.top,
  129. rx: obj.rx*2,
  130. ry: obj.ry*2,
  131. strokeWidth: obj.strokeWidth,
  132. stroke: circlestroke.color,
  133. strokeOpacity:circlestroke.opacity,};break;case'Device':
  134. deviceparams.value ={...defaultparam,
  135. refnumber:obj.refnumber +'',
  136. versionid:obj.versionid +'',};break;}
  137. canvas!.setActiveObject(obj);// 激活选中元素
  138. canvas!.renderAll();//重新渲染画布(虽然选中元素通常会自动触发重绘)}});}//将rgba提取为rgb的格式和透明度functionsplitRgbaSimple(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();//打开修改属性弹框functioneditattr(){let mapflag ={'Rect': massifDialog,'Circle': pondDialog,'Device': deviceDialog,}
  139. mapflag[cur.value.types].value.disbled =true;}//回填数据点击确定更新图层functionupdatecanvas(params:any){console.log(params);
  140. canvas!.getObjects().forEach((obj: fabric.Object & p)=>{if(cur.value.id == obj.id){switch(obj.types){case'Rect':
  141. obj.set({...params,
  142. width:params.width ?+params.width :50,
  143. height:params.height ?+params.height :100,
  144. stroke:rgbToRgba(params.stroke,params.strokeOpacity),
  145. fill:rgbToRgba(params.fill,params.fillopacity),
  146. angle:+params.angle
  147. });break;case'Circle':
  148. obj.set({...params,
  149. rx:params.rx ?(+params.rx)/2:25,
  150. ry:params.ry ?(+params.ry)/2:25,
  151. left:+params.left,
  152. top:+params.top,
  153. stroke:rgbToRgba(params.stroke,params.strokeOpacity),
  154. fill:rgbToRgba(params.fill,params.fillopacity)});break;case'Device':
  155. obj.set(params);break;}updatelist({name:obj.name,id:obj.id,types:obj.types})//更新列表中的数据
  156. canvas!.requestRenderAll();// 重新渲染画布(虽然选中元素通常会自动触发重绘)}});}//处理颜色格式 最终显示是rgba的格式functionrgbToRgba(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);
  157. alpha =(parseInt(alpha)/100).toFixed(1);return`rgba(${r}, ${g}, ${b}, ${alpha})`;}//控制图层弹框中选的图层控制当前元素的显示与隐藏functionupdatevisible(item){
  158. canvas!.getObjects().forEach((obj: fabric.Object & p)=>{if(item.id == obj.id){//查找当前选中的元素
  159. obj.visible = item.visible;
  160. canvas!.requestRenderAll()}});}//删除图层functionremovevnode(){
  161. canvas!.getObjects().forEach((obj: fabric.Object & p)=>{if(cur!.value!.id == obj?.id){//查找当前选中的元素
  162. canvas!.remove(obj);//移除元素removelist();//对应的左侧栏数据也移除}});}//更新左侧列表数据functionupdatelist(params){let i = vnodelist.value.findIndex(item => item.id == cur.value.id);
  163. vnodelist.value[i]= params;}//画布转jsonconsttojson=()=>{let jsonbefore =ref<{version:string,objects:Array<fabric.Object & p>}>({
  164. objects:[],
  165. version:'6.1.0'})
  166. canvas!.getObjects().forEach((obj: fabric.Object & p)=>{let objadd = obj.toObject();['id','z','name','types','selectable','hasControls','classify','area','refnumber','versionid'].forEach(item=>{
  167. obj[item]&&(objadd[item]= obj[item])})
  168. objadd.hasControls =false;
  169. jsonbefore.value.objects.push(objadd)});//调试专用
  170. localStorage.setItem('canvas',JSON.stringify(jsonbefore));
  171. canvas!.clear()//调用接口//return JSON.stringify(jsonbefore)}//用json回显画布consttocanvas=()=>{let json =JSON.parse(localStorage.getItem('canvas')asstring);
  172. canvas!.loadFromJSON(json._value,()=>{
  173. canvas!.requestRenderAll();setTimeout(()=>{
  174. scale.value = canvas!.getObjects()[1].zoomX asnumber;
  175. vnodelist.value = canvas!.getObjects().map((item:fabric.Object & p)=>{return{
  176. id: item.id,
  177. types:item.types,
  178. name:item.name,
  179. visible:item.visible,}})},100)});}//移除左侧列表数据functionremovelist(){closeall()let i = vnodelist.value.findIndex(item => item.id == cur.value.id);
  180. vnodelist.value.splice(i,1);}//关闭所有弹框functioncloseall(){[massifDialog,pondDialog,deviceDialog,layerDialog].forEach(item =>{
  181. item.value.disbled =false;})}//放大缩小事件 最大放大两倍 最小还原1:1let scale =ref<number>(1);functionzoomIn(){if(scale.value <2){
  182. scale.value +=0.1;// 可以调整步长来平滑缩放
  183. canvas!.setZoom(scale.value)}}//缩小functionzoomOut(){if(scale.value >1){
  184. scale.value -=0.1;
  185. canvas!.setZoom(scale.value);}}//打开图层弹框functionopenlyer(){closeall();
  186. 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交叉声明)

标签: 前端 fabric

本文转载自: https://blog.csdn.net/m0_52313178/article/details/141436798
版权归原作者 段子君 所有, 如有侵权,请联系我们删除。

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

还没有评论