低代码第一章(拖拽实现)

1,037 阅读10分钟

image.png

低代码平台第一步绝对是要先实现拖拽功能,我开始也打算用现成的组件 vue.draggable ,但总是磕磕绊绊不顺利,所以用原始拖动API实现如上图。

我通过原生API来实现拖动有一个很大好处,就是实现解耦,我之前用vue.draggable时,要写很多嵌套代码把拖动和业务数据都耦合到一起了,现在我做法是用vue指令方式,写了 v-dragv-drop 分别标志可拖动元素和可放置容器,相应配置两个指令的参数。

如下面代码就表示第一个input能被拖进第二个div中,后面我再具体讲参数。

<input v-drag/>
<div v-drop></div>

上图例子可看 gitee.com/rootegg/wan… 分支 drag

先明确拖动的功能需求:

1、从左侧组件列表中拖动组件到中间渲染,拖动后左侧组件列表还在(复制数据),不同于中间存在的组件随意拖动,中间拖动是移动(非复制)

2、布局组件可以被拖动,也可以是容器组件可以允许放置其他组件。但是有的容器只允许放置一个组件在里面,比如布局表格;有些容器没有数量限制。

3、非布局组件不允许内部放置组件

4、中间区域组件可以来回拖动,可以在随意变换组件位置和所在容器组件,可以从一个容器中拖动另一个容器中

回顾HTML原始拖动API

本章引自 《HTML5入门教程》

拖放是允许用户在某个元素上点击并按住鼠标按键,把它从一个位置拖到另外一个位置,然后释放鼠标按键将元素放置到该位置上。

拖放 API

拖放常用三个事件:dragstart 和 dragoverdrop

  • 开始拖动元素会触发 dragstart 事件。
  • 拖动的元素拖到指定的区域时会触发 dragover 事件。
  • 在指定区域放置拖动元素会触发 drop 事件。

拖放 API 在拖放期间只会触发拖放事件,对于拖放操作期间的鼠标事件,比如 mousemove 事件并不会触发

事件描述
dragstart用户开始拖动对象时触发。
dragenter鼠标初次移到目标元素并且正在进行拖动时触发。这个事件的监听器应该之指出这个位置是否允许放置元素。如果没有监听器或者监听器不执行任何操作,默认情况下不允许放置。
dragover拖动时鼠标移到某个元素上的时候触发。大多数时候,监听器触发的操作与 dragenter 事件相同。
dragleave拖动时鼠标离开某个元素的时候触发。监听器应该移除用于放置反馈的高亮或插入标记。
drag对象被拖拽时每次鼠标移动都会触发。
drop拖动操作结束,放置元素时触发。监听器负责检索被拖动的数据以及在放置位置插入它。
dragend拖动对象时用户释放鼠标按键的时候触发。

拖放对象 DragTransfer

拖放对象(DragTransfer)是 HTML5 中用于表示拖动操作的数据传输对象。它包含了一些属性和方法,可以帮助开发者在拖放过程中传输数据。

DragTransfer 属性

属性描述
types包含了可用数据类型的 DOMString 列表。
effectAllowed表示拖动操作的可接受效果的 DOMString。
dropEffect表示放置操作的效果的 DOMString。
files包含了用户拖动到拖放区域内的文件的 FileList。
setData(format, data)添加指定类型给定的数据
getData(format)返回指定的数据。如果没有该数据则返回空字符串。
clearData([format])移除指定格式的数据。如果省略参数则移除所有数据。
常见的拖放 Types

拖放对象的 types 属性包含了可用数据类型的 DOMString 列表,它们表示可以在拖放操作中传输的数据类型。在 HTML5 中,拖放对象定义了一些常见的数据类型。

类型描述
text/plain表示纯文本数据类型。
text/html表示 HTML 格式的文本数据类型。
text/xml表示 XML 格式的文本数据类型。
text/uri-list表示一个 URL 列表。
application/json表示 JSON 格式的数据类型。
image/png表示 PNG 格式的图片数据类型。
image/jpeg表示 JPEG 格式的图片数据类型。
image/gif表示 GIF 格式的图片数据类型。
audio/mpeg表示 MP3 格式的音频数据类型。
video/mp4表示 MP4 格式的视频数据类型
application/pdf表示 PDF 格式的文档数据类型。

如何使用 DragTransfer 对象

在拖放过程中,开发者可以使用 dataTransfer 属性来访问拖放对象。在拖动元素时,使用 setData 方法来设置要传输的数据。代码示例如下:

event.dataTransfer.setData('text/plain', 'Drag Data');

