2022 年 7 月 27 日

使用滑鼠事件進行拖放

拖放是一種很棒的介面解決方案。拿起某個東西並將其拖放是執行許多事情的清晰且簡單的方法,從複製和移動文件(如檔案管理員)到排序(將項目放入購物車)。

在現代 HTML 標準中,有一個關於拖放的章節,其中包含特殊事件,例如 dragstartdragend 等。

這些事件允許我們支援特殊類型的拖放,例如處理從作業系統檔案管理員拖放檔案並將其放到瀏覽器視窗中。然後 JavaScript 可以存取這些檔案的內容。

但原生拖曳事件也有其限制。例如,我們無法防止從特定區域拖曳。我們也無法只讓拖曳「水平」或「垂直」。還有許多其他無法使用原生拖曳事件完成的拖放任務。此外,行動裝置對此類事件的支援非常薄弱。

因此,我們將在此瞭解如何使用滑鼠事件實作拖放。

拖放演算法

基本的拖放演算法如下

  1. mousedown 上 – 準備元素移動,如果需要的話(也許建立它的複製,加入一個類別或其他)。
  2. 然後在 mousemove 上使用 position:absolute 改變 left/top 來移動它。
  3. 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。但如果從它的邊緣「取用」它,那麼球會突然「跳」到滑鼠指標下方,使其居中。

如果我們保持元素相對於指標的初始偏移,那會更好。

例如,如果我們從球的邊緣開始拖曳,那麼指標在拖曳時應該保持在邊緣上方。

讓我們更新我們的演算法

  1. 當訪客按下按鈕(mousedown)時 – 記住指標到球的左上角的距離,在變數 shiftX/shiftY 中。我們在拖曳時會保持那個距離。

    要取得這些偏移,我們可以減掉座標

    // onmousedown
    let shiftX = event.clientX - ball.getBoundingClientRect().left;
    let shiftY = event.clientY - ball.getBoundingClientRect().top;
  2. 然後在拖曳時,我們將球定位在相對於指標的相同偏移量,如下所示

    // 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);
    }
  }
}

在以下範例中,當球拖曳到足球球門時,球門會被反白。

結果
style.css
index.html
#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 中當前「拖放目標」,我們正在飛越它,可以使用它來反白或任何其他東西。

摘要

我們考慮了一個基本的拖放演算法。

關鍵組成部分

  1. 事件流程:ball.mousedowndocument.mousemoveball.mouseup(別忘了取消本機 ondragstart)。
  2. 在拖曳開始時 - 記住指標相對於元素的初始偏移:shiftX/shiftY,並在拖曳期間保持它。
  3. 使用 document.elementFromPoint 偵測指標下的可拖放元素。

我們可以在這個基礎上做很多事。

  • mouseup 上,我們可以在概念上完成拖放:變更資料、移動元素。
  • 我們可以反白我們飛越的元素。
  • 我們可以限制拖曳在特定區域或方向。
  • 我們可以使用事件委派進行 mousedown/up。檢查 event.target 的大區域事件處理常式可以管理數百個元素的拖放。
  • 等等。

有些架構會在其上建立架構:DragZoneDroppableDraggable 和其他類別。它們大多數都執行與上述類似的操作,所以現在應該很容易理解它們。或者自行開發,因為你可以看到這很容易做到,有時比調整第三方解決方案更容易。

任務

重要性:5

建立一個滑塊

用滑鼠拖曳藍色拇指並移動它。

重要細節

  • 當按下滑鼠按鈕時,在拖曳期間,滑鼠可能會在滑塊上方或下方。滑塊仍然會運作(對使用者來說很方便)。
  • 如果滑鼠非常快速地向左或向右移動,拇指應該會準確地停在邊緣。

開啟一個沙盒進行任務。

從 HTML/CSS 中可以看到,滑塊是一個具有彩色背景的 <div>,其中包含一個滑塊 - 另一個具有 position:relative<div>

為了定位滑塊,我們使用 position:relative,以提供相對於其父項的座標,這裡比 position:absolute 更方便。

接著我們實作僅限水平方向的拖放,並限制寬度。

在沙盒中開啟範例。

重要性:5

這個任務有助於你檢查對拖放和 DOM 的幾個面向的理解。

讓所有具有 draggable 類別的元素都可以拖放。就像章節中的球一樣。

需求

  • 使用事件委派來追蹤拖放開始:在 document 上針對 mousedown 的單一事件處理常式。
  • 如果元素被拖放到視窗頂部/底部邊緣,頁面會向上/向下捲動以允許進一步拖放。
  • 沒有水平捲動(這讓任務變得簡單一些,加入它很容易)。
  • 可拖放元素或其部分永遠不應離開視窗,即使在快速移動滑鼠之後也不行。

示範太大,無法放在這裡,所以這裡提供連結。

在新視窗中示範

開啟一個沙盒進行任務。

為了拖放元素,我們可以使用 position:fixed,它讓座標更容易管理。最後我們應該將它切換回 position:absolute 以將元素放置到文件中。

當座標位於視窗頂部/底部時,我們使用 window.scrollTo 來捲動它。

在程式碼中,以註解提供更多詳細資訊。

在沙盒中開啟範例。

教學課程地圖

註解

在註解之前閱讀這段文字…
  • 如果你有建議要如何改進,請 提交 GitHub 議題 或發起拉取請求,而不是留言。
  • 如果你無法理解文章中的某些內容,請詳細說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,若要插入多行,請將它們包在 <pre> 標籤中,若要插入 10 行以上,請使用沙盒(plnkrjsbincodepen…)