Javascript 鼠標拖放事件

2023-02-17 10:54 更新

拖放(Drag'n'Drop)是一個很贊的界面解決方案。取某件東西并將其拖放是執(zhí)行許多東西的一種簡單明了的方式,從復制和移動文檔(如在文件管理器中)到訂購(將物品放入購物車)。

在現代 HTML 標準中有一個 關于拖放的部分,其中包含了例如 dragstart 和 dragend 等特殊事件。

這些事件使我們能夠支持特殊類型的拖放,例如處理從 OS 文件管理器中拖動文件,并將其拖放到瀏覽器窗口中。之后,JavaScript 便可以訪問此類文件中的內容。

但是,原生的拖放事件也有其局限性。例如,我們無法阻止從特定區(qū)域的拖動。并且,我們無法將拖動變成“水平”或“豎直”的。還有很多其他使用它們無法完成的拖放任務。并且,移動設備對此類事件的支持非常有限。

因此,在這里我們將看到,如何使用鼠標事件來實現拖放。

拖放算法

基礎的拖放算法如下所示:

  1. 在 ?mousedown? 上 —— 根據需要準備要移動的元素(也許創(chuàng)建一個它的副本,向其中添加一個類或其他任何東西)。
  2. 然后在 ?mousemove? 上,通過更改 ?position:absolute? 情況下的 ?left/top? 來移動它。
  3. 在 ?mouseup? 上 —— 執(zhí)行與完成的拖放相關的所有行為。

這些都是基礎內容。稍后,我們將看到如何實現其他功能,例如當我們將一個東西拖動到一個元素上方時,高亮顯示該元素。

下面是拖放一個球的實現代碼:

ball.onmousedown = function(event) {
  // (1) 準備移動:確保 absolute,并通過設置 z-index 以確保球在頂部
  ball.style.position = 'absolute';
  ball.style.zIndex = 1000;

  // 將其從當前父元素中直接移動到 body 中
  // 以使其定位是相對于 body 的
  document.body.append(ball);

  // 現在球的中心在 (pageX, pageY) 坐標上
  function moveAt(pageX, pageY) {
    ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
    ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
  }

  // 將我們絕對定位的球移到指針下方
  moveAt(event.pageX, event.pageY);

  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }

  // (2) 在 mousemove 事件上移動球
  document.addEventListener('mousemove', onMouseMove);

  // (3) 放下球,并移除不需要的處理程序
  ball.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    ball.onmouseup = null;
  };

};

如果我們運行這段代碼,我們會發(fā)現一些奇怪的事情。在拖放的一開始,球“分叉”了:我們開始拖動它的“克隆”。

嘗試使用鼠標進行拖放,你會看到這種奇怪的行為。

這是因為瀏覽器有自己的對圖片和一些其他元素的拖放處理。它會在我們進行拖放操作時自動運行,并與我們的拖放處理產生了沖突。

禁用它:

ball.ondragstart = function() {
  return false;
};

現在一切都正常了。

另一個重要的方面是 —— 我們在 document 上跟蹤 mousemove,而不是在 ball 上。乍一看,鼠標似乎總是在球的上方,我們可以將 mousemove 放在球上。

但正如我們所記得的那樣,mousemove 會經常被觸發(fā),但不會針對每個像素都如此。因此,在快速移動鼠標后,鼠標指針可能會從球上跳轉至文檔中間的某個位置(甚至跳轉至窗口外)。

因此,我們應該監(jiān)聽 document 以捕獲它。

修正定位

在上述示例中,球在移動時,球的中心始終位于鼠標指針下方:

ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';

不錯,但這存在副作用。要啟動拖放,我們可以在球上的任意位置 mousedown。但是,如果從球的邊緣“抓住”球,那么球會突然“跳轉”以使球的中心位于鼠標指針下方。

如果我們能夠保持元素相對于鼠標指針的初始偏移,那就更好了。

例如,我們按住球的邊緣處開始拖動,那么在拖動時,鼠標指針應該保持在一開始所按住的邊緣位置上。


