May 09, 2007

画面上の座標を指定してその位置の要素を取得する、という処理

タブカタログのサムネイル上でリンクをクリックしたらリンク先に飛べるようにしたついでに、リンク上の中クリックでタブも開けるようにしてみたんだけど、これ、ちょっとでもクリック位置がずれたらタブを閉じてしまいかねないなあ。いまリンクの上にポインタがあるのかどうか一目で分かるようにしたいけど、CSSのcursorみたいなことはJavaScriptじゃ実現できないようなあ……と思った末に、現在ポイントしている位置のクリック可能な要素を強調表示するということを思いついた。でも、これで本当に実用的な速度で動くかどうか?というのははなはだ疑問だった。

GomitaさんのTab Scopeでは、DOM2 Tree Walkerによってすべてのクリック可能な要素を頭から順番に走査していき、要素のボックスの範囲がclickイベントの発生した座標を含んでいるかどうかをチェックする、という手順によってcanvas上のクリック位置に対応するリンクやボタンを見つけている。タブカタログもこの実装をそのまま使わせてもらっている。

この方法の問題は、ページの中にリンクやボタンが大量に配置されていたり、ページの末尾近くまでスクロールしていたりすると、クリック位置の要素を見つけるまでにものすごく時間がかかってしまうという点だ。クリック時のみの処理ならまあ我慢できるかもだけど、mousemoveイベントを拾って「今クリック可能な要素をポイントしている」時だけその事を示そうと思ったら、相当悲惨な事になるのは目に見えている。っていうか実際やってみてあまりの重さに死にかけた。

そこで、何年も前に試して諦めたnsIAccessibleを使う方法に再び挑戦してみることにした。

結論から言うと、これのおかげで現在の位置の要素の取得を飛躍的に高速化できて、やっと実用レベルになったと思う。

当該箇所の最終的なコードはこんな感じになった。

getClickableElementFromPoint : function(aWindow, aScreenX, aScreenY) 
{
  var accNode;
  try {
    /*
      クリック位置からアクセシビリティ用のノードを得る
      参考:http://www.mozilla-japan.org/access/architecture.html
    */
    var accService = Components.classes['@mozilla.org/accessibilityService;1']
              .getService(Components.interfaces.nsIAccessibilityService);
    var acc = accService.getAccessibleFor(aWindow.document);
    accNode = acc.getChildAtPoint(aScreenX, aScreenY);
    /* アクセシビリティ用のノードからDOMのノードを得る */
    accNode = accNode.QueryInterface(Components.interfaces.nsIAccessNode).DOMNode;
    /*
      この時点で、得られたノードがクリック可能な要素またはその子孫である場合、
      祖先をたどってクリック可能な要素を返す。
    */
    var clickable = accNode ? this.getParentClickableNode(accNode) : null ;
    if (clickable)
      return this.getImageInLink(clickable) || clickable;
  }
  catch(e) {
  }

  var doc = aWindow.document;
  /*
    アクセシビリティ用のノードから得られたDOMノードがクリック可能な要素または
    その子孫で *なかった* 場合でも、かなり近い位置の祖先ノードは取得できている。
    なので、検索をそこからスタートすれば、相当な高速化になる。
  */
  var startNode = accNode || doc;
  var filter = function(aNode) {
    switch (aNode.localName) {
      case 'A':
        if (aNode.href)
          return NodeFilter.FILTER_ACCEPT;
        break;
      case 'BUTTON':
        return NodeFilter.FILTER_ACCEPT;
        break;
      case 'INPUT':
        if (aNode.type == 'button' || aNode.type == 'submit' || aNode.type == 'image')
          return NodeFilter.FILTER_ACCEPT;
        break;
    }
    return NodeFilter.FILTER_SKIP;
  };
  var img;
  var walker = aWindow.document.createTreeWalker(startNode, NodeFilter.SHOW_ELEMENT, filter, false);
  for (var node = walker.firstChild(); node != null; node = walker.nextNode())
  {
    if (
      node.hasChildNodes() &&
      (img = this.getImageInLink(node))
      )
      node = img;
    var box = doc.getBoxObjectFor(node);
    var l = box.screenX;
    var t = box.screenY;
    var r = l + box.width;
    var b = t + box.height;
    if (l <= aScreenX && aScreenX <= r && t <= aScreenY && aScreenY <= b)
      return node;
  }
  return null;
},

nsIAccessibleServiceを使って取得できるnsIAccessibleというのは、DOMツリーのサブセット的な物で、ユーザが操作可能な要素?のみで構成されたツリーらしい。nsIAccessibleのgetChildAtPoint()メソッドを使うと、画面上の座標から、その位置にあるユーザが操作可能な要素に対応するノード(nsIAccessible)が取得できる。3年前は、ここからDOMノードを取得する方法が分からなくてドツボにハマッていた。nsIAccessibleにはgetDOMNode()というメソッドがあってこれでDOMノードを取れると思いきや、「未実装だYO!」というエラーが起こってにっちもさっちもいかなくなっていた。

ところが、この度情報を求めて検索していてたどり着いたBug 249421 – Remove getDOMNode from nsIAccessible (it is already available in nsIAccessNode)を見て初めて、このメソッドを使わなくてもnsIAccessNodeインターフェースを介せばnsIAccessibleからDOMノードを取得できる(ようにいつの間にかなっていた)ということを知った。というかこのリンク先のバグの修正の結果、そもそもnsIAccessibleからgetDOMNode()メソッド自体が削除されたらしい。

というわけで早速getChildAtPoint()を使ってみたんだけど、実際には期待に反した動作をすることがままあるようだった。リンクをポイントしてもその祖先要素のblockquote要素が取得されてしまったりして……ていうかこれってもしかして文字の隙間からその背景要素を取得してしまってそうなるという話なんだろうか? まあともかく、狙った位置の要素を確実に取得するのは残念ながら無理っぽい。

とはいえ、この方法で取得した要素ノードは、これまでクリック可能な要素の走査の起点にしていたルートノードに比べればずっと、目的としているノードに「近い」位置にあることは間違いない。というわけでフォールバックとしてこれまでの処理も組み合わせてやることで、やっと、意図通りに実用的な速度で動くようになってくれた。

これでXUL/MigemoやCrossFireの動作ももっと高速化できるかも?

エントリを編集します。

wikieditish message: Ready to edit this entry.











拡張機能