十二.玩转可视化拖拽之原生篇
前言
接上一篇,通过插件实现了拖拽缩放等功能,但是也产生一些问题,这次针对这些问题,使用原生方法来进行优化和调整。
1.vuedraggable我们只是使用了克隆元素,拖动到指定盒子,其他很多功能没有用到,不如使用原生方便;
2.vue-drag-resize没有吸附对齐,冲突检测,辅助线等功能,交互体检上不太好,所以这里打算尝试一下用原生办法实现这些功能。
原生实现
原生实现前提,需要对js中drop、dragover、mousedown等有一定的了解。 draggable:通过设置这个属性,可以使元素可被拖拽;
dragstart:当开始拖动元素时触发;
dragover:当元素被拖拽到另一个元素上移动时触发;
drop:当元素拖拽到另一个元素释放时触发;
mousedown:鼠标按下时触发;
mousemove:鼠标按下后移动时触发;
mouseuo:鼠标移开释放时触发。
拖动元素到画布
首先,我们需要给元素设置draggable
属性,表示元素可被拖拽,然后为了能拿到当前拖动的元素下标,我们可以给元素设置 :data-index="index"
,然后再通过dragstart
,获取到当前拖动元素的下标。
左侧元素:
<div class="drag-component" @dragstart="handleDragStart">
<div
class="drag-component-item"
draggable
:data-index="index"
v-for="(item, index) in componentList"
:key="index"
>
<el-icon name="circle-plus-outline" class="icon"></el-icon
>{{ item.title }}
</div>
</div>
data(){
return{
componentList: [
{
title: "矩形盒子",
componentName: "dragBox",
id: "",
config: {
w: 150,
h: 150,
x: "",
y: "",
},
},
{
title: "圆形盒子",
componentName: "dragCircle",
id: "",
config: {
w: 150,
h: 150,
x: "",
y: "",
},
},
],
currentIndex:null
}
}
methods:{
handleDragStart(event) {
this.currentIndex = event.target.dataset.index;
},
}
右边画布区域: 画布区域主要做两件事:1.dragover时取消事件默认行为和设置光标为copy;2.drop释放时,记录当前在画布的位置,赋值top,left等。
<div
class="drag-canvas"
id="dragCanvas"
@drop="handleDropEnd"
@dragover="handleDragOver">
<component
v-for="(com, index) in drawList"
:key="index"
:is="com.componentName"
v-bind="com.config"
:style="getStyle(com.config)"
/>
</div>
computed: {
getStyle() {
return (com) => {
return {
width: com.w + "px",
height: com.h + "px",
left: com.x + "px",
top: com.y + "px",
position: "absolute",
};
};
},
},
methods:{
//拖动结束
handleDropEnd(e) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.clearData()
const index = this.currentIndex;
//获取画布区域的位置信息
const rectInfo = document
.getElementById("dragCanvas")
.getBoundingClientRect();
if (index || index === 0) {
const component = JSON.parse(JSON.stringify(this.componentList[index]));
component.config.y = (e.clientY - rectInfo.y);
component.config.x = (e.clientX - rectInfo.x);
component.id = "box_" + new Date().getTime();
this.drawList.push({ ...component });
}
},
//移动中
handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
},
}
这样即可实现左边元素克隆拖拽到右边画布区域。
画布移动
针对需要画布元素移动和缩放,我对前面的<component/>
外层定义了一个<drag-item></drag-item>
,所有的移动缩放操作都需要在这个组件里面进行,所以右边的画布代码就变成:
<div
class="drag-canvas"
id="dragCanvas"
@drop="handleDropEnd"
@dragover="handleDragOver">
<drag-item
v-for="com in drawList"
:key="com.id"
:w.sync="com.config.w"
:h.sync="com.config.h"
:x.sync="com.config.x"
:y.sync="com.config.y"
>
<component :is="com.componentName" v-bind="com.config" />
</drag-item>
</div>
然后就开始对这个drag-item
组件进行封装设置:
1.最外层盒子,需要将前面的computed
的getStyle
计算挪到这边来,这里多说一下,我们不能直接使用传递过来的width,height,top,left,不然你进行拖动或缩放,会直接改变当前的width等,违背了props单向数据流,所以这里直接在这个组件定义一个boxStyle参数,用来接收位置信息等。(可以用watch,进行赋值操作)
2.需要初始化定义几种状态,例如:active,draging,resizing
,分别代表当前盒子是否处于活跃,是否处于拖动中和缩放中;
3.所谓对盒子进行移动,首先需要对盒子移动的位置进行记录(即鼠标按下时记录当前位置),然后在盒子移动中获取新的位置信息,然后减去旧的盒子位置,就是当前挪动的距离。
4.禁止移动操作,可以通过props
传递isDraggable
这个属性,判断当前是否允许移动。
<div
:class="[
'drag-item',
{
active: active,
'vue-drag-moving': draging,
'vue-drag-resizing': resizing,
},
]"
:style="dragStyle"
@mousedown.stop.prevent="handleDragStart($event)"
>
</div>
data(){
return{
draging: false,
resizing: false,
active: false,
boxStyle: {
top: "",
left: "",
width: "",
height: "",
},
//移动前的位置信息
beforeMove: {
left: null,
top: null,
width: null,
height: null,
oldPageX: null,
oldPageY: null,
},
}
}
methods:{
//鼠标按下时,记录当前位置
handleDragStart(event){
this.active = true;
event.stopPropagation();
//如果为false,禁止移动
if (!this.isDraggable) {
return;
}
//记录当前位置的pageX和pageY
const { pageX, pageY } = event;
this.setBeforeMove({ oldPageX: pageX, oldPageY: pageY });
this.draging = true;
document.onmousemove = (event) => this.handleDragMove(event);
document.onmouseup = () => {
//拖动结束,将数据清空
document.onmousemove = null;
document.onmouseup = null;
this.$emit("draged", {
left: this.boxStyle.left,
top: this.boxStyle.top,
});
this.beforeMove = {
left: null,
top: null,
width: null,
height: null,
oldPageX: null,
oldPageY: null,
};
this.draging = false;
};
},
//移动中的盒子
handleDragMove(event){
//当前位置信息
let { pageX, pageY } = event;
//移动前位置信息
let { top, left, oldPageX, oldPageY } = this.beforeMove;
//这里就是移动后的位置
let newTop = pageY - oldPageY + top,
newLeft =pageX - oldPageX + left;
Object.assign(this.boxStyle, snapObj);
this.$emit("update:x", newLeft);
this.$emit("update:y", newTop);
this.$emit("draging", this.boxStyle);
}
//存储resize或移动前位置信息
setBeforeMove(position) {
const { left, top, height, width } = this.boxStyle;
this.beforeMove = {
left,
top,
height,
width,
...position,
};
},
}
画布缩放
进行缩放需要考虑几个点:
1.缩放角的位置展示;
2.记录缩放前的位置信息,这里稍微比移动复杂点,后面详细说;
3.禁止缩放操作,可以通过props
传递isResizable
这个属性,判断当前是否允许缩放。
可缩放角的实现
先定义sticks=["tl", "tm", "tr", "ml", "mr", "bl", "bm", "br"]
代表对应的几个角,那这几个角的位置信息如何展示呢?
假设这个时候每个缩放角的高宽都是8px
tl:即top left,左上角的点位,他的位置应该是top:-4px,left:-4px
tm:即top middle,头部中间的点位,他的位置应该是:top:-4px,margin-left:-4px,left:50%;
tr:即top right,右上角的点位,他的位置应该是:top:-4px,right:-4px;
ml:即middle left,中间左边的点位,他的位置应该是:left:-4px,margin-top:-4px,top:50%;
mr:即middle right,中间右边的点位,他的位置应该是:right:-4px,margin-top:-4px,top:50%;
bl:即bottom left,左下角的点位,他的位置应该是:bottom:-4px,left:-4px;
bm:即bottom middle,底部中间的点位,他的位置应该是:bottom:-4px,margin-left:-4px,left:50%;
br:即bottom right,右下角的点位,他的位置应该是:bottom:-4px,right:-4px;
这里参考vue-drag-resize源码的方法,定义好x轴和y轴对应的位置,然后通过css设置ml,mr,tm,bm对应top:50%或left:50%,这样即可实现8个角的位置。
const stickPosition = {
x: {
l: "left",
m: "marginLeft",
r: "right",
},
y: {
t: "top",
m: "marginTop",
b: "bottom",
},
};
<div
v-for="stick in sticks"
:key="stick"
:class="['vue-drag-stick', `vue-drag-stick-${stick}`]"
:style="stickStyle(stick)"
@mousedown.stop.prevent="handleResizeStart(stick, $event)"
></div>
//这里stickSize是传来的缩放角大小,默认为8
computed: {
stickStyle() {
const { stickSize } = this;
return (stick) => {
let stickObj = {
width: stickSize + "px",
height: stickSize + "px",
};
stickObj[stickPosition.y[stick[0]]] = -stickSize / 2 + "px";
stickObj[stickPosition.x[stick[1]]] = -stickSize / 2 + "px";
return stickObj;
};
},
},
缩放功能的实现
同样的,还是通过鼠标按下,移动中,鼠标弹起三个事件:
handleResizeStart(stick, event) {
event.stopPropagation();
//记录当前是哪个角进行的缩放
this.currentStick = stick;
const { pageX, pageY } = event;
this.resizing = true;
//和前面一样,记录缩放前位置
this.setBeforeMove({ oldPageX: pageX, oldPageY: pageY });
document.onmousemove = (event) => this.handleResizeMove(event);
document.onmouseup = () => {
//缩放结束,清空值
document.onmousemove = null;
document.onmouseup = null;
this.currentStick = null;
this.$emit("resized", this.boxStyle);
this.beforeMove = {
left: null,
top: null,
width: null,
height: null,
oldPageX: null,
oldPageY: null,
};
this.resizing = false;
};
},
关于这个缩放中,这里着重说一下,它分为几种情况:
这里的oldPageY,oldPageX,height,width,left,top是缩放前的位置信息,pageX,pageY是新的位置信息
1.缩放tm
,即头部中间,它会改变的是top和height,如果往上拉,它的top减少,height增加,计算公式为:
newHeight = oldPageY - pageY + height;
newTop = pageY - oldPageY + top;
2.缩放tl
,即左上角,它会改变的是top和height,width和left,计算公式为:
newTop = pageY - oldPageY + top;
newLeft = pageX - oldPageX + left;
newHeight = oldPageY - pageY + height;
newWidth = oldPageX - pageX + width;
3.缩放tr
,即右上角,它会改变的是top和height,width,计算公式为:
newTop = pageY - oldPageY + top;
newHeight = oldPageY - pageY + height;
newWidth = pageX - oldPageX + width;
4.缩放ml
,即中左边,它会改变的是left和width,计算公式为:
newLeft = pageX - oldPageX + left;
newWidth = oldPageX - pageX + width;
5.缩放mr
,即中右边,它会改变的是width,计算公式为:
newWidth = pageX - oldPageX + width;
6.缩放bl
,即左下角,它会改变的是width,left,和heigth,计算公式为:
newLeft = pageX - oldPageX + left;
newWidth = oldPageX - pageX + width;
newHeight = pageY - oldPageY + height;
7.缩放bm
,即底中部,它会改变的是heigth,计算公式为:
newHeight = pageY - oldPageY + height;
8.缩放br
,即右下角,它会改变的是heigth和width,计算公式为:
newHeight = pageY - oldPageY + height;
newWidth = pageX - oldPageX + width;
根据上方的计算公式,缩放逻辑即可得出:
handleResizeMove(event) {
let { pageX, pageY } = event;
let { top, left, height, width, oldPageX, oldPageY } = this.beforeMove;
let newHeight = height,
newWidth = width,
newTop = top,
newLeft = left;
switch (this.currentStick) {
case "tl":
newTop = pageY - oldPageY + top;
newLeft = pageX - oldPageX + left;
newHeight = oldPageY - pageY + height;
newWidth = oldPageX - pageX + width;
break;
case "tm":
newHeight = oldPageY - pageY + height;
newTop = pageY - oldPageY + top;
break;
case "tr":
newTop = pageY - oldPageY + top;
newHeight = oldPageY - pageY + height;
newWidth = pageX - oldPageX + width;
break;
case "ml":
newLeft = pageX - oldPageX + left;
newWidth = oldPageX - pageX + width;
break;
case "mr":
newWidth = pageX - oldPageX + width;
break;
case "bl":
newLeft = pageX - oldPageX + left;
newWidth = oldPageX - pageX + width;
newHeight = pageY - oldPageY + height;
break;
case "bm":
newHeight = pageY - oldPageY + height;
break;
case "br":
newHeight = pageY - oldPageY + height;
newWidth = pageX - oldPageX + width;
break;
default:
break;
}
this.$emit("resizing", {
width: this.boxStyle.width,
height: this.boxStyle.height,
left: this.boxStyle.left,
top: this.boxStyle.top,
});
}
冲突检测
前面已经实现盒子的缩放和拖动功能,但是出现了两个盒子重叠的问题,它会在缩放和移动中都会出现这种问题,所以这里进行下优化操作: 在缩放和移动结束(mouseup)里面,判断当前位置是否和其他盒子冲突,如果有,就还原之前的位置。
如何判断冲突?这里也分几种情况:
这里的boxStyle已经是当前移动后的位置了,move的时候已经修改了这个值了
1.当前盒子的右边>其他盒子的左边,即left+width>otherLeft,右冲突
2.其他的盒子的右边>当前盒子的左边,即otherLeft+otherWidth>left,左冲突
3.当前盒子的底部>其他盒子的头部,即top+height>otherTop,下冲突
4.其他的盒子的底部>当前盒子的头部,即otherTop+otherHeight>top,上冲突
conflictCheck(){
return new Promise((resolve) => {
let childNode = this.$el.parentNode.childNodes;
const { top, left, width, height } = this.boxStyle;
//获取父盒子下面的所有子盒子
for (let i = 0; i < childNode.length - 1; i++) {
let node = childNode[i];
if (
node.className.includes("vue-drag-moving") ||
node.className.includes("vue-drag-resizing")
) {
continue;
}
let style = node.style;
if (
left + width > parseFloat(style.left) &&
parseFloat(style.left) + parseFloat(style.width) > left &&
top + height > parseFloat(style.top) &&
parseFloat(style.top) + parseFloat(style.height) > top
) {
resolve(true);
}
}
resolve(false);
});
}
然后根据这个返回的值,判断是否需要复原初始位置。
吸附对齐
吸附对齐主要是为了优化拖拽,防止拖拽时无法对齐的问题,同时这里衍生出另一个功能: 每次拖拽固定移动距离
固定移动距离:设置每次移动股东的距离,主要实现方法:
前面提起过,每次移动的距离如下,假设定义一个参数:snapNumber=10
newTop = pageY - oldPageY + top,
newLeft =pageX - oldPageX + left;
固定移动距离后:
newTop =Math.round((pageY - oldPageY) / snapNumber) * snapNumber + top;
newLeft =Math.round((pageX - oldPageX) / snapNumber) *snapNumber +left;
接下来言归正传,实现吸附功能,具体实现逻辑为:移动盒子的时候,获取当前画布上的除移动盒子的外的所有盒子,判断是否他们的left
或top
的差在一个范围内,如果在,直接就把固定盒子的left
或top
赋值给移动的盒子,完成吸附功能。
他主要分为几种情况:x轴的吸附和y轴的吸附
这里定义 width,left,top,height为移动盒子的属性,cl,cw,ch,ct为其他盒子的属性,通过这个我们可以得出计算公式,计算方法同上面缩放差不多。
左对左吸附:
const lTol = Math.abs(cl - left) < snapNumber;
右对右吸附:
const rTor = Math.abs(cl + cw - left - width) < snapNumber;
左对右吸附:
const lToR = Math.abs(cw + cl - left) < snapNumber;
右对左吸附:
const rTol = Math.abs(cl - left - width) < snapNumber;
中对中吸附:
const cToc = Math.abs(cl + cw / 2 - left - width / 2) < snapNumber;
顶对顶吸附:
const tTot = Math.abs(ct - top) < snapNumber;
顶对底吸附:
const tTob = Math.abs(ct - top - height) < snapNumber;
底对底吸附:
const bTob = Math.abs(ct + ch - top - height) < snapNumber;
底对顶吸附:
const bTot = Math.abs(ct + ch - top) < snapNumber;
中对中吸附:
const CToC = Math.abs(ct + ch / 2 - top - height / 2) < snapNumber;
然后再判断如果这个里面那个为true,就把对应的top,left值=cl或ct,这样即可实现吸附效果(完整代码在最下方地址)
辅助线
辅助线功能这个走了点弯路,当时想着把辅助线放在当前盒子里面,但是发现了一个问题,他的top,left是根据当前盒子的定位,我们获取到的位置信息是外面画布的定位,所以这里把辅助线单独写了一个组件,和盒子同级,通过eventBus进行传值。
其实如果实现了吸附功能,辅助线功能也就水到渠成,根据吸附功能,我们可以拿到当前吸附的位置信息,然后通过eventBus传递给辅助线组件即可,这里定义y为Y轴的辅助线,左中右分别为:l,c,r; x为X轴的辅助线,上中下分为t,c,b
左对左辅助线:
const lTol = Math.abs(cl - left) < snapNumber;
eventBus.$emit("moving", "yl", left);
右对右辅助线:
const rTor = Math.abs(cl + cw - left - width) < snapNumber;
eventBus.$emit("moving", "yr", cl + cw);
左对右辅助线:
const lToR = Math.abs(cw + cl - left) < snapNumber;
eventBus.$emit("moving", "yl", left);
右对左辅助线:
const rTol = Math.abs(cl - left - width) < snapNumber;
eventBus.$emit("moving", "yr", cl);
中对中辅助线:
const cToc = Math.abs(cl + cw / 2 - left - width / 2) < snapNumber;
eventBus.$emit("moving", "yc", cl + cw / 2);
顶对顶辅助线:
const tTot = Math.abs(ct - top) < snapNumber;
eventBus.$emit("moving", "xt", top);
顶对底辅助线:
const tTob = Math.abs(ct - top - height) < snapNumber;
eventBus.$emit("moving", "xt", ct);
底对底辅助线:
const bTob = Math.abs(ct + ch - top - height) < snapNumber;
eventBus.$emit("moving", "xt", ct + ch);
底对顶辅助线:
const bTot = Math.abs(ct + ch - top) < snapNumber;
eventBus.$emit("moving", "xt", top);
中对中辅助线:
const CToC = Math.abs(ct + ch / 2 - top - height / 2) < snapNumber;
eventBus.$emit("moving", "xc", ct + ch / 2);
对应markLine组件,先将6条线展示上去:
<div
v-for="line in lines"
:key="line.type"
v-show="line.show"
:class="['drag-line', `drag-line-${line.type}`]"
:style="lineStyle(line)"
></div>
lines: [
{
type: "xt",
position: "",
show: false,
},
{
type: "xc",
position: "",
show: false,
},
{
type: "xb",
position: "",
show: false,
},
{
type: "yl",
position: "",
show: false,
},
{
type: "yc",
position: "",
show: false,
},
{
type: "yr",
position: "",
show: false,
},
],
computed: {
lineStyle() {
return (line) => {
return {
//这里的guideBackground,guideSize可以传进来,代表意思是辅助线的颜色和宽度
background: this.guideBackground,
top: line.type.indexOf("y") ? line.position + "px" : "unset",
left: line.type.indexOf("x") ? line.position + "px" : "unset",
width: line.type.indexOf("x") ? this.guideSize + "px" : "100%",
height: line.type.indexOf("y") ? this.guideSize + "px" : "100%",
};
};
},
},
然后再监听盒子组件传递过来的moving
和moved
事件,每次moveing
触发的时候都先把line
的show
变为false
,然后再判断当前传递的type
是哪个,然后把他对应的postion
和show
赋值即可;moved
移动结束后,将所有show
变成false
;
mounted() {
eventBus.$on("moving", (type, position) => {
this.lines.forEach((item) => (item.show = false));
this.draging = true;
if (type) {
this.setMarkLine(type, position);
}
});
eventBus.$on("moved", () => {
this.lines.forEach((item) => (item.show = false));
this.draging = false;
});
},
setMarkLine(type, position) {
for (let i = 0; i < this.lines.length; i++) {
let item = this.lines[i];
if (item.type == type) {
item.show = true;
item.position = position;
break;
}
}
},
这样,我们初步设想的功能都完成啦~
后续
后续可能还会加上一些其他功能,如不可超出父盒子,setting可视化配置等,不断优化中...
最后
到这里,通过原生实现已经完成了,希望该文章能对你有帮助,如果疑问和建议,可在评论区留言,如果觉得本文写的不错,麻烦点个赞。
在线预览地址:vue-drag-template 项目地址:vue-drag-template
其他文章
转载自:https://juejin.cn/post/7095576145097129992