实现前端拖拽,应用在虚拟树

623 阅读4分钟

本文目标:借助JS事件如mousedown、mousemove、mouseup等实现拖拽功能,因项目需要拖拽不希望有交换元素的效果,故本文最终实现效果如下图:

tutieshi_628x520_6s.gif

本文项目代码

1. 项目前期准备

  1. 我是利用vue/cli启动的项目
  2. 准备一个需要拖动的列表,我演示的是一个虚拟组织树列表。如果你不知道如何实现虚拟组织树,可以看这里
  3. 在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>

image.png

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)"
  > 

在该方法中:

  1. 第一,拿到之前在public/index.html中声明的dom元素,并修改其name值
onMousedown (event, item) {
  // 拿到小球的logo
  let ball = document.getElementById('drag-logo');
  // 修改这个图标的name
  ball.querySelector('span.from').innerText = '目录' + item.name + '移动到';
},
  1. 第二,设置绝对定位和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)
},
  1. 第三,开始监听鼠标的mousemovemouseup方法。应该在鼠标点击的一开始就监听鼠标的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清空元素的事件监听器

此时页面的效果如下:

拖拽-小区域.gif

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值 image.png

但是存在一种可能,把A拖拽到B时,拖拽至B的li标签的子元素i标签上,这样elementFromPoint方法拿到的就是i标签的dom元素,可是我们把item.name的值绑定到li标签上,到底绑定在谁身上好一些呢?每个标签身上都绑定一个会更好吗?我觉得不是,绑定在li标签上我认为更好,这样每个子元素都能向上查找到li,最终拿到name值 image.png

第二,给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)
  }
},

如上代码中:

  1. 通过event的clientX和clientY找到鼠标经过的dom
  2. 再通过getElementParent方法,该方法先查找当前dom是否是指定目标小li,如果 不是,再看其parentNode是否是指定小li,不是,再向上查找。
  3. 注意,mouseEvent的clientX和pageX的区别是:pageX包括页面的滚动条,clientX不包括,pageX大于clientX

目前效果如下图,会有一个test-6移动到test-8这样的效果

拖拽提示3.gif

7. 鼠标点击和拖动事件冲突

当前稍微点击一下,就会出现拖拽的提示;拖拽和点击折叠功能会发生冲突,如下图 拖拽和点击拖拽.gif

解决方法是,判断鼠标移动的距离,如果小于一定范围,则才能触发拖拽,否则只能是点击:

在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) {
  }
},

如下是解决的效果:

拖拽冲突解决.gif

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里面进行的删除旧的,拖拽到新的操作,如下图 image.png
  • 在4.1~4.3中,注意这里要更新拍平的数据,否则拖动数据,页面视图不会更新;
  • 要通过代码触发(handleNodeClick)点击方法,展开to和from节点,把test-3-0拖拽到test-2节点中时,希望能够展开test-2节点,这样能看到刚刚拖拽的效果

tutieshi_384x402_7s.gif