前端拖拽 - 低代码、可视化平台的基石

HTML5 Drag and Drop 接口
html5中提供了一系列Drag and Drop 接口,主要包括四部分:DragEvent,DataTansfer,DataTransferItem 和DataTransferItemList。

DragEvent
源元素和目标元素

源元素: 即被拖拽的元素。

目标元素: 即合法的可释放元素。

每个事件的事件主体都是两者之一。

拖拽事件
事件    事件处理程序    事件主体    触发时机
dragstart    ondragstart    源元素    当源元素开始被拖拽。
drag    ondrag    源元素    当源元素被拖拽(持续触发)。
dragend    ondragend    源元素    当源元素拖拽结束(鼠标释放或按下esc键)
dragenter    ondragenter    目标元素    当被拖拽元素进入该元素。
dragover    ondragover    目标元素    当被拖拽元素停留在该元素(持续触发)。
dragleave    ondragleave    目标元素    当被拖拽元素离开该元素。
drop    ondrop    目标元素    当拖拽事件在合法的目标元素上释放。
触发顺序及次数
我们绑定相关的事件,拖放一次来查看相关事件的触发情况。



我们让相应事件处理程序打印事件名称及事件触发的主体是谁,下面截取部分展示。


我们可以看到对于被拖拽元素,事件触发顺序是   dragstart->drag->dragend;对于目标元素,事件触发的顺序是  dragenter->dragover->drop/dropleave。

其中drag和dragover会分别在源元素和目标元素反复触发。整个流程一定是dragstart第一个触发,dragend最后一个触发。

这里还有一个注意的点,如果某个元素同时设置了dragover和drop的监听,那么必须阻止dragover的默认行为,否则drop将不会被触发。


DataTansfer
我们先用一张图来直观的感受一下:


我们可以看到,DataTransfer如同它的名字,作用就是在拖放过程中对数据进行传输,其中setData用来存放数据,getData用来获取数据,出于安全的考量,数据只能在drop时获取,而effectAllowed和dropEffect则影响鼠标展示的样式,下面我们用一个例子来进行展示:

sourceElem.addEventListener('dragstart', (event) => {
    event.dataTransfer.effectAllowed = 'move';
    event.dataTransfer.setData('text/plain', '放进来了');
});
targetElem.addEventListener('dragover', (event) => {
    event.preventDefault();
    event.dataTransfer.dropEffect = 'move';
});
targetElem.addEventListener('drop', (event) => {
    event.target.innerHTML = event.dataTransfer.getData('text/plain');
});

可以看到在蓝色方块设置的数据被成功取得了。

DataTransferItemList
属性
length: 列表中拖动项的数量。

方法
add(): 向拖动项列表中添加新项 (File对象或String),该方法返回一个 DataTransferItem) 对象。

remove(): 根据索引删除拖动项列表中的对象。

clear(): 清空拖动项列表。

DataTransferItem(): 取值方法:返回给定下标的DataTransferItem对象.

DataTransferItem
属性
kind: 拖拽项的种类,string 或是 file。

type: 拖拽项的类型,一般是一个MIME 类型。






方法
getAsString: 使用拖拽项的字符串作为参数执行指定回调函数。

getAsFile: 返回一个关联拖拽项的 File 对象 (当拖拽项不是一个文件时返回 null)。

实践
学习了上面的基础知识,我们从几个常见的应用场景入手,来实践上面的知识

可放置组件
知道上面几个事件后,我们来完成一个简单可放置组件,为了方便大家理解,这里不使用任何框架,以免增加不会框架同学的学习成本。

想让组件可拖行,那么就要可以改变它的位置,有两种思路:

pos:abs通过top/left等直接改变元素的位置
使用css的transform属性中的translate对元素的位置进行改变
我推荐第二种,首先translate是基于本身的移动,因此自身的坐标就作为原点(0,0),但是第一种,元素本身的top/left等可能并不为0,计算起来比较复杂。

其次,第一种是通过cpu去计算,而第二种是通过gpu去计算,并且会提升到一个新的层,这样做非常有利于页面的性能。原因是 Chrome 这样将 DOM 转变成一个屏幕图像:

获取 DOM 并将其分割为多个层
将层作为纹理上传至 GPU
复合多个层来生成最终的屏幕图像。
但更新的帧可以走捷径,不必经历所有过程:

如果某些特定 CSS 属性变化,并不需要发生重绘。Chrome 可以使用早已作为纹理而存在于 GPU 中的层来重新复合,但会使用不同的复合属性(例如,出现在不同的位置,拥有不同的透明度等等)。

如果图层中某个元素需要重绘,那么整个图层都需要重绘 。所以提升为一个新的层,可以减少重绘的次数。因为只改变位置,所以可以复用纹理,提高性能。

有了思路那么我们就开始吧!

