uhyohyo.net

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

十二章第六回 DnD API

次に、前回のFile APIと関連するところもあるDnD APIを紹介します。DnDとは、 ドラッグ & ドロップのことです。

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

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

ある要素をドラッグできることを示すためには、HTML5のdraggable属性を使います。

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

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

実際にやってみたのがサンプル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";
},false);

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

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

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

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

ここまでを反映したサンプル2を見ると、なんとなくドロップに成功しているのが分かるとおもいます。

データを送る

さて、ドロップはできるようになりましたが、ドロップしたからには何か動作を起こしたいものです。そこで、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"とかです。

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

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

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

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

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

},false);

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

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

今回は単純に、データの種類は"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要素はもともとドラッグ&ドロップ可能である」といいました。そこで、画像をドラッグするとその画像を表示するということを考えてみましょう。

ここで、画像はどのように区別するかというと、URLで区別します。つまり、逆にいうとURLさえわかれば同じ画像を表示できるということです。ですから、dataTransferでURLを送りましょう。

document.addEventListener("dragstart",function(ev){
  var target = ev.target;	//ドラッグされた要素
  if(/^img$/i.test(target.tagName)){
    //img要素かどうか判定する
    ev.dataTransfer.setData("text/uri-list",target.src);	//URLを渡す
  }
},false);

受け取る側はこうしましょう。

function drop(ev){
  var data = ev.dataTransfer.getData("text/uri-list");
    //新しいimg要素を作って追加する
    var img=document.createElement("img");
    img.src=data;
    ev.target.appendChild(img);
}

dragstartのほうで、/^img$/i.test(target.tagName)の部分でtargetがimg要素か判定しています。何をしているか分からない人は正規表現を復習しましょう。

今回はtext/plainではなくtext/uri-listなんてものを使ってみました。このように、URLを表すデータを格納する場合はtext/uri-listを使用することができます。なお、名前から分かるように複数のURLを改行で区切って格納することができます。

これを実践したサンプルがサンプル4です。

ちなみに、text/plainやtext/uri-list以外の独自なデータをdataTransferに入れる場合は、同時にtext/plain型のデータも入れておくことが望ましいとされています。これは、例えばテキストボックスにドロップするとその内容を表示するといった、ブラウザがデフォルトで備える動作をサポートするためです。

ファイルのドラッグ&ドロップ

ここからが前回のFile APIとからむ所です。前回、ファイルを読み込むためにinput要素を使用しましたね。実は、ほかにも、ブラウザ内へファイルをドラッグ&ドロップすることでもローカルにあるファイルを読み込むことができるのです。

ファイルがどこからかドラッグされてきた場合、やはりdataTransferに情報が入っています。実は、dataTransferはfilesというプロパティを持っており、これは前回登場したFileListオブジェクトです。これを用いてファイルを取得することができます。

まず、今回はdragstartはもはや要りませんね。なぜなら、ドラッグを開始するのはページ上ではないからです。では、dropイベントのほうを見てみましょう。

function drop(ev){
  //ファイルが複数かもしれないので、forループで調べる
  for(var i=0;i<ev.dataTransfer.files.length;i++){
    var file=ev.dataTransfer.files[i];

    //新しいp要素を作って表示
    var p=document.createElement("p");
    p.textContent= file.name;
    ev.target.appendChild(p);
  }
}

今までの説明を総合するとこんな感じになります。サンプル5で実際に確かめましょう。

ちなみに、紹介しそびれたdataTransferのメソッドで、clearDataというものがあります。これはフォーマット文字列を引数にとって呼び出すことで、そのフォーマットのデータをdataTransferから削除できます。

参考:新しい方法

実は、今まで紹介してきたDnD APIは、実はちょっと古い方法で、最新の仕様では新しい方法が用意されています(ただし、古い方法も使用可能であると定められているのですぐに問題になるわけではありません)。

今回は、(2014年7月現在)まだ新しい仕様を完全にサポートしたブラウザが無いため古い方法を重点的に解説しましたが、新しい方法についても触れておきます。

dropzone

新しい方法においては、ある要素がそこへのドロップを受け入れられることを示す方法が異なります。それは、dropzone属性を使う方法です。dropzone属性がある要素はドロップの受け入れ可能とみなされ、dragenterイベントとdragoverイベントをキャンセルする必要がありません。まだ、dropoverイベントで行なっていた、ドラッグオペレーションを設定する処理も書く必要がありません(dropzone属性に記述しておきます)。

