uhyohyo.net

JavaScript初級者から中級者になろう

十二章第六回 Drag and Drop API

このページの最終更新日:

次に、前回のFile APIと関連するところもあるDrag and Drop API(略してDnD API)を紹介します。名前から分かるように、これはドラッグ & ドロップに関するAPIです。

しかし、ドラッグ&ドロップといっても、「ページ内の要素をつかんで自由に移動させられる」とかそういうのとは少し違います。今回は、ドラッグ(つかんで移動)してドロップ(離す)するということですから、途中の過程ではなく「どこからどこまで何を移動したか」ということを大事にします。

ドラッグできる要素を作る

とはいえ、今回つかむことができるのはやはり要素です。ある要素をドラッグできることを示すためには、HTML5のdraggable属性を使います。

ドラッグできるようにしたい要素には、draggable="true"という属性をつけます。単純に、trueならドラッグできて、falseならドラッグできないということです。

しかし、考えてみるとcheckedみたいに論理属性にして、draggable属性があればドラッグできる、ないならドラッグできないというようにすればいいと思うかもしれませんが、そうもいきません。というのも、a要素やimg要素はもともとドラッグできるからです。そういう要素に対してドラッグできないということを明示するためにはdraggable="false"とします。

実際にやってみたのがサンプル1です。ドラッグできる要素とできない要素の違いを確かめてください。

ドラッグ先を作る

ところで、このままだと要素をつかむことができても、それをドロップする場所がありません。ということで、ドロップする場所を作りましょう。

それにかかわるのがdragenterイベントです。これは、ドラッグしながら他の要素の上にさしかかったときに発生するイベントです。普通、ある要素の上にさしかかったら、その要素がドロップ先として認識されます。ところが、このdragenterイベントは面白いデフォルトアクションを持っています。デフォルトアクションというのは以前解説したように、「そのイベントが発生した場合にデフォルトで起こる動作」です。例えばa要素をクリックしたならばリンク先に飛ぶという動作が発生しますね。

それで、dragenterイベントのデフォルトアクションは「ドロップ先をbody要素に変更する」ということです。すなわち、dragenterイベントが発生すると、どこにマウスを持って行っても全部body要素にドロップした扱いになってしまいます。

DnD APIを使ってやりたいことは、やっぱり「これをここにドロップしたらこの処理をする」というようなことでしょう。ですから、どこに置いてもbody要素にドロップした扱いになるのはちょっと困ります。そこで、dragenterイベントのデフォルトアクションを無効にする必要があります(これを、イベントをキャンセルするといいます)。イベントをキャンセルするにはpreventDefaultを使うのでしたね。

さらに面倒なことに、実はもうひとつキャンセルすべきイベントがあります。それはdragoverイベントです。 これについては、ドラッグオペレーションというものが関係しています。ドラッグオペレーションとは、そのドラッグ&ドロップにどのような効果があるのかを示す値であり、以下の4種類があります。

copy
データがコピーされます。
link
データのリンクが張られます。
move
データが移動します。
none
何も起きません。

このドラッグオペレーションがどうなっているかによって、ドラッグ時の表示も変わってきます(親切なブラウザならば)。

ドラッグし始めた段階では、最初は"none"にセットされています。実はこのままだと、ドロップしても何も起こりません。

dragoverイベントは、dragenter同様にマウスが上に差し掛かったときに発生します。そのため、このタイミングでドラッグオペレーションを設定してやることで、その要素にドロップしたときの動作を示すことができます。ただし、後述しますが、実際にドロップしたときの動作はやはりJavaScriptで記述するので、ドラッグオペレーションには表示以上の意味はそんなにありません。しかし、値が"none"になっている場合はドラッグ&ドロップ自体が無効になってしまうので、何か他の値にセットする必要があります。

その方法ですが、dragenter,dragoverなどのDnD APIに関係するイベントでは、そのイベントオブジェクトはDragEventといい、dataTransferプロパティを持ちます。これをDataTransferオブジェクトといい、ドラッグ&ドロップに関するさまざまな情報を管理します。

さて、DataTransferオブジェクトはdropEffectプロパティを持ち、これに代入することでドラッグオペレーションを設定できます。つまり、こういうことですね。


//elmは適当なHTMLElementとする
elm.addEventListener("dragover",function(e){
  e.dataTransfer.dropEffect = "copy";
});

ところで、さっきdragoverはキャンセルする必要があると述べました。それは、当然ながらdragoverのデフォルトアクションに理由があります。

dragoverのデフォルトアクションは「ドラッグオペレーションを"none"にする」ということなのです。

