2009-06-18

DOM level 3のマウスイベントにおけるカーソル位置の詳細

マウスイベントは、DOM level 3で定義されている。問題は、規格の定義が曖昧で、ブラウザの実装が救いがたいぐらい異なっているということだ。ここでは、マウスの位置を取得する方法を、完璧に解説しようと思う。とくに、canvasを使うにあたっては、マウスカーソルの位置を取得することは重要だ。

座標とは何か

ここで私の言う座標とは、ある点を(0,0)と置いた場合の、その点からの位置(x,y)のことである。ただし、右下が正になる。これはコンピューターの世界では、一般的な座標系である。では、その基準となるべき「ある点」とはどこか。これが問題である。

次のようなコードを考える。

var mouse_event_listener =
{
        handleEvent : function(event)
        {
                //ここにコードが記述される
        }
} ;

これは、DOM level 3 Eventに準拠するイベントリスナーの、Javascriptによる実装である。このコードをベースに、マウスカーソルの位置を取得したい。

スクリーン座標

var x = event.screenX ;
var y = event.screenY ;

スクリーン座標は、コンピュータのディスプレイの左上を原点とする座標系である。screenX, screenY属性で取得できる。Javascritpでは、同名のプロパティとして実装されている。しかし、これは、現実的には、何の役に立たない。ブラウザ自体がディスプレイのどの位置にいるのかがわからないので、画面上の位置を知ったところで、何にもならないからだ。

ウインドウ座標

var x = event.clientX ;
var x = event.clientY ;

ウインドウ座標とは、現在のブラウザのウインドウの、ドキュメントを表示している部分の左上原点とした座標である。問題は、ウインドウは、必ずしもドキュメント全体を表示するとは限らない。スクロールと呼ばれるUIによって、ドキュメントの一部だけを表示しているかもしれない。

ドキュメント座標

これは、ドキュメント全体に対する座標である。ウインドウ座標が、ドキュメントをすべて表示している場合と考えてもいい。理想は、この座標だ。問題は、この座標を、クロスブラウザな実装で得るのが不可能に近いということだ。これについては最後に解説する。

ある要素からの相対座標

HTML5のcanvasを使う場合、そのcanvas要素の左上を原点とした座標での、マウスカーソルの位置が知りたい場合がよくある。というのも、canvasを使う以上、通常のマークアップ言語を離れた、特殊な描画がしたいのだから、これ自体は不思議ではない。問題は、その方法だ。一体どうすれば、この座標を得られるのだろう。以下に列挙する。

offsetX/offsetY

var x = event.offsetX ;
var y = event.offsetY ;

多くのブラウザで、MouseEvent インターフェースには、offsetXとoffsetYが定義されている。これは、まさに欲している、イベント要素からの相対座標である。問題は、W3Cの規格では定義されていないことと、さらにひどいことに、ブラウザ間で、微妙に異なる実装をしている。そもそも、その要素からの相対座標といっても、具体的にどこを原点とした座標なのか、ということだ。規格では、padding boxの左上を原点としている。ところが、ブラウザによって、原点の取り方が異なる。

IE8は、完璧な実装をしている。
Firefoxでは、サポートしていない。
Safari、Chrome、Konqueror、では、border boxの左上を原点としている。
Operaでは、content boxの左上を原点としている。

ブラウザ間で差異があり、W3C規格で未定義で、Firefoxでサポートしていないことに目をつぶれば、一応この機能は使える。実際に使う際には、要素のpaddingを0pxに設定しておいたほうがいいだろう。

layerX/layerY

var x = event.layerX ;
var y = event.layerY ;

Firefoxのみがサポートしている独自規格で、offsetX、offsetYと機能は同じである。W3C規格には載っていないし、Firefox以外にサポートしているブラウザもない。

ドキュメント座標から、要素への相対座標を得る

今、何らかの方法で、マウスカーソルのドキュメント座標が得られたとする。ここから要素への相対座標を得ることは、可能である。イベントリスナーが登録されている要素からの相対座標を得たいとする。Eventインターフェースには、target属性があり、これを使えば、イベントリスナーが登録されている要素が得られる。Elementインターフェースは、CSSOM View Moduleに定義されているように、offsetLeftと、offsetTop属性を持っている。問題は、これは、親要素からのオフセットだということだ。ある要素の親要素には、さらに親要素が存在する可能性がある。したがって、body要素まで延々と親要素をたどっていかなければならない。いま、x, yにドキュメント座標が入っているとすると、そのコードは以下のようになる。

