2022年7月27日

マウスイベントを使ったドラッグ&ドロップ

ドラッグ&ドロップは優れたインターフェースソリューションです。何かを掴んでドラッグ&ドロップすることは、ドキュメントのコピーや移動(ファイルマネージャーのように)から、注文(カートへのアイテムのドロップ)まで、さまざまなことを行うための明確でシンプルな方法です。

現代の HTML 標準では、dragstart, dragend などの特別なイベントを含む ドラッグアンドドロップに関するセクションがあります。

これらのイベントにより、OS のファイルマネージャーからファイルをドラッグしてブラウザウィンドウにドロップするなどの、特別な種類のドラッグ&ドロップをサポートできます。その後、JavaScript はそのようなファイルの内容にアクセスできます。

ただし、ネイティブのドラッグイベントには制限もあります。例えば、特定の領域からのドラッグを防止することはできません。また、ドラッグを「水平」または「垂直」のみにすることもできません。また、それらを使用して実行できないドラッグ&ドロップタスクも多くあります。また、モバイルデバイスでのこのようなイベントのサポートは非常に弱いです。

したがって、ここではマウスイベントを使用してドラッグ&ドロップを実装する方法を見ていきます。

ドラッグ&ドロップアルゴリズム

基本的なドラッグ&ドロップアルゴリズムは次のようになります

  1. mousedown 時 – 必要に応じて移動のための要素を準備します(クローンを作成したり、クラスを追加したりなど)。
  2. 次に mousemove 時、position:absoluteleft/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;
};

これで全て問題ありません。

実際に動作する様子

もう1つの重要な側面は、ball ではなく、documentmousemove を追跡することです。一見すると、マウスは常にボールの上にあるように見え、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 ハンドラーを設定することでしょうか?

しかし、それはうまくいきません。

問題は、ドラッグしている間、ドラッグ可能な要素が常に他の要素の上にあることです。そして、マウスイベントは、下にある要素ではなく、一番上の要素でのみ発生します。

たとえば、以下は2つの <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> であり、ランナー(別の <div>position:relative を持つ)が含まれています。

ランナーを配置するには、position:relative を使用して、親に対する相対的な座標を提供します。ここでは、position:absolute よりも便利です。

次に、幅で制限された水平のみのドラッグ&ドロップを実装します。

サンドボックスでソリューションを開きます。

重要度: 5

このタスクは、ドラッグ&ドロップと DOM のいくつかの側面に関する理解度をチェックするのに役立ちます。

クラス draggable を持つすべての要素を、チャプターのボールのようにドラッグ可能にします。

要件

  • イベント委任を使用してドラッグ開始を追跡します: mousedowndocument 上の単一のイベントハンドラー。
  • 要素が上/下のウィンドウの端にドラッグされた場合、ページが上下にスクロールし、さらにドラッグできるようになります。
  • 水平スクロールはありません(これにより、タスクが少し簡単になります。追加するのは簡単です)。
  • ドラッグ可能な要素またはその一部が、マウスをすばやく移動した後でも、ウィンドウから出ることはありません。

デモが大きすぎてここに収まらないため、リンクはこちらです。

新しいウィンドウでデモを開きます

タスクのサンドボックスを開きます。

要素をドラッグするには、position:fixed を使用できます。これにより、座標の管理が容易になります。最後に、要素をドキュメントに配置するために、position:absolute に戻す必要があります。

座標がウィンドウの上端/下端にある場合、window.scrollTo を使用してスクロールします。

詳細はコード内のコメントにあります。

サンドボックスでソリューションを開きます。

チュートリアルマップ

コメント

コメントする前にこちらをお読みください…
  • 改善点についてご提案がある場合は、コメントする代わりにGitHub issue を送信するか、プルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • コードを数語挿入するには、<code> タグを使用し、複数行の場合は <pre> タグで囲み、10 行を超える場合はサンドボックス (plnkrjsbincodepen…) を使用してください。