拖放是一種很棒的介面解決方案。拿起某個東西並將其拖放是執行許多事情的清晰且簡單的方法,從複製和移動文件(如檔案管理員)到排序(將項目放入購物車)。
在現代 HTML 標準中,有一個關於拖放的章節,其中包含特殊事件,例如 dragstart
、dragend
等。
這些事件允許我們支援特殊類型的拖放,例如處理從作業系統檔案管理員拖放檔案並將其放到瀏覽器視窗中。然後 JavaScript 可以存取這些檔案的內容。
但原生拖曳事件也有其限制。例如,我們無法防止從特定區域拖曳。我們也無法只讓拖曳「水平」或「垂直」。還有許多其他無法使用原生拖曳事件完成的拖放任務。此外,行動裝置對此類事件的支援非常薄弱。
因此,我們將在此瞭解如何使用滑鼠事件實作拖放。
拖放演算法
基本的拖放演算法如下
- 在
mousedown
上 – 準備元素移動,如果需要的話(也許建立它的複製,加入一個類別或其他)。 - 然後在
mousemove
上使用position:absolute
改變left/top
來移動它。 - 在
mouseup
上 – 執行所有與完成拖放相關的動作。
這些是基礎。稍後我們將看到如何加入其他功能,例如在我們拖曳時高亮目前底層的元素。
以下是拖曳一個球的實作
ball.onmousedown = function(event) {
// (1) prepare to moving: make absolute and on top by z-index
ball.style.position = 'absolute';
ball.style.zIndex = 1000;
// move it out of any current parents directly into body
// to make it positioned relative to the body
document.body.append(ball);
// centers the ball at (pageX, pageY) coordinates
function moveAt(pageX, pageY) {
ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
}
// move our absolutely positioned ball under the pointer
moveAt(event.pageX, event.pageY);
function onMouseMove(event) {
moveAt(event.pageX, event.pageY);
}
// (2) move the ball on mousemove
document.addEventListener('mousemove', onMouseMove);
// (3) drop the ball, remove unneeded handlers
ball.onmouseup = function() {
document.removeEventListener('mousemove', onMouseMove);
ball.onmouseup = null;
};
};
如果我們執行程式碼,我們會注意到一些奇怪的事情。在拖放的開始,球會「分叉」:我們開始拖曳它的「複製」。
以下是實際的範例
嘗試用滑鼠拖放,你會看到這樣的行為。
那是因為瀏覽器有它自己的拖放支援,用於圖片和其他一些元素。它會自動執行並與我們的衝突。
要停用它
ball.ondragstart = function() {
return false;
};
現在一切都沒問題了。
實際執行
另一個重要的面向 – 我們在 document
上追蹤 mousemove
,而不是在 ball
上。從第一眼看來,滑鼠似乎總是停留在球上,我們可以將 mousemove
放在它上面。
但正如我們記得的,mousemove
會經常觸發,但不是每個畫素。因此在快速移動後,指標可能會從球跳到文件的中間某處(甚至視窗外)。
所以我們應該在 document
上監聽來捕捉它。
正確定位
在上面的範例中,球總是移動,使其中心在指標下方
ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
不錯,但有一個副作用。要啟動拖放,我們可以在球上的任何地方 mousedown
。但如果從它的邊緣「取用」它,那麼球會突然「跳」到滑鼠指標下方,使其居中。
如果我們保持元素相對於指標的初始偏移,那會更好。
例如,如果我們從球的邊緣開始拖曳,那麼指標在拖曳時應該保持在邊緣上方。
讓我們更新我們的演算法
-
當訪客按下按鈕(
mousedown
)時 – 記住指標到球的左上角的距離,在變數shiftX/shiftY
中。我們在拖曳時會保持那個距離。要取得這些偏移,我們可以減掉座標
// onmousedown let shiftX = event.clientX - ball.getBoundingClientRect().left; let shiftY = event.clientY - ball.getBoundingClientRect().top;
-
然後在拖曳時,我們將球定位在相對於指標的相同偏移量,如下所示
// onmousemove // ball has position:absolute ball.style.left = event.pageX - shiftX + 'px'; ball.style.top = event.pageY - shiftY + 'px';
具有更好定位的最終程式碼
ball.onmousedown = function(event) {
let shiftX = event.clientX - ball.getBoundingClientRect().left;
let shiftY = event.clientY - ball.getBoundingClientRect().top;
ball.style.position = 'absolute';
ball.style.zIndex = 1000;
document.body.append(ball);
moveAt(event.pageX, event.pageY);
// moves the ball at (pageX, pageY) coordinates
// taking initial shifts into account
function moveAt(pageX, pageY) {
ball.style.left = pageX - shiftX + 'px';
ball.style.top = pageY - shiftY + 'px';
}
function onMouseMove(event) {
moveAt(event.pageX, event.pageY);
}
// move the ball on mousemove
document.addEventListener('mousemove', onMouseMove);
// drop the ball, remove unneeded handlers
ball.onmouseup = function() {
document.removeEventListener('mousemove', onMouseMove);
ball.onmouseup = null;
};
};
ball.ondragstart = function() {
return false;
};
在動作中 (在 <iframe>
內)
如果我們從右下角拖曳球,差異特別明顯。在之前的範例中,球會在指標下方「跳動」。現在它會從目前位置流暢地追隨指標。
潛在的放置目標 (可放置)
在之前的範例中,球可以放置在「任何地方」以保持原狀。在實際生活中,我們通常會取一個元素並將其放置到另一個元素上。例如,將「檔案」放入「資料夾」或其他東西。
抽象來說,我們取一個「可拖曳」元素並將其放置到「可放置」元素上。
我們需要知道
- 元素在拖曳與放置結束時放置在哪裡 - 以執行對應的動作,
- 而且最好知道我們正在拖曳的放置目標,以將其反白顯示。
解決方案有點有趣,而且有點棘手,所以我們在此說明。
第一個想法是什麼?可能是對潛在的放置目標設定 mouseover/mouseup
處理常式?
但這不起作用。
問題在於,當我們正在拖曳時,可拖曳元素始終在其他元素上方。而且滑鼠事件只會發生在最上層元素,不會發生在下方元素。
例如,以下有兩個 <div>
元素,紅色元素在藍色元素上方 (完全覆蓋)。無法在藍色元素上擷取事件,因為紅色元素在上方
<style>
div {
width: 50px;
height: 50px;
position: absolute;
top: 0;
}
</style>
<div style="background:blue" onmouseover="alert('never works')"></div>
<div style="background:red" onmouseover="alert('over red!')"></div>
可拖曳元素也是如此。球始終在其他元素上方,因此事件會發生在球上。無論我們在較低層元素上設定什麼處理常式,它們都不會執行。
這就是為什麼在潛在的放置目標上放置處理常式的初始想法在實務上不起作用。它們不會執行。
那麼,該怎麼辦?
有一個稱為 document.elementFromPoint(clientX, clientY)
的方法。它會傳回給定視窗相對座標上最巢狀的元素 (或如果給定的座標超出視窗,則傳回 null
)。如果在相同座標上有多個重疊元素,則會傳回最上層的元素。
我們可以在任何滑鼠事件處理常式中使用它來偵測指標下方潛在的放置目標,如下所示
// in a mouse event handler
ball.hidden = true; // (*) hide the element that we drag
let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
// elemBelow is the element below the ball, may be droppable
ball.hidden = false;
請注意:我們需要在呼叫 (*)
之前隱藏球。否則,我們通常會在這些座標上有一個球,因為它是指標下方的最上層元素:elemBelow=ball
。因此,我們隱藏它,然後立即再次顯示它。
我們可以使用該程式碼隨時檢查我們「飛越」哪個元素。並在發生時處理拖放。
onMouseMove
的延伸程式碼,用於尋找「可拖放」元素
// potential droppable that we're flying over right now
let currentDroppable = null;
function onMouseMove(event) {
moveAt(event.pageX, event.pageY);
ball.hidden = true;
let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
ball.hidden = false;
// mousemove events may trigger out of the window (when the ball is dragged off-screen)
// if clientX/clientY are out of the window, then elementFromPoint returns null
if (!elemBelow) return;
// potential droppables are labeled with the class "droppable" (can be other logic)
let droppableBelow = elemBelow.closest('.droppable');
if (currentDroppable != droppableBelow) {
// we're flying in or out...
// note: both values can be null
// currentDroppable=null if we were not over a droppable before this event (e.g over an empty space)
// droppableBelow=null if we're not over a droppable now, during this event
if (currentDroppable) {
// the logic to process "flying out" of the droppable (remove highlight)
leaveDroppable(currentDroppable);
}
currentDroppable = droppableBelow;
if (currentDroppable) {
// the logic to process "flying in" of the droppable
enterDroppable(currentDroppable);
}
}
}
在以下範例中,當球拖曳到足球球門時,球門會被反白。
#gate {
cursor: pointer;
margin-bottom: 100px;
width: 83px;
height: 46px;
}
#ball {
cursor: pointer;
width: 40px;
height: 40px;
}
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="style.css">
</head>
<body>
<p>Drag the ball.</p>
<img src="https://en.js.cx/clipart/soccer-gate.svg" id="gate" class="droppable">
<img src="https://en.js.cx/clipart/ball.svg" id="ball">
<script>
let currentDroppable = null;
ball.onmousedown = function(event) {
let shiftX = event.clientX - ball.getBoundingClientRect().left;
let shiftY = event.clientY - ball.getBoundingClientRect().top;
ball.style.position = 'absolute';
ball.style.zIndex = 1000;
document.body.append(ball);
moveAt(event.pageX, event.pageY);
function moveAt(pageX, pageY) {
ball.style.left = pageX - shiftX + 'px';
ball.style.top = pageY - shiftY + 'px';
}
function onMouseMove(event) {
moveAt(event.pageX, event.pageY);
ball.hidden = true;
let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
ball.hidden = false;
if (!elemBelow) return;
let droppableBelow = elemBelow.closest('.droppable');
if (currentDroppable != droppableBelow) {
if (currentDroppable) { // null when we were not over a droppable before this event
leaveDroppable(currentDroppable);
}
currentDroppable = droppableBelow;
if (currentDroppable) { // null if we're not coming over a droppable now
// (maybe just left the droppable)
enterDroppable(currentDroppable);
}
}
}
document.addEventListener('mousemove', onMouseMove);
ball.onmouseup = function() {
document.removeEventListener('mousemove', onMouseMove);
ball.onmouseup = null;
};
};
function enterDroppable(elem) {
elem.style.background = 'pink';
}
function leaveDroppable(elem) {
elem.style.background = '';
}
ball.ondragstart = function() {
return false;
};
</script>
</body>
</html>
現在我們在整個過程中,都有變數 currentDroppable
中當前「拖放目標」,我們正在飛越它,可以使用它來反白或任何其他東西。
摘要
我們考慮了一個基本的拖放演算法。
關鍵組成部分
- 事件流程:
ball.mousedown
→document.mousemove
→ball.mouseup
(別忘了取消本機ondragstart
)。 - 在拖曳開始時 - 記住指標相對於元素的初始偏移:
shiftX/shiftY
,並在拖曳期間保持它。 - 使用
document.elementFromPoint
偵測指標下的可拖放元素。
我們可以在這個基礎上做很多事。
- 在
mouseup
上,我們可以在概念上完成拖放:變更資料、移動元素。 - 我們可以反白我們飛越的元素。
- 我們可以限制拖曳在特定區域或方向。
- 我們可以使用事件委派進行
mousedown/up
。檢查event.target
的大區域事件處理常式可以管理數百個元素的拖放。 - 等等。
有些架構會在其上建立架構:DragZone
、Droppable
、Draggable
和其他類別。它們大多數都執行與上述類似的操作,所以現在應該很容易理解它們。或者自行開發,因為你可以看到這很容易做到,有時比調整第三方解決方案更容易。
註解
<code>
標籤,若要插入多行,請將它們包在<pre>
標籤中,若要插入 10 行以上,請使用沙盒(plnkr、jsbin、codepen…)