for ( var p = event.target ; p !== null ; p = p.offsetParent )
{
        x -= p.offsetLeft ;
        y -= p.offsetTop ;
}

body要素のoffsetParentはnullを返すと規定されているので、このコードで、親要素を延々とたどって、相対座標を計算できる。

ドキュメント座標を得る方法

さて、いよいよ本題の、ドキュメント座標を得る方法について解説する。ドキュメント座標が得られれば、上記のoffsetを引くループで、相対座標が得られる。ただし、これがなかなか難しい。

pageX/pageY

var x = event.pageX ;
var y = event.pageY ;

これは、W3C規格では定義されていない。しかし、IE以外の主要なブラウザすべてでサポートされている。これはまさに、ドキュメント座標が得られる。

ウインドウ座標にスクロール分を加算する

// it only worked on webkit.
var x = event.clientX + document.body.scrollLeft ;
var y = event.clientY + document.body.scrollTop ;

// it's worked on Firefox and Opera, but not for webkit.
var x = event.clientX + document.documentElement.scrollLeft ;
var y = event.clientY + document.documentElement.scrollTop ;

// I think this is the perfect solution. but for unknown reason, failed at both Firefox and Opera.
var x = event.clientX + document.getElementsByTagName("body").item(0).scrollLeft ;
var y = event.clientY + document.getElementsByTagName("body").item(0).scrollTop ;

もういやだ。一体body要素のElementインターフェースはどうやって取得すればいいのか。歴史的に、document.bodyが使われてきた。ところが、DOM level 2では、document.documentElementが定義され、かなりのブラウザがそちらに移った。そして、document.bodyが動かなくなった。quirks modeかどうかで、この挙動を変えるブラウザが存在することも、頭を悩ませる。また、HTML5では、document.bodyが規格に入っている。だから、HTMLとDOMを統合したHTML5ならば、document.bodyが正しいということになるし、それ以外なら、DOM level 2の定めるところにより、document.documentElementが正しいといえるのだろう。しかも、たいていのブラウザでは、動かないだけで、実際にはどちらも定義されているので、未定義かどうかでブランチすることも出来ない。したがって、ウインドウ座標からスクロール分を加算してドキュメント座標を得る手法では、ブラウザのブランチが必要不可欠になってしまう。

ところで、三番目のコードは、私としては、正しく動くと期待していたのだが、なぜかwebkit系のブラウザ(SafariとChrome)でしか動かない。もうわけが分からない。

参考

Document Object Model (DOM) Level 3 Core Specification
Document Object Model (DOM) Level 3 Events Specification
CSSOM View Module
HTML5
Javascript Madness: Mouse Events
Javascript - Event properties
Mission Impossible - mouse position | evolt.org
W3C DOM Compatibility - CSS Object Model View

追記:コメント欄より、 CSSOM View ModuleのgetBoundingClientRect()を使う。
基本的に理想主義者の私は、まず先に参考にするのは規格書なので、getBoundingClientRect()の存在は知っていたが、どうもその解説が複雑で、まじめに読む気にならなかった。さて、思い直して読んでみたところ、これはなかなか面白いものだ。これをまじめに解説した日本語の情報がWebあるかどうか、ふとググってみたところ、なかった。そこで、解説してみることにする。

getBoundingClientRect()の仕様を説明するには、まず、getClientRects()を説明しなければならない。これらの二つは、どちらもElementViewインターフェースで定義されており、DOM level 3では、すべてのElementは、ElementViewインターフェースを実装していなければならない。

getClientRects()は、その要素の子のボーダーボックス領域のリストを返す。getBoundingClientRect()は、getClientRects()の戻り値のリストから計算して、TextRectangleを返す。言い換えれば、その要素の領域だ。

重要な事は、これがviewportに対する相対座標だということだ。つまり、event.clientX/Yとは、同じ座標系ということになる。これで、マウスカーソルの、その要素に対する相対座標が得られる。

うれしいことに、これはほとんどのブラウザで動く。有名なquirksmode.orgのW3C DOM Compatibility - CSS Object Model Viewによれば、IE7以前で、2ピクセルずれるとか、Firefoxは結果を丸めないとか、些細な違いがあるが、それはそのブラウザのバグで、私の知ったことではないし、他の悲惨な方法に比べれば、実に他愛ない違いである。

