TextNodeにも使えるgetBoundingClientRect

JavaScript(というかDOM? CSSOM?)には getBoundingClientRect という便利なメソッドが用意されています。これを使えば,ある要素のviewport上の座標,つまりブラウザ画面の左上を原点とした座標上での要素の位置を取得することができます。これまでは d:id:gifnksm:20100729:1280429284 のgetOffset関数のように,offsetParentを辿っていく方法で位置を取得していました。また親要素に position: fixed; が指定されている場合などを考慮すると,d:id:gifnksm:20090506:1241630603 のように非常に長い関数を書かざるを得なかったりして,何かと面倒でややこしくて,できるだけ敬遠したい処理でした。複雑な関数を書いても,最終的に任意の文書に対して正しく動作するか結局よくわかりませんでしたし。
しかし,getBoundingClientRectを用いると,この処理が一気に簡潔に掛けるようになります。すばらしい。

function getPosition(node, root) {
  if (root === undefined) root = document.documentElement;
  let nodeRect = node.getBoundingClientRect();
  let rootRect = root.getBoundingClientRect();
  return {
    top: nodeRect.top - rootRect.top,
    bottom: nodeRect.bottom - rootRect.top,
    left: nodeRect.left - rootRect.left,
    right: nodeRect.bottom - rootRect.left,
    width: nodeRect.right - nodeRect.left,
    height: nodeRect.bottom - nodeRect.top
  };
}

どうやらこのgetBoundingClientRectは,IE5が独自に実装したもので,Firefoxも3から対応していたらしい。最近まで知らずに無駄に関数を書いてしまっていた…。
で,以下が本題。
非常に便利な getBoundingClientRect だが,テキストノードの位置取得に用いる事はできない (offsetTop等を用いる方法も同様だけど)。そこで,テキストノードの位置も取得できるように以下のような関数を書いてみた(let使ってるからFirefox専用)。2010/10/15追記 Firefox 4用のコードを追加。テキストノードでも高速に動作する…と思う(未検証)

function getBoundingClientRect(node) {
  let doc = node.ownerDocument;

  if (node.nodeType === doc.ELEMENT_NODE)
    return node.getBoundingClientRect();

  // for Firefox 4
  if ('getBoundingClientRect' in Range.prototype) {
    let range = doc.createRange();
    range.selectNode(node);
    return range.getBoundingClientRect();
  }

  let parent = node.parentNode;
  let span = doc.createElement('span');
  span.style.verticalAlign = 'top';
  parent.insertBefore(span, node);
  let preTopRect = span.getBoundingClientRect();
  span.style.verticalAlign = 'bottom';
  let preBottomRect = span.getBoundingClientRect();
  parent.insertBefore(span, node.nextSibling);
  let postRect = span.getBoundingClientRect();
  parent.removeChild(span);

  let isMultiLine = preBottomRect.top !== postRect.top;
  if (isMultiLine) {
    let parentRect = parent.getBoundingClientRect();
    return { top:    preTopRect.top,
             bottom: postRect.bottom,
             left:   parentRect.left,
             right:  parentRect.right,
             width:  parentRect.width,
             height: postRect.bottom - preTopRect.top
           };
  }

  return { top: preTopRect.top, bottom: postRect.bottom,
           left: preTopRect.left, right: postRect.right,
           width: postRect.right - preTopRect.left,
           height: postRect.bottom - preTopRect.top };
}

やり方としては非常に単純で,

  1. nodeの前にvertical-align: top; <span/>を挿入,位置を取得 (preTopRect)
  2. vertical-align: bottom;にして<span/>の位置を再取得 (preBottomRect)
  3. nodeの後ろにvertical-align: botom; <span/>を挿入,位置を取得 (postRect)
  4. テキストノードが複数行にまたがっている場合 (preBottomRect.top !== postRect.top)と,そうでない場合で場合分けしてテキストノードの位置を計算する
    • 単一行の場合: { top, left } = preTopRect; { right, bottom } = postRect
    • 複数行の場合: parentRect = node.parentNode.getBoundingClientRect(); { top } = preTopRect; { bottom } = postRect; { left, right } = parentRect;

これでテキストが一行に収まる場合は正確に,複数行にまたがる場合はおおまかな値を取得することができます。複数行の場合,親要素のpadding-(left/right)border-(left/right)-widthを減算する処理を加えた方が良いかもしれない。
とまあこんな感じで書いてみたのですが,この関数,意外と重い。100個以上のテキストノードの位置を取得しようとするとブラウザが数秒固まることも(エレメントノードの場合は数百個の要素に対して位置を取得しても,ブラウザが固まることは無かった)。要素の追加・削除を数百回繰り返すわけですから,考えてみれば当たり前なのですが…。何か他に良い方法はないものか。

2010/10/14追記

Firefox4からはRange#getBoundingClientRectが追加されるので,これを使えば劇的に高速化可能っぽい.Firefox4早く来い!!

参考
Firefox 4 for developers - Mozilla | MDN