低代码平台第一步绝对是要先实现拖拽功能,我开始也打算用现成的组件 vue.draggable ,但总是磕磕绊绊不顺利,所以用原始拖动API实现如上图。
我通过原生API来实现拖动有一个很大好处,就是实现解耦,我之前用vue.draggable时,要写很多嵌套代码把拖动和业务数据都耦合到一起了,现在我做法是用vue指令方式,写了 v-drag 和 v-drop 分别标志可拖动元素和可放置容器,相应配置两个指令的参数。
如下面代码就表示第一个input能被拖进第二个div中,后面我再具体讲参数。
<input v-drag/>
<div v-drop></div>
上图例子可看 gitee.com/rootegg/wan… 分支 drag
先明确拖动的功能需求:
1、从左侧组件列表中拖动组件到中间渲染,拖动后左侧组件列表还在(复制数据),不同于中间存在的组件随意拖动,中间拖动是移动(非复制)
2、布局组件可以被拖动,也可以是容器组件可以允许放置其他组件。但是有的容器只允许放置一个组件在里面,比如布局表格;有些容器没有数量限制。
3、非布局组件不允许内部放置组件
4、中间区域组件可以来回拖动,可以在随意变换组件位置和所在容器组件,可以从一个容器中拖动另一个容器中
回顾HTML原始拖动API
本章引自 《HTML5入门教程》
拖放是允许用户在某个元素上点击并按住鼠标按键,把它从一个位置拖到另外一个位置,然后释放鼠标按键将元素放置到该位置上。
拖放 API
拖放常用三个事件:dragstart 和 dragover、drop。
- 开始拖动元素会触发
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>
- 创建一个可拖放的对象
为了让元素可以被拖放,需要将目标元素的标签属性 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();
}
上述代码,设置了两个可以被拖拽放置的元素容器,这样将图片拖拽到目标容器中的时候,图片就可以被放置其中。
- 进行放置
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 的拖拽还是比较清晰简单的。
拖动插件实现关键问题
检查当前拖动元素在哪个放置容器上
在我们拖动过程,要显示红色虚线框,这样用户在拖动过程中就很清晰看出放置的位置,但问题是鼠标移动过程中怎么知道当前移动经过的是哪个容器呢?
这里可以理解为碰撞检测,既要知道鼠标经过的是哪个元素,此元素在哪个容器中,而且要防止冒泡,不然容器嵌套容器时会检测到多个容器。
核心代码是
// 可放置指令
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 事件,红色框就动态的插入到鼠标经过的放置容器中,用户就能一眼看出,其中重点是 isNearTopEdge 和 isNearBottomEdge 元素边界检测。
当然ondragover 事件中还需要做更多检查比如容器只允许放置一个元素,鼠标经过时已经有一个元素在容器中了,那就不能再显示红色框。
容器放置时删除与新增元素的先后顺序问题
上面ondragover 事件中已经通过红色框显示知道了将要放置的位置,实际放置时也有一些问题要处理,比如元素放置后,来源的元素是要删除还是要保留呢?分别对应的就是从左边控件列表中拖拽到中间容器中,还是中间容器中相互拖动位置两种情况。这里我们在:dropOption中通过clone参数决定,第一种情况就表示要克隆。
所以经过上面讨论,在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包文档说明和源码例子