こんなデフォルトアクションがあっては、いくらこちらで書き換えてやっても意味がありません。そこで、dragoverをキャンセルしてやります。つまりこうです。

//elmは適当なHTMLElementとする
elm.addEventListener("dragover",function(e){
  e.dataTransfer.dropEffect = "copy";
  e.preventDefault();
});

ここまでを反映したサンプル2を見ると、なんとなくドロップに成功しそうな感じになっているのが分かるとおもいます。いい感じのブラウザ(Chromeとか)だと、ドロップ可能なdiv要素の上にさしかかったときのみマウスカーソルが変わるはずです。ドロップ可能なdiv要素を出るとカーソルが戻る理由は、他の要素(多分body要素)のdragoverイベントが発生してドラッグオペレーションが"none"に戻るからです。

データを送る

さて、ドロップはできるようになりましたが、ドロップしたからには何か動作を起こしたいものです。そこで、dropイベントを使います。これは、要素に対して何かがドロップされたときにそこで発生するイベントです。

当然ながら、イベントオブジェクトのtargetプロパティはドラッグ先の要素です。そうなると、そこでさらに問題が発生します。それは、ドラッグされたほうの要素はどうやって調べるのかです。

実は、要素自体は調べません。ここで登場するのが、先ほどのdataTransferです。このdataTransferはdropEffectを設定できるだけではなく、データを格納しておくことができるのです。

ドラッグ&ドロップにおけるデータの流れは、「ドラッグされる側がdataTransferにデータを格納する」→「ドロップされたとき(dropイベント)にdataTransferからデータを取り出す」ということになります。

では、「ドラッグされる側がdataTransferにデータを格納する」はどのようにすればいいかというと、ここで新しいイベントdragstartが登場します。これは当然ながら、ドラッグされる側の要素(draggable="true"の要素)で発生するイベントです。dragstartからも当然dataTransferが利用でき、dataTransferに書き込むことができます。

dataTransferはsetDataというメソッドを持っています。第一引数がフォーマットの文字列、第二引数がデータの文字列です。

フォーマットというのは、格納されるデータがどんなデータが判別するための文字列なので、わかればいいということで自由に指定していいです。ただし、MIMEタイプの利用が推奨されます。

例えば、今回データは文字列なので"text/"から始まるとして、普通の文字列は"text/plain"というタイプになります。また、この部分には"x-"から始まる自由な文字列を設定できるので、例えば"text/x-mydata"とかです。

dataTransferには複数のデータを格納することができますが、同じフォーマットのものは複数格納できません。

さて、具体的にやってみましょう。

例えばさっきのサンプル2をもとにして、div要素のテキストをドラッグ先に表示してみるというサンプルを作ってみます。

まず今回draggableなdiv要素は3つあるので、ひとつひとつでdragstartイベントを監視するのではなく、documentあたりでまとめて監視してみましょう。


document.addEventListener("dragstart",function(ev){

});

そして、今回はdiv要素のテキスト、すなわちtextContentをdataTransferに入れてあげましょう。


document.addEventListener("dragstart",function(ev){
  ev.dataTransfer.setData("text/plain",ev.target.textContent);
});

今回は単純に、データの種類は"text/plain"としました。ただのテキストデータなら"text/plain"としておいて損はないでしょう。

これでdragstart側の処理は終了です。次に、ドロップされた側(dropイベント)の処理です。

こっちも同じようにaddEventListenerでイベントを付加してもいいのですが、面倒なのでondrop="drop(event)"のようにしてdropという関数を作ってそれに渡すことにしましょう。突如でてきたeventという変数は、三章第五回で解説したようにイベント属性を使った場合のイベントオブジェクトです。

そして肝心のdrop関数においては、dataTransferからデータを取得します。これは、getDataメソッドを使います。引数にフォーマット文字列を渡すと対応するデータが帰ってきます


function drop(ev){
  var data = ev.dataTransfer.getData("text/plain");
}

これを実際にやってみたのがサンプル3です。「div要素1」や「div要素2」などを一番下のdiv要素にドラッグ&ドロップすると、そのとおりに文字が変化することが確認できるでしょう。ドラッグ&ドロップの結果が目に見えて現れるようになったので面白いですね。

なお、dropイベントでも最後にpreventDefaultしているのが分かると思います。これは、(2014年7月現在)Firefoxがドロップ後にページ遷移してしまうので、それを防ぐ目的があります。

さて、今回はドラッグ&ドロップによって受け渡したデータはtextContentでしたが、同様にしてさまざまなデータを渡すことができるでしょう。応用して、機会があったら試してみましょう。

画像のドラッグ&ドロップ