在这个示例中,使用 DataTransfer.setData 方法设置了一个名为 text/plain 的数据类型,并将要传输的数据设置为 Drag Data

在放置元素时,可以使用 DataTransfer.getData 方法来获取传输的数据。代码示例如下:

const data = event.dataTransfer.getData('text/plain'); // 获取到的应该是 "Drag Data"

getData(format) 的数据类型必须与 setData(format, data) 类型保持一致,如果没有找到对应的数据类型,则返回一个空字符串。

修改拖动时样式

官方示例 dataTransfer.setDragImage(img | element, xOffset, yOffset);

function dragstart_handler(ev) { 
    var img = new Image(); 
    img.src = 'example.jpg'; 
    ev.dataTransfer.setDragImage(img, 0, 0); 
}

完整拖动例子

<!DOCTYPE html>
<html lang="en">
<body>
  <div class="container" ondrop="drop(event)" ondragover="allowDrop(event)">
    <img src="./images/juejin.svg" draggable="true" ondragstart="drag(event)" id="drag1" width="100" height="32">
  </div>
  <div class="container" ondrop="drop(event)" ondragover="allowDrop(event)"></div>
</body>
<script>
  function allowDrop(event) {
    event.preventDefault();
  }
  
  function drag(event) {
    event.dataTransfer.setData("text/plain", event.target.id);
  }
  
  function drop(event) {
    event.preventDefault();
    const data = event.dataTransfer.getData("text/plain");
    event.target.appendChild(document.getElementById(data));
  }
</script>
</html>
  1. 创建一个可拖放的对象

为了让元素可以被拖放,需要将目标元素的标签属性 draggable 设置为 true

<img src="./images/juejin.svg" draggable="true" width="100" height="32">

2. 拖动开始事件 ondragstart 和 DataTransfer.setData

设置完成可拖放对象后,需要对元素被拖动的时候会发生什么事件做相应的处理,一般来说通过 ondragstart 事件调用一个函数,拖动过程中通过 DataTransfer.setData 来对拖动的数据格式以及内容做处理,具体代码如下:

HTML 代码部分,ondragstart 绑定 drag(event) 方法。

 <img src="./images/juejin.svg" draggable="true" ondragstart="drag(event)" id="drag_container1" width="100" height="32">

JavaScript 代码部分,设置拖拽的数据格式和内容。

function drag(event) {
  event.dataTransfer.setData("text/plain", event.target.id);
}

3. 放置到哪里 ondragover

开发者需要处理拖拽元素要放置的位置,ondragover 事件规定在何处放置被拖动的数据。

需要注意的是,默认情况下,无法将数据或者元素放置到其他元素中。如果需要设置允许放置,必须阻止对应元素的默认处理方式

HTML 代码,对目标元素绑定 ondragover 事件

<div class="container" ondragover="allowDrop(event)"></div>
<div class="container" ondrop="drop(event)" ondragover="allowDrop(event)"></div>

JavaScript 代码,实现允许目标元素作为容器接受拖拽元素的放置

function allowDrop(event) {
  event.preventDefault();
}

上述代码,设置了两个可以被拖拽放置的元素容器,这样将图片拖拽到目标容器中的时候,图片就可以被放置其中。

  1. 进行放置 ondrop

最后,当放置被拖元素以及携带的数据时,会发生 ondrop 事件,方便开发者处理元素拖放后要处理的回调内容。

HTML 代码,为容器绑定 ondrop 事件。

<div class="container" ondrop="drop(event)"></div>
<div class="container" ondrop="drop(event)"></div>

JavaScript 代码,拖拽完成后向目标容器放置拖拽元素。

function drop(event) {
  event.preventDefault();
  const data = event.dataTransfer.getData("text/plain");
  event.target.appendChild(document.getElementById(data));
}

从上述代码可以看出,draggable 属性只是让元素变成可拖动,拖动过程以及最后的放置行为都是需要开发者去实现代码才可以的,上述整个流程讲解下来,其实 HTML5 的拖拽还是比较清晰简单的。

拖动插件实现关键问题

检查当前拖动元素在哪个放置容器上

image.png

在我们拖动过程,要显示红色虚线框,这样用户在拖动过程中就很清晰看出放置的位置,但问题是鼠标移动过程中怎么知道当前移动经过的是哪个容器呢?

这里可以理解为碰撞检测,既要知道鼠标经过的是哪个元素,此元素在哪个容器中,而且要防止冒泡,不然容器嵌套容器时会检测到多个容器。

核心代码是