讓我們更新一下我們的算法:

  1. 當訪問者按下按鈕(mousedown)時 —— 我們可以在變量 shiftX/shiftY 中記住鼠標指針到球左上角的距離。我們應該在拖動時保持這個距離。
  2. 我們可以通過坐標相減來獲取這個偏移:

    // onmousedown
    let shiftX = event.clientX - ball.getBoundingClientRect().left;
    let shiftY = event.clientY - ball.getBoundingClientRect().top;
  3. 然后,在拖動球時,我們將鼠標指針相對于球的這個偏移也考慮在內,像這樣:
  4. // onmousemove
    // 球具有 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);

  // 移動現在位于坐標 (pageX, 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);
  }

  // 在 mousemove 事件上移動球
  document.addEventListener('mousemove', onMouseMove);

  // 放下球,并移除不需要的處理程序
  ball.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    ball.onmouseup = null;
  };

};

ball.ondragstart = function() {
  return false;
};

如果我們按住球的右下角來進行拖動,這種差異會尤其明顯。在前面的示例中,球會在鼠標指針下“跳轉”一下?,F在,更新后的代碼可以讓我們從當前位置流暢地跟隨鼠標。

潛在的放置目標

在前面的示例中,球可以被放置(drop)到“任何地方”。在實際中,我們通常是將一個元素放到另一個元素上。例如,將一個“文件”放置到一個“文件夾”或者其他地方。

抽象地講,我們取一個 “draggable” 的元素,并將其放在 “droppable” 的元素上。

我們需要知道:

  • 在拖放結束時,所拖動的元素要放在哪里 —— 執(zhí)行相應的行為
  • 并且,最好知道我們所拖動到的 “droppable” 的元素的位置,并高亮顯示 “droppable” 的元素。

這個解決方案很有意思,只是有點麻煩,所以我們在這兒對此進行介紹。

第一個想法是什么?可能是將 onmouseover/mouseup 處理程序放在潛在的 “droppable” 的元素中?

但這行不通。

問題在于,當我們拖動時,可拖動元素一直是位于其他元素上的。而鼠標事件只發(fā)生在頂部元素上,而不是發(fā)生在那些下面的元素。

例如,下面有兩個 <div> 元素,紅色的在藍色的上面(完全覆蓋)。這里,在藍色的 <div> 中沒有辦法來捕獲事件,因為紅色的 <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>

與可拖動的元素相同。球始終位于其他元素之上,因此事件會發(fā)生在球上。無論我們在較低的元素上設置什么處理程序,它們都不會起作用。

這就是一開始的那個想法,將處理程序放在潛在的 “droppable” 的元素,在實際操作中不起作用的原因。它們不會運行。

那么,該怎么辦?

有一個叫做 document.elementFromPoint(clientX, clientY) 的方法。它會返回在給定的窗口相對坐標處的嵌套的最深的元素(如果給定的坐標在窗口外,則返回 null)。如果同一坐標上有多個重疊的元素,則返回最上面的元素。

我們可以在我們的任何鼠標事件處理程序中使用它,以檢測鼠標指針下的潛在的 “droppable” 的元素,就像這樣:

// 在一個鼠標事件處理程序中
ball.hidden = true; // (*) 隱藏我們拖動的元素

let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
// elemBelow 是球下方的元素,可能是 droppable 的元素

ball.hidden = false;

請注意:我們需要在調用 (*) 之前隱藏球。否則,我們通常會在這些坐標上有一個球,因為它是在鼠標指針下的最頂部的元素:elemBelow=ball。

我們可以使用該代碼來檢查我們正在“飛過”的元素是什么。并在放置(drop)時,對放置進行處理。

基于 onMouseMove 擴展的代碼,用于查找 “droppable” 的元素:

// 我們當前正在飛過的潛在的 droppable 的元素
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 事件可能會在窗口外被觸發(fā)(當球被拖出屏幕時)
  // 如果 clientX/clientY 在窗口外,那么 elementfromPoint 會返回 null
  if (!elemBelow) return;

  // 潛在的 droppable 的元素被使用 "droppable" 類進行標記(也可以是其他邏輯)
  let droppableBelow = elemBelow.closest('.droppable');

  if (currentDroppable != droppableBelow) {
    // 我們正在飛入或飛出...
    // 注意:它們兩個的值都可能為 null
    //   currentDroppable=null —— 如果我們在此事件之前,鼠標指針不是在一個 droppable 的元素上(例如空白處)
    //   droppableBelow=null —— 如果現在,在當前事件中,我們的鼠標指針不是在一個 droppable 的元素上

    if (currentDroppable) {
      // 處理“飛出” droppable 的元素時的處理邏輯(移除高亮)
      leaveDroppable(currentDroppable);
    }
    currentDroppable = droppableBelow;
    if (currentDroppable) {
      // 處理“飛入” droppable 的元素時的邏輯
      enterDroppable(currentDroppable);
    }
  }
}