var x = event.clientX ;
var y = event.clientY ;
var rect = event.target.getBoundingClientRect() ;
        
x -= rect.left ;
y -= rect.top ;

これで、この前のHTML5のCanvasの使ったデモは、規格を正しく実装しているどんなブラウザでも動くようになった。

6 comments:

os0x said...

少なくともHTMLでは、
document.getElementsByTagName("body")[0] === document.body // true
document.getElementsByTagName("body").item(0) === document.getElementsByTagName("body")[0] // true
です。

>quirks modeかどうかで、この挙動を変えるブラウザが存在する
Safari以外の主要なブラウザは後方互換モードか標準準拠モードかでdocument.bodyとdocument.documentElementの挙動を変えます。つまりこちらがスタンダードな実装です。(Operaは9.5より前はどちらも使えましたが、9.5以降からFirefoxやIEと合わせました)

また、要素の絶対座標を求めるには、getBoundingClientRect(IE,Firefox3,Opera,Safari4)もあります。window.page[XY]OffsetもIE以外で使えるのでなかなか優秀です。

まあ、結局この辺りは各ブラウザでバラバラ、メンドクサイってのはホントですね…

hito said...

getElementsByTagNameで一応、bodyのElementが返ってくるらしいのですが、そのScrollTopなどを見ても、OperaやFirefoxでは、0になっているのです。

getBoundingClientRectの返す内容は、viewportからの相対座標ですか。すると、同じくウインドウ座標のevent.clientX/Yと組み合わせれば、要素からの相対座標が分かりますね。

どうやら、この方法が一番のようですね。W3C規格にもありますし。

Anonymous said...

> マウスイベントは、DOM level 3で定義されている
DOM Level 2 Events。

> これは、DOM level 3 Eventに準拠するイベントリスナーの、Javascriptによる実装である。
ECMAScript 言語束縛では Function。Object を渡せるのは独自拡張。

> layerX/layerY
に対応するのは IE の event.x、event.y(IE4 以下を除く)。ただし WebKit でも CSSOM-View でも混乱。

> 親要素からのオフセット
offsetParent は必ずしも parentNode ではない。

> DOM level 2では、document.documentElementが定義され
DOM Level 1。

> かなりのブラウザがそちらに移った
初期包含ブロックを形成するルート要素が何であるかは CSS の問題で DOM とも HTML5 とも無関係。

> HTMLとDOMを統合したHTML5ならば、document.bodyが
document.body は DOM Level 1 HTML。

> 正しく動くと期待していたのだが
表示域と初期包含ブロックの関係。CSS 2.1。

hito said...

>DOM Level 2 Events。
DOM level 3から定義されているのはキーボード周りのイベントでした。混同したようです。


>ECMAScript 言語束縛では Function。Object を渡せるのは独自拡張。
あの辺は正直よく分かりません。
DOMの規格を素直にJavascriptで実装すると、handleEventという関数をプロパティにもったオブジェクトになると考え、
実際に主要なブラウザで動いたので、そう解釈しましたが、javascriptでは単なる関数になるんでしょうかねぇ。

>に対応するのは IE の event.x、event.y(IE4 以下を除く)。ただし WebKit でも CSSOM-View でも混乱。
www.quirksmode.orgを参考に書いたのですが、実際に自分では試していないので分かりません

>offsetParent は必ずしも parentNode ではない。
どうも一部の要素やpositionが絡むと(absoluteとか考えると分かりやすいのかな)、そこで終わることもあるようですね。

>DOM Level 1。
ふむ。


CSS周りの規格は、まだ、あまり真剣に読んでいませんが、
body周りのよく分からない挙動が、CSSに起因するのですか?
はて、何故でしょう。

hito said...

やはり分からない。そもそもwebkitとそのほかで挙動が違うと言うことは、webkitが間違っているのか、はてさて。

Anonymous said...

> Firefoxは結果を丸めないとか、些細な違いがあるが、それはそのブラウザのバグで、私の知ったことではない
cssom-viewの仕様ではgetBoundingClientRectが返すClientRectオブジェクトのメンバはfloat型なのですが、本当に値を丸めないのはFirefoxのバグなのですか?