本文目标:借助JS事件如mousedown、mousemove、mouseup等实现拖拽功能,因项目需要拖拽不希望有交换元素的效果,故本文最终实现效果如下图:
1. 项目前期准备
- 我是利用vue/cli启动的项目
- 准备一个需要拖动的列表,我演示的是一个虚拟组织树列表。如果你不知道如何实现
虚拟组织树,可以看这里 - 在index.html文件中准备一个dom元素,该元素是鼠标拖动跟随鼠标的提示。i标签图标,from是拖动的元素,to是被拖动的元素
<!-- 拖动时出现的元素 -->
<div id="drag-logo">
<i class="el-icon-folder"></i>
<span class="from"></span>
<span class="to"></span>
</div>
2. mousedown事件
mdn对于该事件的描述:mousedown 事件在定点设备(如鼠标或触摸板)按钮在元素内按下时,会在该元素上触发。
常见就是鼠标左键按下那一刻触发
给需要添加拖拽的列表绑定mousedown事件,并把当前的item项传递到该方法中
<ul
:style="{transform: `translateY(${offset}px)`}"
>
<li
v-for="item in visibleData"
:key="item.path + item.name"
@click="handleNodeClick(item)"
:style="{paddingLeft: `${item.level * 5}px`}"
+ @mousedown="onMousedown($event, item)"
>
在该方法中:
- 第一,拿到之前在public/index.html中声明的dom元素,并修改其name值
onMousedown (event, item) {
// 拿到小球的logo
let ball = document.getElementById('drag-logo');
// 修改这个图标的name
ball.querySelector('span.from').innerText = '目录' + item.name + '移动到';
},
- 第二,设置绝对定位和z-index和moveAt方法(该方法会在下文介绍)
onMousedown (event, item) {
// 拿到小球的logo
let ball = document.getElementById('drag-logo');
console.log(ball, 'ball');
// 增加鼠标跟随
ball.querySelector('span.from').innerText = '目录' + item.name + '移动到';
+ ball.style.position = 'absolute';
+ ball.style.zIndex = 1000;
// moveAt方法,公共处理鼠标的跟随效果
+ this.moveAt(event.pageX, event.pageY)
},
- 第三,开始监听鼠标的mousemove和mouseup方法。应该在鼠标点击的一开始就监听鼠标的mousemove方法和mouseup方法(该两个方法会在下文介绍)
onMousedown (event, item) {
// 拿到小球的logo
let ball = document.getElementById('drag-logo');
// 修改name
ball.querySelector('span.from').innerText = '目录' + item.name + '移动到';
// 设置鼠标的定位
ball.style.position = 'absolute';
ball.style.zIndex = 1000;
// 移动
this.moveAt(event.pageX, event.pageY)
+ // 记录鼠标点击的x和y坐标
+ this.clientX = event.clientX;
+ this.clientY = event.clientY;
+ // 开始拖动监听鼠标事件
+ document.addEventListener('mousemove', this.onMouseMove)
+ document.addEventListener('mouseup', this.onMouseUp)
},
3. moveAt方法
在moveAt方法中设置left和top属性,进而让步骤一种准备的dom元素跟随鼠标移动
moveAt (pageX, pageY) {
let ball = document.getElementById("drag-logo");
// 设置鼠标的left和top值让文字跟随鼠标
ball.style.left = pageX + 15 + 'px'
ball.style.top = pageY + 'px'
},
4. mousemove事件和mouseup事件
mdn对于mousemove事件介绍:mousemove 事件在定点设备(通常指鼠标)的光标在元素内移动时,会在该元素上触发。就是鼠标移动经过元素时会触发
4.1 增加mousemove事件对应onMouseMove方法
onMouseMove (event) {
this.moveAt(event.pageX, event.pageY)
},
如上代码中,调用第三步设置的moveAt方法,不断更新鼠标跟随的位置
4.2 增加mouseup事件对应的onMouseUp方法
mouseup事件的mdn解释是:mouseup 事件在定点设备(如鼠标或触摸板)按钮在元素内释放时,在该元素上触发。常用就是鼠标松开左键时触发
onMouseUp(event) {
// 首先隐藏该图标
let ball = document.getElementById("drag-logo");
ball.style.opacity = 0;
ball.querySelector("span").innerText = ""
// 解绑对应的事件
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onDocumentMouseup);
},
如上代码中:
- 通过
opacity隐藏元素的显示 - 通过
innerText = ''置空元素的内容 - 通过
removeEventListener清空元素的事件监听器
此时页面的效果如下:
5. 拖拽到指定目录
拖拽时,需要知道:从哪个数据项开始拖拽、拖拽到哪个目标上。
如何获取拖拽到哪个目标?采用是document.elementFromPoint方法, 该方法的mdn的解释是:Document对象的 elementFromPoint接受() 方法返回给定相对于视口的坐标点下最上层的 Element
elementFromPoint接受x和y两个坐标,我们通过event的clientX和clientY,能够拿到鼠标拖拽的目标的dom元素是谁
第一,我们先给列表的每个数据项绑定一个自定义属性:
<li
v-for="item in visibleData"
:key="item.path + item.name"
@click="handleNodeClick(item)"
:style="{paddingLeft: `${item.level * 5}px`}"
:draggable="false"
@mousedown="onMousedown($event, item)"
+ :attrs-path="item.name"
>
这样每个小li的dom元素上都能够拿到其name值
但是存在一种可能,把A拖拽到B时,拖拽至B的li标签的子元素i标签上,这样elementFromPoint方法拿到的就是i标签的dom元素,可是我们把item.name的值绑定到li标签上,到底绑定在谁身上好一些呢?每个标签身上都绑定一个会更好吗?我觉得不是,绑定在li标签上我认为更好,这样每个子元素都能向上查找到li,最终拿到name值
第二,给i标签增加类名,希望找到li标签:
<li
v-for="item in visibleData"
:key="item.path + item.name"
@click="handleNodeClick(item)"
:style="{paddingLeft: `${item.level * 5}px`}"
:draggable="false"
@mousedown="onMousedown($event, item)"
:attrs-path="item.name"
+ class="tree-item-content"
>
第三,onMouseMove中增加:
onMouseMove (event) {
let ball = document.getElementById('drag-logo');
// 拖动经过某个目标拿到的dom元素
+ let target = document.elementFromPoint(event.clientX, event.clientY); // 鼠标松开在谁身上
+ let parent = this.getElementParent(target, 'tree-item-content') // 找到其父元素(鼠标松开到目录上的target可能不统一)
ball.querySelector('span.to').innerText = '...'
this.moveAt(event.pageX, event.pageY)
},
第四,递归查找父元素
getElementParent (node, parentName) {
if (!node) return null
// 就是当前目录
if (node.className && node.className.includes(parentName)) {
return node
}
let parent = node.parentNode
// 拿到父级目录
let flag = parent && parent.className && parent.className.includes(parentName)
if (flag) {
return parent
} else {
// 递归
this.getElementParent(parent, parentName)
}
},
如上代码中:
- 通过event的clientX和clientY找到鼠标经过的dom
- 再通过getElementParent方法,该方法先查找当前dom是否是指定目标小li,如果 不是,再看其parentNode是否是指定小li,不是,再向上查找。
- 注意,mouseEvent的clientX和pageX的区别是:pageX包括页面的滚动条,clientX不包括,pageX大于clientX
目前效果如下图,会有一个test-6移动到test-8这样的效果
7. 鼠标点击和拖动事件冲突
当前稍微点击一下,就会出现拖拽的提示;拖拽和点击折叠功能会发生冲突,如下图
解决方法是,判断鼠标移动的距离,如果小于一定范围,则才能触发拖拽,否则只能是点击:
在mousemove方法中,增加鼠标经过距离判断,只有在距离大于8(暂先假定8),才执行下面的逻辑
onMouseMove(event) {
// 计算鼠标移动的距离
+ let distance = Math.sqrt(Math.pow(event.pageX - this.pageX, 2) + Math.pow(event.pageY - this.pageY, 2))
+ // 鼠标移动距离超过8才执行,避免点一下就触发
+ if (distance > 8) {
+ this.isMouseMove = true
在mouseup方法中也要做同样的处理
mouseup (event) {
……
+ if (this.isMouseMove) {
}
},
如下是解决的效果:
mouseup中,应该立刻清空clientX和clientY的值为0
onMouseUp (event) {
+ let ball = document.getElementById("drag-logo");
+ ball.querySelector("span.from").innerText = ""
+ ball.querySelector("span.to").innerText = ""
+ ball.style.opacity = 0;
+ document.removeEventListener('mousemove', this.onMouseMove);
+ document.removeEventListener('mouseup', this.onMouseUp);
if (this.isMove) {
// 在指定dom上松开的
let target = document.elementFromPoint(event.clientX, event.clientY);
// 找到指定子元素的父元素li标签
let toItem = this.getElementParent(target, 'tree-item-content')
// 更新数据
this.updateData(toItem)
}
// 鼠标每次松开,都要取消移动
+ this.isMove = false
// 鼠标左键松开,置空点击的按钮
+ this.clientX = 0
+ this.clientY = 0
},
8. 更新数据
在虚拟数里面更新拖拽后数据:
// 在这里更新数据
updateDragData (toItem) {
// 1. 提前获取拖拽对象的数据和父节点
let {element, parent} = this.getChildAndParentTree(this.treeData, this.dragItem.name)
this.dragParent = parent
if (!parent) {
// 1.1 没有parent,第一层数据,直接删this.treeData
const spliceIndex = this.treeData.findIndex(item => item.name === element.name)
this.treeData.splice(spliceIndex, 1)
} else {
// 1.2 有parent表示是第二、三及以上的节点
const spliceIndex = parent.children.findIndex(item => item.name === element.name)
parent.children.splice(spliceIndex, 1)
}
// 2.1 拿到目标的name值
let toName = toItem.getAttribute('attrs-path')
// 2.2 找到目标的dom
let toTarget = this.getChildAndParentTree(this.treeData, toName)
// 3. push到目标的children中,成为他的子数据
toTarget.element.children.push(this.dragItem)
// 4.1 更新拍平的数据
this.flattenTree = this.flatten(this.treeData)
// 4.2 手动触发点击方法,展开to节点
this.handleNodeClick(toTarget.element)
// 4.3 手动展开from节点
this.handleNodeClick(this.dragParent)
// 5. 置空拖拽对象
this.dragItem = null
},
如上:
- 在1中通过拖拽的对象,提前记录拖拽对象的父亲,避免更新后其父亲直变化
- 在1.1和1.2里面进行的删除旧的,拖拽到新的操作,如下图
- 在4.1~4.3中,注意这里要更新拍平的数据,否则拖动数据,页面视图不会更新;
- 要通过代码触发(handleNodeClick)点击方法,展开to和from节点,把test-3-0拖拽到test-2节点中时,希望能够展开test-2节点,这样能看到刚刚拖拽的效果