上で、「img要素はもともとドラッグ&ドロップ可能である」といいました。実は、img要素がドラッグされる場合自動的にdataTransferに画像のURL(具体的にはsrc属性の内容)が格納されます。その際のフォーマットは"text/uri-list"です。ただし、複数のimg要素が同時にドラッグされている場合もあります。そのようなときは、全てのURLが改行で区切られた文字列が入っています。

ということで、このことを用いて画像がドロップされてきたらそれを表示してみるというサンプルを作ってみました。サンプル4です。

このサンプル中ではsplitメソッドやforEachメソッドを使っています。忘れた方はぜひ戻って復習しましょう。

ちなみに、もうひとつもともとドラッグ&ドロップ可能な要素としてa要素がありました。実はa要素の場合も、そのリンク先のURL(href属性)が"text/uri-list"で入っています。

テキストコントロールへのドロップ

デフォルトでドラッグ可能なのがimg要素とa要素でした。実は、デフォルトでドロップ可能な要素も存在します。それはtextarea要素およびinput要素(type="text"の場合)です。

実は、"text/plain"のデータがdataTransferに入った状態でこれらの要素にドロップされると、その内容が入力されます。これもサンプル5を用意しましたので、試してみてください。

この特徴のために、何か変なデータをdataTransferに入れる場合でそれが適切なテキスト表現を持つならば、"text/plain"のデータも一緒に入れてあげるのが親切です。

ドラッグ&ドロップに関わるイベントのまとめ

ここまでの話で、ドラッグ&ドロップの制御は主にイベントを通して行われることが分かったと思います。ドラッグ&ドロップ時に発生するイベントは、実は今まで紹介したほかにもいくらかあります。ここでは一連の流れの中で発生するイベントをまとめて解説します。

まず、ドラッグ可能な要素をマウス等でつかんでドラッグ開始した時点で、その要素でdragstartイベントが発生します。これは先に紹介した通りですね。dragstartをキャンセルした(preventDefaultでデフォルトアプションを無効化)した場合はその要素はドラッグできません。

ドラッグ中、ドラッグ先(マウス等の真下にある要素)が変わるたびに、ドラッグ先の要素でdragenterイベントが発生します。このイベントで処理すべきことは先に解説した通りで、ドロップを受け入れるにはdragenterイベントをキャンセルする必要があります。

また、実はdragenterと対になるイベントとしてdragleaveイベントが存在します。これは、ドラッグ先が移り変わった場合に移動前の要素で発生するイベントです。dragenterとdragleaveの関係はmouseoverとmouseoutの関係に近く、基本的に2つのイベントは同時に発生します。実は、dragleaveイベントのイベントオブジェクトのrelatedTargetプロパティは移動先の要素を示しています。

実は、ドラッグを続けている間、ドラッグされている要素ではdragイベントが発生し、ドラッグ先の要素ではdragoverイベントが発生します。これらのイベントは、ドラッグされている最中、一定時間ごとに何度も発生するイベントです。ブラウザにもよりますが、1秒に数回程度のペースで発生します。先に述べたようにdragoverはキャンセルしなければドロップを受け入れられません。一方、dragイベントはキャンセルする必要はありません。逆に、dragイベントをキャンセルした場合はドラッグが中断されます。ドラッグを強制的に止めたい場合はdragイベントをキャンセルしましょう。

そして、ドラッグが終了したときにドロップ先の要素でdropイベントが発生します。ただし、前述のdropEffectが"none"の場合など、ドロップが受け入れられない状況でドラッグが終了した場合はドロップ扱いにならず、dropイベントは発生しません。

また、ドラッグが終了した場合は(ドロップに成功したかどうかに関わらず)dragendイベントがドラッグされている要素で発生します。

まとめると、掴まれてドラッグされる要素ではdragstartdropdrop→……→dropdragendというようにイベントが発生します。一方、ドロップ先の要素は移り変わりますが、各要素はdragenterdragoverdragover→……→dragoverdragleaveの流れとなります。

他にdragexitというイベントもありますが、これはdragleaveと似ているものの存在理由がよく分からず、使われる機会はないのではないかと思います。

イベントに関してもうひとつ注意すべき点は、セキュリティ上の理由から、dataTransfer内のデータにアクセスできるのはdragstartイベントとdropイベント(すなわち、ドラッグの開始時と終了時)に限られるという点です。ドラッグの途中(例えばdragoverイベント)でデータを読めてしまうと、関係ないドラッグ物がたまたまブラウザの上を通った場合に読まれてしまうというようなセキュリティ上のリスクがあります。

やや長くなりましたが、今回はページ内のドラッグ&ドロップを扱う方法を説明しました。実は次回も引き続きドラッグ&ドロップに関わる話です。