Feb 02, 2007

Split Browser開発のよもやま話(9):ポップアップボタン上へのドラッグ&ドロップの検知

Split Browserの作り込みの話のおまけ。このエントリにはドラッグ&ドロップの実装に関する話が含まれているかもしれません。

まず基本の話として、Firefoxで(というかXULで)ドラッグ&ドロップを実装するには、ondraggesutre, ondragover, ondragenter, ondragexit, ondragdropの5つのイベントハンドラと、XPCOMの機能を使う必要がある。このあたりの話はMDCのXULチュートリアルには無いんだけど、古いXULチュートリアルには載ってるので、熟読しとくことをお勧めしたい。

XUL要素をドラッグしようとすると、draggesutreというイベントが発行される。いわゆるAjaxとかだと、ボタンを押下→マウスが動いた、という操作をそれぞれ別のイベントで拾わないといけなかったり、クリック時にマウスがブレただけでドラッグ開始と判断してしまわないように閾値を設定したり、と色々めんどくさい配慮がいるんだけど、XULではdraggestureイベントいっこ拾うだけで済むので話が早い。

ドラッグ中にボタンを放した時、つまりドロップの操作が行われた時には、dragdropというイベントが発生する。これは他のアプリケーションからのドラッグ&ドロップでも発生するので、アプリケーションの垣根を越えてのデータのやりとりもできる。やろうと思えば多分バイナリデータも渡せるんじゃないかな……やったことは無いけど。

あとの3つのイベントはおまけのようなもので、ドラッグ中にポインタが載った要素に対して、今ドラッグ中のデータをドロップできるかどうか(その要素がそのデータのドロップを受け入れられるかどうか)を示す、とかそういった用途で使うことが多い。

データの受け渡しにはXPCOMの機能を使う。詳細は旧チュートリアルの当該項目で解説されてる……ンだけど、ぶっちゃけこんなの真面目に使ったらあかん(ぉぃ)。これをラッピングして簡単に使えるようにしてくれる物として、nsDragAndDropという標準ライブラリがあって、これはFirefoxでも利用できる(っていうかFirefox内部で使われまくり)ので是非活用しましょう。旧チュートリアルのnsDragAndDropの使い方の解説利用例は要チェックですよ。

……というのがドラッグ&ドロップの実装の基本。ここから先は、その応用。

一つ前のエントリで書いたとおり、Split Browserではmousemoveイベントをハンドリングして、ポインタがブラウズ領域の端に来た時にその位置にボタンを表示するようになっている。このボタンをドラッグ&ドロップの際にも表示しておけば、リンクやブックマーク、あるいはローカルのファイルをFirefoxで開く時なんかにも、いちいち「上下左右いずれかに分割→そのブラウズ領域のロケーションバーでURIを入力」なんてめんどくさいことをしなくても、ドラッグしてボタン上でドロップすれば新しい分割ブラウザでそのリンクなりブックマークなりファイルなりを表示する、という操作ができるようになる。

ということで早速やってはみたものの、最初は上手くいかなかった。

まず、mousemoveイベントをトリガーにしていると、ドラッグ&ドロップ中のマウスの移動には反応してくれない。何かをドラッグしている最中というのは、Firefoxではいろんな処理に影響が出る。

  • ドラッグ中にはmousemove, mouseover, mouseoutイベントは発生せず、代わりにdragover, dragenter, dragexitイベントが発生する。
  • タイマー(setTimeout()setInterval())はドラッグ中は停止する。ドラッグ中にセットしたタイマーは、ドラッグ操作が終わった直後から動き始める。

そんなワケなので、改めてdragoverイベントをトリガーにしてボタンをポップアップ表示するようにした。具体的には、以下のようなコードをsubbrowser要素のバインディング定義に加えた。


<field name="contentAreaEdgeDNDObserver"><![CDATA[
({
  mOwner : this,

  onDragOver: function (aEvent, aFlavour, aDragSession)
  {
    if (aFlavour.contentType  == 'text/x-moz-url' ||
      aFlavour.contentType == 'text/unicode' ||
      aFlavour.contentType == 'application/x-moz-file') {
      if (this.mOwner.toggleToolbar) {
        this.mOwner.toggleToolbar(true, true);
        this.mOwner.toggleToolbar(false, true, 0);
      }
      this.mOwner.handleMouseOverEvent(aEvent);
    }
  },

  getSupportedFlavours: function ()
  {
    var flavourSet = new FlavourSet();
    flavourSet.appendFlavour('text/x-moz-url');
    flavourSet.appendFlavour('text/unicode');
    flavourSet.appendFlavour('application/x-moz-file', 'nsIFile');
    return flavourSet;
  }
})
]]></field>

(中略)

<handler event="dragover" phase="capturing"
  action="nsDragAndDrop.dragOver(event, this.contentAreaEdgeDNDObserver);"/>

nsDragAndDrop用のオブザーバは、getSupportedFlavours()メソッドさえ備えていれば、あとは全てのメソッドを備えている必要はない。ここではドラッグ開始もドロップも検知せず、単にドラッグ中にブラウズ領域の端にポインタが来たかどうかを調べるためだけにオブザーバを使うので、このオブザーバにはonDragOver()メソッドしか持たせていない。