dropzone属性には、2つの情報を記述します。1つはドラッグオペレーション、すなわち、"copy","link","move"のいずれかです。こうすることで、その上にマウスを乗せるとそのドラッグオペレーションが適用されます。もう1つは、どんなフォーマットのデータが受け入れ可能かです。

前述のように、DnD APIで取り扱えるデータは文字列とファイルの2種類です。文字列の受け入れは、string:text/plainのように、string:のあとにフォーマット文字列を記述します。ファイルの受け入れも同様に、file:image/pngのようにfile:のあとにMIMEタイプを記述します。

これらはスペースで区切って複数記述可能ですが、ドラッグオペレーションは1つだけです。すなわち、具体的には以下のようなdropzone属性を書くことになります。

dropzone="copy string:text/uri-list string:text/plain file:text/plain"

この場合は、(dragstartイベントでdataTransferにセットされた)text/plain,text/uri-list型のデータと、(ブラウザ外からドラッグされてきた)テキストファイルを受け入れることを示します。

これを使ってサンプル4を書きなおしたのがサンプル6です。dragenter,dragoverの処理がないぶんすっきりしています。

DataTransferについて

実は、新しい方法ではDataTransferにデータを格納する方法が異なります。

新しい方法ではデータのやりとりは、dataTransferのitemsプロパティに集約されます。これにはDataTransferItemListオブジェクトが入っており、このオブジェクトを通してデータをやりとりします。

データをdataTransferに格納するには、このDataTransfetItemListオブジェクトのaddメソッドを使います。setDataメソッドとは逆で、第一引数にデータ文字列、第二引数にフォーマット文字列となっています。

この新しい方法の特徴は、addメソッドの第一引数に、別の方法で生成したFileオブジェクトを渡すことでdataTransferにファイルを追加することができる点です。古い方法では、ブラウザウィンドウの外からファイルをドラッグしてきた場合しか、DnD APIでファイルを扱う機会はありませんでしたが、新しい方法ではこのようなパターンもあるのです。

そして、入っているデータを得る方法ですが、実はこのDataTransferItemListオブジェクトはlengthプロパティを持ちます。これだけでピンとくるかもしれません。dataTransfer.items[0]のように、添字で入っているデータを取得できます。

そのため、ループして目的のフォーマットのものを探す必要があります。getDataメソッドを使った場合は一発で取得できたのでちょっと面倒ですね。

しかも、dataTranser.items[0]のように取得したデータは生のデータではありません。これはDataTransferItemオブジェクトというもので、このオブジェクトからさらにデータを引き出すのが少し面倒です。

DataTransferItemオブジェクトはkindプロパティとtypeプロパティを持ち、kindプロパティは"string""file"のどちらかです。つまり、このデータがテキストなのかファイルなのかを示しています。typeプロパティはフォーマット文字列です。

そして、文字列の場合はgetAsStringメソッドで、ファイルの場合はgetAsFileで生のデータを取得します。getAsFileの場合は引数なしで、返り値でFileオブジェクトが得られるのですが、getAsStringメソッドの場合はさらに面倒です。このメソッドには引数でコールバック関数を渡す必要があり、その関数にデータが渡されます。

これを踏まえて先ほどのサンプル6を更に書き換えたのがサンプル7です。

なお、利用するフォーマットが"text/uri-list"だったのに"text/x-src"に変わっています。これは、img要素をドラッグする場合は親切にも自動的にそのURLが"text/uri-list"フォーマットでセットされるからです。さらに、addメソッドは、同じフォーマットのデータが既に存在している場合はエラーを発生させます(setDataは上書きします)。それを回避するためにここでは"text/x-src"に変えました。もともと"text/uri-list"型のデータが入っているのでそれをそのまま利用する方法もあります。

ブラウザによってはさらに、画像ファイルのFileオブジェクトも同梱してくれたりするようです。

ちなみに、さきほど何気なく説明しましたが、itemsを使う方法によりファイルも取得できるので、古い方法で使用していたDataTransferのfilesプロパティは使わなくてもよくなりました。