在下面這個示例中,當球被拖到球門上時,球門會被高亮顯示。

示例代碼

現在,我們在整個處理過程中,在當前變量 currentDroppable 中都存儲了當前的“放置目標”,可以用它來進行高亮顯示或者其他操作。

總結

我們考慮了一種基礎的拖放算法。

關鍵部分:

  1. 事件流:?ball.mousedown? → ?document.mousemove? → ?ball.mouseup?(不要忘記取消原生 ?ondragstart?)。
  2. 在拖動開始時 —— 記住鼠標指針相對于元素的初始偏移(shift):?shiftX/shiftY?,并在拖動過程中保持它不變。
  3. 使用 ?document.elementFromPoint? 檢測鼠標指針下的 “droppable” 的元素。

我們可以在此基礎上做很多事情。

  • 在 ?mouseup? 上,我們可以智能地完成放置(drop):更改數據,移動元素。
  • 我們可以高亮我們正在“飛過”的元素。
  • 我們可以將拖動限制在特定的區(qū)域或者方向。
  • 我們可以對 ?mousedown/up? 使用事件委托。一個大范圍的用于檢查 ?event.target? 的事件處理程序可以管理數百個元素的拖放。
  • 等。

有一些在此基礎上已經將體系結構構建好的框架:DragZone,DroppableDraggable 及其他 class。它們中的大多數做的都是與上述類似的事情,所以現在你應該很容易理解它們了?;蛘咦约簞邮謱崿F。正如你所看到的,其實挺簡單的,有時候比基于第三方解決方案進行改寫還容易。

任務


滑動條

重要程度: 5

創(chuàng)建一個滑動條(slider):


用鼠標拖動藍色的滑塊(thumb)并移動它。

重要的細節(jié):

  • 當鼠標按鈕被按下時,在滑動過程中,鼠標指針可能會移動到滑塊的上方或下方。此時滑塊仍會繼續(xù)移動(方便用戶)。
  • 如果鼠標非??斓叵蜃筮吇蛘呦蛴疫呉苿?,那么滑塊應該恰好停在邊緣。

打開一個任務沙箱。


解決方案

正如我們從 HTML/CSS 中所看到的,滑動條就是一個帶有彩色背景的 <div>,其中包含一個滑塊 —— 另一個具有 position:relative 的 <div>。

為了對滑塊進行定位,我們使用 position:relative 來提供相對于其父元素的坐標,在這兒它比 position:absolute 更方便。

然后我們通過限制寬度來實現僅水平方向的拖放。

使用沙箱打開解決方案。


將超級英雄放置在足球場周圍

重要程度: 5

這個任務可以幫助你檢查你對拖放和 DOM 的一些方面的理解程度。

使所有元素都具有類 draggable —— 可拖動。就像本章中的球一樣。

要求:

  • 使用事件委托來跟蹤拖動的開始:一個在 ?document? 上的用于 ?mousedown? 的處理程序。
  • 如果元素被拖動到了窗口的頂端/末端 —— 頁面會向上/向下滾動以允許進一步的拖動。
  • 沒有水平滾動(這使本任務更簡單,但添加水平滾動也很簡單)。
  • 即使在快速移動鼠標后,可拖動元素或該元素的部分也絕不應該離開窗口。

這個示例太大了,不適合放在這里,所以在下面給出了示例鏈接。

在新窗口中演示

打開一個任務沙箱。


解決方案

要拖動元素,我們可以使用 position:fixed,它使坐標更易于管理。最后,我們應該將其切換回 position:absolute,以使元素放置到文檔中。

當坐標位于窗口頂端/底端時,我們使用 window.scrollTo 來滾動它。

更多細節(jié)請見代碼注釋。

使用沙箱打開解決方案。


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號