イベントハンドラの定義でphase="capturing"という指定があるのがポイント。これはJavaScriptでtarget.addEventListener(type, listener, true)と書くのと同じで、イベントが伝搬される優先順位を逆転させるための物だ。

通常、DOMのイベントモデルでは、発生したイベントは「イベントが発生した要素」からその祖先要素へと順番に伝搬していき、イベントハンドラやイベントリスナは、それらが設定された要素のところまでイベントが昇ってきた段階で初めて動き出す。これをイベントの伝搬とかバブリングとか言うんだけど、XBLでphase="capturing"という指定を使ったり、addEventListener()メソッドの第3引数にtrueを渡したりすると、そのイベントハンドラ(リスナ)には特別なルールが適用されて、イベントが発生した瞬間、バブリングを待たずにイベントを処理できるようになる(イベントのキャプチャリング)。

XULのbrowser要素には標準でdragoverイベントに対応したイベントハンドラが定義されていて、こいつがイベントを捕捉した時点でイベントのバブリングをキャンセルしてしまうため、その祖先要素であるsubbrowser要素ではいつまで待ってもdragoverイベントを拾えない。そのため、キャプチャリングを使って、キャンセルされる前のイベントを強制的に拾うようにする必要がある。というわけ。

ここではドラッグ&ドロップで受け取れるデータの型として、文字列とファイル(実際に渡るのはファイルパスだけ)を指定してある。これはつまり、ドラッグ中のデータがこれらの型である場合にのみオブザーバのonDragOver()メソッドが呼ばれて、ボタンを表示する処理が行われるという事だ。

なお、同様のオブザーバをポップアップ表示されるボタン用にも用意してあって、こっちは基本的にドロップ操作だけを受け付けるようにしてある。

……という風にしてドロップ操作の受け入れを実現したんだけれども、ここで2つの問題が発生してしまった。

  1. Linux上ではボタンへのドロップ操作を検知できなかった。
  2. Drag de Goのような、ドラッグ操作を乗っ取る他の拡張機能が動かなくなった。

順番に、それぞれの解決策を解説しよう。

XULはクロスプラットフォームなのが売りとはいえ、実際には環境ごとに若干、挙動に差異がある。今回の問題もそういった差異の一つに起因していて、Windowsだとdragoverイベントをトリガーにして表示したボタンの上に何かをドロップした時はそのボタンでdragdropイベントが発生するのに、Linuxではボタンの下にあるbrowser要素の方でdragdropイベントが発生してしまう事に、実際に動かしてみて気がついた。

これは、コードとしては特に複雑なことをする必要はなかった。browser要素へのドラッグ&ドロップのオブザーバについて、ドロップ時の処理をオーバーライドして「ドロップ位置がボタンのある位置で、且つ、ボタンが表示されていた場合は、ボタンの上にドロップされたものと認識する」という処理を付け加えてやったところ、意図したとおりに動いてくれるようになった。

面倒だったのはもう一つの方の、他の拡張機能とのコンフリクトだ。

オブザーバのonDragOver()メソッドの呼び出し元となるnsDragAndDropのdragOver()メソッドは、そのオブザーバがデータを受け入れ可能だった場合、問答無用でイベントのバブリングをキャンセルしてしまうという仕様になっている。このせいで、Split Browserのオブザーバがdragoverイベントを拾ってボタンを表示した瞬間にイベントがキャンセルされてしまい、Drag de Goがdragoverイベントを拾えないという事態になってしまっていた。

この仕様は、考えてみればとてもまっとうなものだ。例えばブラウザの中のテキストボックスに文字列をドラッグ&ドロップした時に、ブラウザ側のイベントハンドラに処理が渡ってしまって、ドロップされたテキストがURIでそれが読み込まれたり何かしてしまったら、まあ、普通に困る。だからこその、そういったトラブルを防ぐための仕様なんだろう。

しかし今回はそれとは話が違う。Split Browserは単にボタンを表示するかしないかの判断のためにイベントを捕捉しているだけであって、判断が終わったらイベントは何事もなかったかのようにバブリングを続けて貰いたいんだ。

というわけで今回はnsDragAndDropの使用を諦めて、直接XPCOMコンポーネントの機能を呼び出すようにすることにした。具体的には以下のように、nsDragAndDropのコードから必要な処理だけを抜き出している。


<handler event="dragover" phase="capturing"><![CDATA[
  const DragService = Components
                       .classes['@mozilla.org/widget/dragservice;1']
                       .getService(Components.interfaces.nsIDragService);
  var session = DragService.getCurrentSession();
  if (!session) return;

  if (session.isDataFlavorSupported('text/x-moz-url') ||
      session.isDataFlavorSupported('text/unicode') ||
      session.isDataFlavorSupported('application/x-moz-file'))
      this.handleMouseOverEvent(event);
]]></handler>

……という風に、最初のリリース以後は細かい使い勝手の向上のためにあれこれ手を尽くしている感じ。今後も何か一般的なテクニックとして参考になりそうなネタがあれば、ドキュメント化していきたい。

エントリを編集します。

wikieditish message: Ready to edit this entry.











拡張機能