ドラッグ&ドロップは素晴らしいインタフェースソリューションです。何かを掴み、ドラッグとドロップをすることは、コピーや移動(ファイルマネージャを参照)から注文(カートにドロップする)まで、多くのことをするための明白かつ簡単な方法です。
現在の HTML 標準では ドラッグイベントに関するセクション があります。
それらはシンプルなタスクを簡単に解決したり、“外部” ファイルのドラッグ&ドロップをブラウザで扱うことができたりと、興味深いです。したがって、OSのファイルマネージャでファイルを取り、ブラウザウィンドウへドロップすることができます。その後、JavaScript はそのコンテンツへアクセスできます。
しかし、ネイティブのドラッグイベントにも制限があります。例えば、特定の領域でドラッグを制限することができます。また、ドラッグを “水平” または “垂直” のみにすることはできません。そのAPIでは実装できない他のドラッグ&ドロップのタスクがあります。
そのため、ここではマウスイベントを使用したドラッグ&ドロップの実装方法を見ていきます。それほど難しくはありません。
ドラッグ&ドロップ アルゴリズム
基本のドラッグ&ドロップのアルゴリズムはこのようになります:
- ドラッグ可能な要素で
mousedown
をキャッチします。 - 移動する要素を準備します (そのコピーを作成したりなど)
- その後、
mousemove
でleft/top
とposition:absolute
を変更することで、それを移動させます。 mouseup
(ボタンを離す) で、 – 終了したドラッグ&ドロップに関連するすべてのアクションを実行します。
これらは基本です。私たちはそれを拡張することができます。例えば、ドロップ可能な要素上にマウスを持ってきたときに、それを強調表示するなどです。
ここでは、ボールのドラッグ&ドロップの場合のアルゴリズムです:
ball.onmousedown = function(event) { // (1) 処理を開始
// (2) 移動のための準備: absolute にし、z-index でトップにする
ball.style.position = 'absolute';
ball.style.zIndex = 1000;
// 現在の親から body へ直接移動させ、body に対して相対配置をする
document.body.append(ball);
// ...そしてその絶対配置されたボールをカーソルの下に置く
moveAt(event.pageX, event.pageY);
// ボールを(pageX、pageY)座標の中心に置く
function moveAt(pageX, pageY) {
ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
}
function onMouseMove(event) {
moveAt(event.pageX, event.pageY);
}
// (3) mousemove でボールを移動する
document.addEventListener('mousemove', onMouseMove);
// (4) ボールをドロップする。不要なハンドラを削除する
ball.onmouseup = function() {
document.removeEventListener('mousemove', onMouseMove);
ball.onmouseup = null;
};
};
コードを実行すると、何かおかしいことに気づきます。ドラッグ&ドロップの開始時に、ボールは “分岐” します: 我々はその “クローン” をドラッグし始めます。
これは、アクションの例です:
マウスをドラッグ&ドロップし、奇妙な振る舞いをみてみてください。
ブラウザは、画像や自動的に実行する他の要素のための独自のドラッグ&ドロップを持っており、それが私たちのコードと競合するためです。
それを無効にするためには:
ball.ondragstart = function() {
return false;
};
これで、すべて大丈夫です。
アクション:
もう1つの重要な側面 – 私たちは ball
ではなく、document
の mousemove
を追跡しています。一見すると、マウスは常にボールの上にあり、その上に 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;
JavaScript では、document に相対的な座標を取得するメソッドがないことに注意してください。そのため、ここではウィンドウに相対的な座標を使っています。
-
次に、ドラッグの間はこのようにして、ポインタに相対的な同じシフトにボールを置きます。:
// onmousemove // ball has position:absoute 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);
}
// (3) mousemove でボールを移動する
document.addEventListener('mousemove', onMouseMove);
// (4) ボールをドロップする。不要なハンドラを削除する
ball.onmouseup = function() {
document.removeEventListener('mousemove', onMouseMove);
ball.onmouseup = null;
};
};
ball.ondragstart = function() {
return false;
};
アクション (<iframe>
の中):
ボールの右下端でドラッグをする場合に、違いは特に顕著になります。以前の例ではボールはポイントの下に “ジャンプ” します。今は現在の位置からのなめらかにカーソルを追うことができます。
ドロップ可能を検出する
前の例では、ボールは “どこにでも” ドロップすることができました。実際には、通常1つの要素を取り、別の要素へそれをドロップします。例えば、ファイルをフォルダに、またはユーザをゴミ箱に、などです。
抽象的には、私たちは “ドラッグ可能な” 要素を取り、“ドロップ可能な” 要素上にドロップします。
ドラッグ&ドロップの最後には、ドロップ可能なターゲットを知る必要があります – 対応するアクションを行ったり、できれば、ドラッグ処理中にそれを強調表示したりします。
そのソリューションは興味深くもあり難解でもあるため、ここで説明しましょう。
最初のアイデアは何でしょう?恐らく潜在的なドロップ可能要素に onmouseover/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
です)。
なので、任意のマウスイベントハンドラで、ポインタの下のドロップ可能要素を検出することができます。次のようになります:
// マウスイベントハンドラの中
ball.hidden = true; // (*)
let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
ball.hidden = false;
// elemBelow はボールの下の要素です. もしそれがドロップ可能であれば処理します
注意:(*)
呼び出しの前に、ボールを隠す必要があります。そうしなければ、ポインタ下の最上位の要素として、通常その座標にはボールがあるためです: elemBelow=ball
私たちはこのコードを使って、いつでも “飛んでいる場所” を確認することができます。そして、それが起きるとドロップを処理します。
“ドロップ可能な” 要素を探すよう拡張された onMouseMove
のコードです:
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 イベントはウィンドウ外をトリガする可能性があります(ボールが画面外にドラッグされたとき)
// clientX/clientY がウィンドウ外の場合、elementfromPoint は null を返します
if (!elemBelow) return;
// 潜在的なドロップ可能領域は "droppable" クラスでラベル付されています (他のロジックの場合もあります)
let droppableBelow = elemBelow.closest('.droppable');
if (currentDroppable != droppableBelow) { // 変更がある場合
// 私たちは飛んでいます(入ったか出たか)...
// 注意: 両方の値は null になりえます。
// currentDroppable=null ドロップ可能領域にいなかった場合 (e.g 空白スペース)
// droppableBelow=null このイベント中、今ドロップ可能領域にいない場合
if (currentDroppable) {
// ドロップ可能領域を "飛び出る" 処理のためのロジック (強調表示の除去)
leaveDroppable(currentDroppable);
}
currentDroppable = droppableBelow;
if (currentDroppable) {
// ドロップ可能領域に "入る" 処理のためのロジック
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
(ネイティブのonstart
はキャンセル) - ドラッグの開始時 – 要素に対するポインタの相対的なずれを記憶すること:
shiftX/shiftY
そしてドラッグ中保持しておくこと - ポインタの下にあるドロップ可能な要素を
document.elementFromPoint
を用いて検出すること
私達は、この機能の上に、様々な物を置くことができます。
mouseup
の段階で、ドロップを終了させることができます: データの変更、要素の移動- 今動かしている要素をハイライト表示できます。
- ドラッグを特定の場所や方向に制限することができます
mousedown/up
において、イベントの委譲を行うことができます。event.target
を確認する広範囲のイベントハンドラが、何百もの要素のドラッグアンドドロップを管理することができます。- などなど。
これらを基にアーキテクチャを構築するフレームワークが存在します: DragZone
、 Droppable
、 Draggable
や他のクラスなど。これらのほとんどは上記で説明したものと類似した事を行うため、今となっては簡単に理解できることでしょう。あるいは自作します。なぜなら既に、どのように処理を行えばよいかを知っているし、こちらの方が、何か他のものを採用するよりも柔軟性が高いでしょうから。