要素が画面上に見えているかどうかを調べる

document.elementFromPoint という便利な関数を知ったので、今作っている ChromeMigemo ページ内検索で使ってみた。

これが困ったことに、ブラウザごとにかなり挙動が違うのだけど、本来の動作はこんな感じらしい。

待望の document.elementFromPoint が Firefox 3.0a8pre にて実装された。仕様は nsIDOMNSDocument.idl に詳しく書いてあるが、おおよそ以下の通りである。

  • HTML, XUL どちらの document に対しても使用可能
  • document の左上を (0, 0) とし、位置 (x, y) にある実際に見えている要素を取得する
  • 同一の document 内に存在する要素のみ取得可能。例えばインナーフレーム内の document 内に存在する要素は取得できず、代わりに iframe 要素を返す。
  • 位置 (x, y) が document の可視領域の外側にある場合、null を返す。
  • XUL document で使用する場合、例えば textbox 要素のスクロールバーのように XBL で生成された無名要素は取得できない。この場合、 textbox 要素を返す。
  • XUL document で使用する場合、 onload イベント発生以降でなければならない。
SCRAPBLOG : 待望の document.elementFromPoint が実装

何かの仕様に書いてあるのだろうか? と思ってググってみたら、CSSOM にあった。

The elementFromPoint(x, y) method, when invoked, must return the element at coordinates x,y in the viewport. The element to be returned is determined through hit testing. If either argument is negative, x is greater than the viewport width excluding the size of a rendered scroll bar (if any), or y is greather than the viewport height excluding the size of a rendered scroll bar (if any), the method must return null. If there is no element at the given position the method must return the root element, if any, or null otherwise.

CSSOM View Module

これがブラウザ間でどう違うかっていうと…

IEFirefoxの場合はウィンドウの表示領域の左上が基準だが、OperaやSafaiはページ全体の左上を基準とした座標で指定しなくてはならない。

そしてGoogle Chromeだがバージョン3ではOperaSafariと同じくページ全体の左上が基準なのだが、バージョン4からはIEFirefoxと同じ表示しているウィンドウの左上が基準となったようです。

kiyohoge Google Chromeのバージョン3と4ではelementfrompointの座標の基準が違う


本来なら要素 (の左上部分) が見えているかどうかはこれだけで判定できるみたい。

function is_in_view(elem) {
  var rect = elem.getBoundingClientRect();
  return elem === document.elementFromPoint(rect.left, rect.top);
}

Chromium (5系), Safari, Firefox, Opera 10.10, Opera 10.50 で試してみた。うまくいったのは Chromium だけだった。


Firefox は、どうやら (getBoundingClientRect か elementFromPoint のどちらかが) 1px ずれて計算されているみたいなので、このようにしないといけない。

function is_in_view(elem) {
  var rect = elem.getBoundingClientRect();
  return elem === document.elementFromPoint(rect.left + 1, rect.top + 1);
}

input 要素とかだと +1 しなくても取得できるんだけど、div とか p とかテキスト系の要素は取得できない。

コメントで指摘していただきました。+1 より Math.ceil のほうが良いとのこと。

えむけいさんが elementFromPoint のパッチを書いてくれました。Firefox 3.7 からは上の Chromium と同じように書けるようになるそうです。


Safari の場合はスクロール量を足してあげないとだめ (Chrome と挙動が違うのは WebKit のバージョンのせい。https://bugs.webkit.org/show_bug.cgi?id=29219 で直ってる)。スクロール量は Safari では document.body.scrollLeft/Top で取れるので、

function is_in_view(elem) {
  var rect = elem.getBoundingClientRect();
  return elem === document.elementFromPoint(rect.left + document.body.scrollLeft, rect.top + document.body.scrollTop);
}

となる。


Opera 10.10 では、互換モードだとスクロール量が document.body.scrollLeft/Top で取れるんだけど、標準モードだと document.documentElement.scrollLeft/Top にしないといけない。たしか、単に数字の大きい方を使えばよかったと思う。

それから、Opera は elementFromPoint でウィンドウ内に見えていない部分の要素まで取得できてしまうので、(これはこれで嬉しいのだけど) それらは除くようにする。

function is_in_view(elem) {
  var scrollLeft = Math.max(d.documentElement.scrollLeft, d.body.scrollLeft);
  var scrollTop = Math.max(d.documentElement.scrollTop, d.body.scrollTop);

  if (rect.left < 0 || rect.left > window.innerWidth || rect.top < 0 || rect.top > window.innerHeight) return false;

  var rect = elem.getBoundingClientRect();
  return elem === document.elementFromPoint(rect.left + scrollLeft, rect.top + scrollTop);
}


最後に Opera 10.50 では elementFromPoint がドキュメントの座標ではなくてウィンドウの座標を基準にするようになっている。ただしウィンドウ外の要素も返すのはそのまま。

function is_in_view(elem) {
  if (rect.left < 0 || rect.left > window.innerWidth || rect.top < 0 || rect.top > window.innerHeight) return false;

  var rect = elem.getBoundingClientRect();
  return elem === document.elementFromPoint(rect.left, rect.top);
}


以上を踏まえてブックマークレットを作ってみた。

要素の左上部分が見えてるものには、赤い ■ を付けてくれる。

javascript:(function(d) {

var scrollLeft = Math.max(d.documentElement.scrollLeft, d.body.scrollLeft);
var scrollTop = Math.max(d.documentElement.scrollTop, d.body.scrollTop);
var ua = {str:navigator.userAgent};
if (window.opera) {
  if (window.opera.version() >= 10.50) ua.opera1050 = 1;
  else ua.opera = 1;
}else if (/Chrome/.test(ua.str)) ua.chrome = 1;
else if (/AppleWebKit/.test(ua.str)) ua.safari = 1;
else if (/Gecko\//.test(ua.str)) ua.firefox = 1;

[].forEach.call(d.getElementsByTagName('*'), function(elem) {

  var rect = elem.getClientRects()[0];
  if (!rect) return;
  if ((ua.opera || ua.opera1050) && (rect.left < 0 || rect.left > window.innerWidth || rect.top < 0 || rect.top > window.innerHeight)) return false;

  var left = rect.left;
  var top = rect.top;

  if (ua.opera || ua.safari) {
    left += scrollLeft;
    top += scrollTop;
  }
  if (ua.firefox) {
    left += 1;
    top += 1;
  }

  if (d.elementFromPoint(left, top) === elem) {

    var div = d.createElement('div');
    div.setAttribute('style', 'position:absolute;top:'+(rect.top+scrollTop)+'px;left:'+(rect.left+scrollLeft)+'px;height:'+10+'px;width:'+10+'px;background-color:red;opacity:0.5;');
    d.body.appendChild(div);
  }
});

})(document);

これを使えば Hit-a-Hint がものすごくラクチンにできる!!

実際に使う場合は WebKit のバージョンで判定したほうがいいな。SafariiPhoneChromeAndroid のブラウザと、全部試すのは無理だし。あと OperaOpera 10.10 (Presto 2.2) と 10.50 (Prest 2.5) の間にモバイルだけの Presto 2.4 というのがあったはず。どこで分けたらいいのか…

IE では getBoundingClientRect の計算が 2px ずれるとかどうとか。持ってないのでわかりません。試した人は教えてください。