// 可放置指令
  el.ondragover = (event: DragEvent) => {
      // 必须停止冒泡,不然放置容器层层嵌套时,会检测到最外出放置容器,不是我们需要的
       event.stopPropagation();
       
      // 获取到当前放置容器的所有子节点
      const children = [...el.children];
      
      // 遍历当前子节点,看哪个接近鼠标
      for (let index = 0; index < children.length; index++) {
          const child = children[index];
          
          // DomTool.isNearTopEdge 函数看源码,意思如果是靠近容器节点上边缘4像素返回true
          const isShowTopBlock = DomTool.isNearTopEdge(child, event);
          
          // DomTool.isNearBottomEdge 函数看源码,意思如果是靠近容器节点下边缘22像素返回true
          const isShowBottomBlock = DomTool.isNearBottomEdge(child, event);
          
          // 如果检测到靠近上或者下边缘,分别在上面或者下面插入红色框框
          if (isShowTopBlock) {
            DomTool.appendEmptyDom(el, child);
          } else if (isShowBottomBlock) {
            DomTool.appendEmptyDom(el, child?.nextSibling);
          }
          
          // 检测到就返回退出循环
          if (isShowTopBlock || isShowBottomBlock) {
            return;
          }
      }
  }

这样拖动过程中不断触发 ondragover 事件,红色框就动态的插入到鼠标经过的放置容器中,用户就能一眼看出,其中重点是 isNearTopEdgeisNearBottomEdge 元素边界检测。

当然ondragover 事件中还需要做更多检查比如容器只允许放置一个元素,鼠标经过时已经有一个元素在容器中了,那就不能再显示红色框。

容器放置时删除与新增元素的先后顺序问题

上面ondragover 事件中已经通过红色框显示知道了将要放置的位置,实际放置时也有一些问题要处理,比如元素放置后,来源的元素是要删除还是要保留呢?分别对应的就是从左边控件列表中拖拽到中间容器中,还是中间容器中相互拖动位置两种情况。这里我们在:dropOption中通过clone参数决定,第一种情况就表示要克隆。

image.png

所以经过上面讨论,在ondrop事件中,我们要区分开是克隆还是移动两种情况,核心代码

el.ondrop = (event: DragEvent) => {
      event.stopPropagation();
      
      // 有clone属性表示新增,否则表示移动
      if (dragOption.clone) {
        // 新增
        triggerOnDropAdd();
      } else {
        // 移动
        moveCloneLocation();
      }
}

不论是新增还是移动,我们都是通过上面红框位置来决定要插入的位置,这就很方便了。

1、新增元素

新增很好理解,在红框位置先浅拷贝原始元素,再插入到红框位置即可。

function triggerOnDropAdd() {
    // 找到红框位置
    const insertIndex = DomTool.getEmptyDomIndex();
    
    // 新增都是拷贝原始元素
    const copyElement = DomTool.copyShallowElement(dragOption.element);
    
    // 插入到红框位置
    dropOption.children.splice(insertIndex, 0, copyElement);
  }

2、移动元素

移动元素要复杂一些,要分三种情况讨论

第一种情况:如果移动是在两个不同放置容器中移动,那先删除老容器中元素再添加新容器元素

function moveCloneLocation() {

    // 找到红框位置
    const insertIndex = DomTool.getEmptyDomIndex();
    
    // 如果是跨区域移动
    if (
      DomTool.getUpParentDrop(DomTool.emptyDom) !=
        DomTool.getUpParentDrop(moveTargetDom)
    ) {
      // 跨区域拖动,删除旧再添加新
      dragOption.brother.splice(oldIndex, 1);
      triggerOnDropMove();
      return;
    }
}

第二种情况:如果是在同一个容器内移动元素,要比较新旧位置,如果新位置比旧位置索引小,那么先删除旧元素再插入新元素

第三种情况:如果是在同一个容器内移动元素,要比较新旧位置,如果新位置比旧位置索引大,那么先插入新元素再删除旧元素

// 同区域拖动
  if (oldIndex > insertIndex!) {
    // 删除旧再添加新
    dragOption.brother.splice(oldIndex, 1);
    triggerOnDropMove();
  } else if (oldIndex < insertIndex!) {
    // 删除新再添加旧
    triggerOnDropMove();
    dragOption.brother.splice(oldIndex, 1);
  }

vue3move封装成插件

综上所述,拖动实现完成了,我已经将上面封装成插件

我把拖动代码封装成了vue3插件 www.npmjs.com/package/vue…

npm i vue3move

具体用法可以看npm包文档说明和源码例子