前端拖拽 - 低代碼、可視化平臺的基石
HTML5 Drag and Drop 接口
html5中提供了一系列Drag and Drop 接口,主要包括四部分:DragEvent,DataTansfer,DataTransferItem 和DataTransferItemList。
DragEvent
源元素和目標元素
源元素: 即被拖拽的元素。
目標元素: 即合法的可釋放元素。
每個事件的事件主體都是兩者之一。
拖拽事件
事件 事件處理程序 事件主體 觸發(fā)時機
dragstart ondragstart 源元素 當源元素開始被拖拽。
drag ondrag 源元素 當源元素被拖拽(持續(xù)觸發(fā))。
dragend ondragend 源元素 當源元素拖拽結(jié)束(鼠標釋放或按下esc鍵)
dragenter ondragenter 目標元素 當被拖拽元素進入該元素。
dragover ondragover 目標元素 當被拖拽元素停留在該元素(持續(xù)觸發(fā))。
dragleave ondragleave 目標元素 當被拖拽元素離開該元素。
drop ondrop 目標元素 當拖拽事件在合法的目標元素上釋放。
觸發(fā)順序及次數(shù)
我們綁定相關(guān)的事件,拖放一次來查看相關(guān)事件的觸發(fā)情況。
我們讓相應事件處理程序打印事件名稱及事件觸發(fā)的主體是誰,下面截取部分展示。
我們可以看到對于被拖拽元素,事件觸發(fā)順序是 dragstart->drag->dragend;對于目標元素,事件觸發(fā)的順序是 dragenter->dragover->drop/dropleave。
其中drag和dragover會分別在源元素和目標元素反復觸發(fā)。整個流程一定是dragstart第一個觸發(fā),dragend最后一個觸發(fā)。
這里還有一個注意的點,如果某個元素同時設置了dragover和drop的監(jiān)聽,那么必須阻止dragover的默認行為,否則drop將不會被觸發(fā)。
DataTansfer
我們先用一張圖來直觀的感受一下:
我們可以看到,DataTransfer如同它的名字,作用就是在拖放過程中對數(shù)據(jù)進行傳輸,其中setData用來存放數(shù)據(jù),getData用來獲取數(shù)據(jù),出于安全的考量,數(shù)據(jù)只能在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');
});
可以看到在藍色方塊設置的數(shù)據(jù)被成功取得了。
DataTransferItemList
屬性
length: 列表中拖動項的數(shù)量。
方法
add(): 向拖動項列表中添加新項 (File對象或String),該方法返回一個 DataTransferItem) 對象。
remove(): 根據(jù)索引刪除拖動項列表中的對象。
clear(): 清空拖動項列表。
DataTransferItem(): 取值方法:返回給定下標的DataTransferItem對象.
DataTransferItem
屬性
kind: 拖拽項的種類,string 或是 file。
type: 拖拽項的類型,一般是一個MIME 類型。
方法
getAsString: 使用拖拽項的字符串作為參數(shù)執(zhí)行指定回調(diào)函數(shù)。
getAsFile: 返回一個關(guān)聯(lián)拖拽項的 File 對象 (當拖拽項不是一個文件時返回 null)。
實踐
學習了上面的基礎知識,我們從幾個常見的應用場景入手,來實踐上面的知識
可放置組件
知道上面幾個事件后,我們來完成一個簡單可放置組件,為了方便大家理解,這里不使用任何框架,以免增加不會框架同學的學習成本。
想讓組件可拖行,那么就要可以改變它的位置,有兩種思路:
pos:abs通過top/left等直接改變元素的位置
使用css的transform屬性中的translate對元素的位置進行改變
我推薦第二種,首先translate是基于本身的移動,因此自身的坐標就作為原點(0,0),但是第一種,元素本身的top/left等可能并不為0,計算起來比較復雜。
其次,第一種是通過cpu去計算,而第二種是通過gpu去計算,并且會提升到一個新的層,這樣做非常有利于頁面的性能。原因是 Chrome 這樣將 DOM 轉(zhuǎn)變成一個屏幕圖像:
獲取 DOM 并將其分割為多個層
將層作為紋理上傳至 GPU
復合多個層來生成最終的屏幕圖像。
但更新的幀可以走捷徑,不必經(jīng)歷所有過程:
如果某些特定 CSS 屬性變化,并不需要發(fā)生重繪。Chrome 可以使用早已作為紋理而存在于 GPU 中的層來重新復合,但會使用不同的復合屬性(例如,出現(xiàn)在不同的位置,擁有不同的透明度等等)。
如果圖層中某個元素需要重繪,那么整個圖層都需要重繪 。所以提升為一個新的層,可以減少重繪的次數(shù)。因為只改變位置,所以可以復用紋理,提高性能。
有了思路那么我們就開始吧!
首先我們要知道這次拖拽的向量是怎樣的,因為DragEvent繼承自MouseEvent ,所以我們可以通過MouseEvent接口的offsetX屬性和offsetY屬性獲取鼠標現(xiàn)在相對于該物體的位置差。
而transform設置多個屬性值,效果就可以疊加,所以我們要獲得之前的移動效果,再加上現(xiàn)在的移動效果即可,之前的移動效果可以通過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屬性獲?。╠ragstart)。改善如下:
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);
圖的連線
節(jié)點使用DOM渲染,連線我們使用SVG來渲染,框架使用React,但除了state盡量使用較少的框架相關(guān)的,以防非React技術(shù)棧的同學看不懂。
首先我們要先組織我們的state,作為一個圖,顯然應該由nodes和edges兩部分組成,我們都使用數(shù)組存儲,我們給每個node一個唯一的id,使用Map去映射id與對應的positon 形如 [x,y]的關(guān)系,而edge有源端與終端的id,通過id去獲得對應的坐標。
我們先假設節(jié)點可以完成所有功能了,只考慮連線,可以定義如下的組件
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來循環(huán)渲染,而是單獨渲染一個出來。在dragstart我們設置一個虛擬節(jié)點temNode,并記錄開始節(jié)點的id。
并如果有temNode則渲染一條預覽的edge。
temNode && (<Edge nodes = {[sourceNode,temNode]}></Edge>)
然后加入這條edge后,我們刪除虛擬節(jié)點,變?yōu)檠h(huán)渲染來展示所有的邊。
edges.map(([sourceId,targetId])=>{
const sourceNode = getNode(sourceId);
const targetNode = getNode(targetId);
return (
<Edge nodes = {[sourceNode,targetNode]}></Edge>);
})
那么我們只考慮邊的展示了,節(jié)點的功能應該如何完善呢?
首先是dragstart,我們要設置起始節(jié)點和虛擬節(jié)點
onDragStart={()=>{
setStartNodeId(uid);
setTemNode({position:[x,y]})
}}
然后既然邊可以跟著動,我們必然要在drag中動態(tài)的改變虛擬節(jié)點的位置
onDrag={(event)=>{
position=[x+event.nativeEvent.offsetX,y+event.nativeEvent.offsetY];
setTemNode({position})
}}
然后drop時,我們加入一條新的邊
onDrop={
(event)=>{
event.preventDefault();
setEdges(edges.concat([[startNodeId,uid]]))
}
}
最重要的是,不論在哪里事件結(jié)束了,要刪除虛擬節(jié)點
onDragEnd={
()=>{
setTemNode(null);
}
}
下面是最終成果:
參考
HTML Drag and Drop API - Web APIs | MDN (mozilla.org)
作者:灰兔呀
https://juejin.cn/post/7075918201359433758
作者:灰兔呀
歡迎關(guān)注微信公眾號 :前端開發(fā)愛好者
添加好友備注【進階學習】拉你進技術(shù)交流群