前端通过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交叉声明)