首先我们要知道这次拖拽的向量是怎样的,因为DragEvent继承自MouseEvent ,所以我们可以通过MouseEvent接口的offsetX属性和offsetY属性获取鼠标现在相对于该物体的位置差。

而transform设置多个属性值,效果就可以叠加,所以我们要获得之前的移动效果,再加上现在的移动效果即可,之前的移动效果可以通过window.getComputedStyle(e.target).transform获得。

sourceElem.addEventListener('dragend', (e) => {
    const startPosition = window.getComputedStyle(e.target).transform;
    e.target.style.transform = `${startPosition} translate(${e.offsetX}px, ${e.offsetY}px)`;
}, true);
我们给要拖拽的元素加上这段处理程序似乎就大功告成了。



但是实际使用时,这个元素并没有停在预览的位置,而是左上角移动到鼠标的位置,显然不符合预期,相信大家都能猜到,我们少考虑了鼠标在元素的位置,而鼠标初始的位置同样可以通过MouseEvent接口的offsetX属性和offsetY属性获取(dragstart)。改善如下:

function enableDrag(element) {
    let mouseDiff = null;
    element.addEventListener('dragstart', (e) => {
        //初始时鼠标与元素的位置差
        mouseDiff = `translate(${-e.offsetX}px, ${-e.offsetY}px)`
    }, true);
    element.addEventListener('dragend', (e) => {
        //开始时元素的位置
        const startPosition = window.getComputedStyle(e.target).transform;
        //鼠标移动的位置
        const mouseMove = `translate(${e.offsetX}px, ${e.offsetY}px)`;
        e.target.style.transform = `${mouseDiff} ${startPosition} ${mouseMove}`;
    }, true);
}
enableDrag(souceElement);

图的连线
节点使用DOM渲染,连线我们使用SVG来渲染,框架使用React,但除了state尽量使用较少的框架相关的,以防非React技术栈的同学看不懂。

首先我们要先组织我们的state,作为一个图,显然应该由nodes和edges两部分组成,我们都使用数组存储,我们给每个node一个唯一的id,使用Map去映射id与对应的positon 形如 [x,y]的关系,而edge有源端与终端的id,通过id去获得对应的坐标。

我们先假设节点可以完成所有功能了,只考虑连线,可以定义如下的组件

const Edge = ({nodes:[sourceNode,targetNode]}) =>(
    <svg key={sourceNode.id + targetNode.id||''}
        style={{position:'absolute',
        overflow:'visible',
        zIndex:'-1',
        transform:'translate(15px,15px)'}}>
        <path d={`M ${sourceNode.position[0]} ${sourceNode.position[1]}
            C
            ${(targetNode.position[0]  + sourceNode.position[0])/2} ${sourceNode.position[1]}
            ${(targetNode.position[0]  + sourceNode.position[0])/2} ${targetNode.position[1]}
            ${targetNode.position[0]} ${targetNode.position[1]} `}
            strokeWidth={6}
            stroke={'red'}
            fill='none'
        ></path>
    </svg>
)
首先我们应该从什么时候生成一个连线呢,显然是dragstart,但这时还没有对应的终端,因此不应该通过加入edges来循环渲染,而是单独渲染一个出来。在dragstart我们设置一个虚拟节点temNode,并记录开始节点的id。

并如果有temNode则渲染一条预览的edge。

temNode && (<Edge nodes = {[sourceNode,temNode]}></Edge>)

然后加入这条edge后,我们删除虚拟节点,变为循环渲染来展示所有的边。

edges.map(([sourceId,targetId])=>{
    const sourceNode = getNode(sourceId);
    const targetNode = getNode(targetId);
    return (
        <Edge nodes = {[sourceNode,targetNode]}></Edge>);
})
那么我们只考虑边的展示了,节点的功能应该如何完善呢?

首先是dragstart,我们要设置起始节点和虚拟节点

onDragStart={()=>{
    setStartNodeId(uid);
    setTemNode({position:[x,y]})
}}
然后既然边可以跟着动,我们必然要在drag中动态的改变虚拟节点的位置

onDrag={(event)=>{
    position=[x+event.nativeEvent.offsetX,y+event.nativeEvent.offsetY];
    setTemNode({position})
}}
然后drop时,我们加入一条新的边

onDrop={
    (event)=>{
        event.preventDefault();
        setEdges(edges.concat([[startNodeId,uid]]))
    }
}
最重要的是,不论在哪里事件结束了,要删除虚拟节点

onDragEnd={
    ()=>{
        setTemNode(null);
    }
}
下面是最终成果:



参考
HTML Drag and Drop API - Web APIs | MDN (mozilla.org)

作者:灰兔呀

https://juejin.cn/post/7075918201359433758

作者:灰兔呀


欢迎关注微信公众号 :前端开发爱好者


添加好友备注【进阶学习】拉你进技术交流群