とはいっても、やはりこの新しい方法はブラウザの対応状況がよくありません。先ほどのサンプル7も、FirefoxはそもそもDataTransferItemListやitemsプロパティを実装していないため動きませんし、Google Chromeはdropzone属性がある要素にドロップするときもdragoverイベントのキャンセルが必要なようです(その処理を追加してやれば動きます)。

なお、他にもDataTransfetItemListはremoveメソッド(除去するデータを番号で指定)やclearメソッド(引数なしで全て削除)を持ちます。

その他のメソッドなど

さて、以上の説明で、やりたいことはだいたいできるでしょう。ここでは、補足説明を加えます。以下の説明は古い方法も新しい方法も共通です。

実は、DataTransferには、setDragImageというメソッドがあり、ドラッグ中に表示する画像を設定できます。例えばdragstartでこれを呼び出すと、ドラッグ中のマウス等の画像表示を変更できるでしょう。使い方は、

dataTransfer.setDragImage( element, x,y)

elementというのは、画像として使用する要素です。一般的にはimg要素ですが、他にも任意の要素を指定可能です。x,yは座標で、画像のつかむ場所でしょう。

最後に、DataTransferのeffectAllowedプロパティを紹介します。これは、ドラッグオペレーション(copy・link・move)のうちどれが可能かを示すものです。

ドラッグオペレーション、dragoverイベント時にdropEffectプロパティ指定する(または新しい方法ではdropzone属性でも指定できる)はずですが、ではeffectAllowedとは何なのでしょうか。

まず、セットするタイミングが違います。effectAllowedは普通、dragstartイベント時にセットされます。これにより、今ドラッグされているものに対して許されているオペレーションを指定します。

一方dropEffectというのは、ドロップ先(受け入れ先)が指定するドラッグオペレーションを示します。すると、たとえば今ドラッグしているものには"link"ドラッグオペレーションしか適さないのに、ドロップ先は"copy"を要求しているという事態が起こります。effectAllowedを適切に設定しておくことにより、こういう場合に対応することができます。

具体的には、dragoverイベントがキャンセルされた場合、セットされたdropEffectとeffectAllowedを照らしあわせて最終的なドラッグオペレーションが決定されます。

effectAllowedには以下の値を設定することができます。

none
ドラッグできません。ドラッグオペレーションは必ずnoneになります。
copy
copyのみ許されます。dropEffectにmoveまたはlinkがセットされた場合はnoneになります。
copyLink
copyとlinkが許されます。dropEffectにmoveがセットされた場合はnoneになります。
all
3種類全てが許されます。
link
linkのみ許されます。dropEffectにcopyまたはmoveがセットされた場合はnoneになります。
linkMove
linkとmoveが許されます。dropEffectにcopyがセットされた場合はnoneになります。
move
moveのみ許されます。dropEffectにcopyまたはlinkがセットされた場合はnoneになります。
uninitialized
3種類全てが許されます。

このように、effectAllowedはドラッグオペレーションを制限する意味を持ちます。dragstartでセットされることにより、ドロップ先に関係なく実際にドラッグされているものに応じた処理をさせることができます。

また、effectAllowedにはもう1つ意味があります。上のリストをよく見てみると、allとuninitializedが同じに見えます。実は、dragenterイベント及びdragoverイベントにおいては、あらかじめdropEffectプロパティが自動的にセットされています。これは、effectAllowedに基づいて決められます。

例えば、effectAllowedが"copy"の場合はdropEffectは自動的に"copy"にセットされています。そのため、effectAllowedを適切に設定してやればdragoverでわざわざdropEffectを手動で設定する必要がありません。ただし、dragoverをキャンセルするのは行う必要があります。

また、複数のところから異なるeffectAllowedを持つものがドラッグされてくるがこちら側では1種類または2種類のドラッグオペレーションしか受け付けないという場合には、手動で設定したりdropEffectの値を見たりしてやる必要があるでしょう。

ちなみに、effectAllowedが"copyMove"のように複数の値が許されている場合は、ブラウザがいい感じに決めてくれます。例えばWindowsでは、普通にドラッグすると移動(move)だけどCtrlキーを押しながら移動するとコピー(copy)になります。気が利くブラウザならばそういった情報を与えてくれるでしょう。しかし、あまり期待するべきではありません。

結局uninitializedとallは何が違うかというと、微妙な違いではありますが、uninitializedの場合は何をドラッグしているかによってさらに気を利かせてくれます。