2014-10-21

CSS Transformが適用されている要素内でのマウスカーソルの位置を求める

こういうことが簡単にできないのがJavaScriptの恐ろしいところですネ。従来は要素のoffsetParent, offsetLeft, offsetTopgetBoundingClientRect なんかを使うのが定石でしたが(これもどうかと思うけど)、最近はCSS Transformなんてものがあるので困ってしまうわけです。CSSOM View Moduleに書いてある offsetLeft, offsetTop の説明にはtransformの影響を受けないと書かれています。影響を受けたところで、座標変換のためには伸縮や回転を考慮しなければならないので、話がややこしくなるだけでしょう。GeometryUtilsなんて面白そうなインタフェースが書かれていますが、今のところ中身は無し。何も決まっていないようです。これが出来れば将来的にはもっと楽に出来るようになるのかも?

WebKitの場合 webkitConvertPointFromPageToNode というそのものズバリの関数があるので、使える場合はおとなしくそれを使いましょう。Firefoxの場合MouseEventにlayerX,layerYというプロパティがあり、どうもそれが要素上の座標を示しているっぽいのですが、微妙にずれていたりしてよく分からないし、対応しているのはMozilla系だけなので無視します。

プラットフォーム側でサポートが無いのであれば、自分で行列を作って変換するのが良さそうです。transformプロパティには結構色々なことが書けるのですが、それもきちんと解析しないといけません。webkitCSSMatrixが他のブラウザでも使えるようになれば少しは楽になるかもしれませんが、まぁ、そんなことを言っていても仕方ないです。

まずはおさらいから。offsetParent,offsetLeft,offsetTopを使うとページ左上からの位置をたどることが出来ます。

offsetParentはparentNodeとは色々と異なります。offsetLeft,offsetTopは単純に一つ上の親からの相対位置にはならないんですね。なので、一工夫して一つ上の親からの相対位置に分解します。親と子でoffsetParentが同じなら子のoffsetLeftから親のoffsetLeftを引けば、親から子への相対位置が求められます。ただしoffsetParentがnullのときはルート、body要素、position:fixedな要素のいずれかです(Firefoxはposition:fixedでnullを返さないバグあり)ので、それ以上親を考慮する必要はありません。offsetParentがparentNodeと一致する場合は、親=offsetParentなので、offsetLeftがそのまま親からの相対位置になります。それらを考慮して親からの相対位置を求める関数relPosX(elem),relPosY(elem)を作ってみました。

    function relPosX(elem){
        return !elem.offsetParent ? elem.offsetLeft :
            elem.parentNode == elem.offsetParent ? elem.offsetLeft :
            elem.parentNode.offsetParent == elem.offsetParent ? elem.offsetLeft - elem.parentNode.offsetLeft :
            0;
    }
    function relPosY(elem){
        return !elem.offsetParent ? elem.offsetTop :
            elem.parentNode == elem.offsetParent ? elem.offsetTop :
            elem.parentNode.offsetParent == elem.offsetParent ? elem.offsetTop - elem.parentNode.offsetTop :
            0;
    }

この関数が返す値はtransformを考慮しません。なので、完全な相対位置を求めるには、transformプロパティとtransform-originプロパティを解析する必要があります(3D変形は今回は対応しません)。

transform プロパティの文字列を解析して変換行列を返す関数parseCSSTransformを作りました。

    function parseCSSTransform(tformStr)
    {
        //http://www.w3.org/TR/css3-transforms/#transform-property
        var funStrs = tformStr.split(")");
        funStrs.pop();

        var mat = matNewIdentity();

        for(var i = 0; i < funStrs.length; ++i){

            var funNameValue = funStrs[i].split("(");
            var fun = funNameValue[0].trim();
            var args = funNameValue[1];
            switch(fun){
            // 2D Transform Functions
            // http://www.w3.org/TR/css3-transforms/#two-d-transform-functions
            case "matrix":
                matMul(mat, mat, matNewMatrix3x2.apply(null, args.split(",")));
                break;
            case "translate":
                var xy = args.split(",");
                matMul(mat, mat, matNewTranslate(parseFloat(xy[0]), parseFloat(xy[1] || "0")));
                break;
            case "translateX":
                matMul(mat, mat, matNewTranslate(parseFloat(args), 0));
                break;
            case "translateY":
                matMul(mat, mat, matNewTranslate(0, parseFloat(args)));
                break;
            case "scale":
                var sxy = args.split(",");
                matMul(mat, mat, matNewScale(parseFloat(sxy[0]), parseFloat(sxy.length >= 2 ? sxy[1] : sxy[0])));
                break;
            case "scaleX":
                matMul(mat, mat, matNewScale(parseFloat(args), 1));
                break;
            case "scaleY":
                matMul(mat, mat, matNewScale(1, parseFloat(args)));
                break;
            case "rotate":
                matMul(mat, mat, matNewRotate2D(toRadian(args)));
                break;
            case "skew":
                var wxy = args.split(",");
                matMul(mat, mat, matNewSkew2D(toRadian(wxy[0]), toRadian(wxy[1] || "0")));
                break;
            case "skewX":
                matMul(mat, mat, matNewSkew2D(toRadian(args), 0));
                break;
            case "skewY":
                matMul(mat, mat, matNewSkew2D(0, toRadian(args)));
                break;
            //http://www.w3.org/TR/css3-transforms/#three-d-transform-functions
            ///@todo support 3D Transform Functions
            }
        }
        return mat;
    }
    function toRadian(angle)
    {
        return angle.indexOf("deg") >= 0 ? parseFloat(angle) * (Math.PI/180) :
            angle.indexOf("grad") >= 0 ? parseFloat(angle) * (Math.PI/200) :
            parseFloat(angle);
    }

一緒に簡単な行列ライブラリ(matで始まる関数)も作成しています。少し長くなるので別途ソース参照のこと。

transform-origin プロパティの文字列を解析して座標を返す関数parseCSSTransformOriginを作りました。

    function parseCSSTransformOrigin(originStr, boxSizes)
    {
        //http://www.w3.org/TR/css3-transforms/#transform-origin-property
        var values = originStr.split(" ");
        var origins = [];
        for(var i = 0; i < values.length; ++i){
            var value = values[i].trim().toLowerCase();
            if(value == ""){
                continue;
            }
            var refSize = boxSizes[origins.length] || 0;
            switch(value){
            case "left": origins.push(0); break;
            case "top": origins.push(0); break;
            case "right": origins.push(boxSizes[0] || 0); break;
            case "bottom": origins.push(boxSizes[1] || 0); break;
            case "center": origins.push(refSize*0.5); break;
            default:
                if(value.indexOf("%") > 0){
                    origins.push(refSize * parseFloat(value) / 100);
                }
                else{
                    origins.push(parseFloat(value));
                }
                break;
            }
        }

        while(origins.length < 2){
            refSize = boxSizes[origins.length] || 0;
            origins.push(refSize * 0.5); //default 50%
        }

        return origins;
    }

この二つの関数が出来ると、要素に設定されているCSS Transformの影響を変換行列の形で求めることが出来ます。

    function getElementComputedStyle(elem)
    {
        return window.getComputedStyle ? window.getComputedStyle(elem, "") :
            document.defaultView && document.defaultView.getComputedStyle ? document.defaultView.getComputedStyle(elem, "") :
            elem.currentStyle ||
            elem.style;
    }
    /// CSS Transformのプロパティ(transformプロパティやtransform-originプロパティなど)による変換行列を求めます。
    function getCSSTransformMatrix(elem)
    {
        var elemStyle = getElementComputedStyle(elem);
        var origins = parseCSSTransformOrigin(elemStyle.transformOrigin, [elem.offsetWidth, elem.offsetHeight]);
        var tform = parseCSSTransform(elemStyle.transform);
        return matNewMul(matNewTranslate(origins[0], origins[1]), matNewMul(tform, matNewTranslate(-origins[0], -origins[1])));
    }

transform-originで指定される原点を引いて、transformを適用し、原点を足して戻すという変換になります。

プロパティの値はできるだけ計算値から求めるようにしています。(transformの計算値は常にmatrixだけのようなので、parseCSSTransformを律儀に作成しなくても良かった気もしますが、まぁ、他でも使えるかもしれないのでいいや)

親子の相対位置とCSS Transformによる変形行列を合成して、どんどんルートまで合成していくと、最終的に要素座標からページ座標へ変換する行列が完成します。

    /// 要素内bounding-box左上を原点とする座標系からページ左上を原点とする座標系へ変換する行列を求めます。
    function getMatrixFromNodeToPage(elem)
    {
        var mat = matNewIdentity();
        while(elem){
            var tformMat = getCSSTransformMatrix(elem);
            if(tformMat){
                matMul(mat, tformMat, mat);
            }

            matMul(mat, matNewTranslate(relPosX(elem), relPosY(elem)), mat );
            var elemStyle = getElementComputedStyle(elem);
            if(elemStyle && elemStyle.position == "fixed"){ //see Firefox Bug https://bugzilla.mozilla.org/show_bug.cgi?id=434678
                matMul(mat, matNewTranslate(window.pageXOffset, window.pageYOffset), mat);
                break;
            }
            if(!elem.offsetParent){
                break; //root or body
            }
            elem = elem.parentNode;
        }
        return mat;
    }

position:fixedの場合、スクロールしてもoffsetLeft, offsetTopの値は一切変わらないみたいなのですが、MouseEventのpageX, pageYは当然スクロールに応じて大きな値になるので、fixedの時だけwindow.pageXOffset,window.pageYOffsetを足すことにしています。

後は逆行列を作れば、ページ座標から要素座標へ変換できるようになります。MouseEventのpageX,pageYから要素上の座標へ変換できます。

    function convertPointFromPageToNode(elem, pageX, pageY)
    {
        if(global.webkitConvertPointFromPageToNode && global.WebKitPoint){
            // webkitConvertPointFromPageToNodeが使える場合はそれを使う!
            var r = webkitConvertPointFromPageToNode(elem, new WebKitPoint(pageX, pageY));
            return [r.x, r.y];
        }
        else{
            return convertPointFromPageToNode_Impl(elem, pageX, pageY);
        }
    }

    function convertPointFromPageToNode_Impl(elem, pageX, pageY)
    {
        var matPageToElement = matNewInverse(getMatrixFromNodeToPage(elem));
        if(matPageToElement){
            return matNewMulVec(matPageToElement, [pageX, pageY]);
        }
        else{
            return [0,0];
        }
    }

この方法で得られるのは境界矩形(bounding-box)左上を原点とする座標系の位置です。つまり、paddingとborderを含む領域の左上が(0,0)になります。この座標系はcanvas要素で使うには不便です。コンテンツ領域左上を原点とする座標系に変換したいです。なので、padding-left, padding-top, border-left-width, border-top-widthの計算値を求めて、少しずらしてやります。

    /// 要素コンテンツ領域左上側の幅と高さを求めます。
    function getElementOuterLeftTop(elem)
    {
        var elemStyle = getElementComputedStyle(elem);
        function getNumber(propName){ return parseFloat(elemStyle.getPropertyValue(propName));}
        var left = getNumber("border-left-width") + getNumber("padding-left") || 0;
        var top = getNumber("border-top-width") + getNumber("padding-top") || 0;
        return [left, top];
    }

    // ページ座標から要素内コンテンツ領域左上を原点とする座標系へ変換します。
    function convertPointFromPageToNodeContentArea(elem, pageX, pageY)
    {
        var outerLeftTop = getElementOuterLeftTop(elem);
        var pos = convertPointFromPageToNode(elem, pageX, pageY);
        pos[0] -= outerLeftTop[0];
        pos[1] -= outerLeftTop[1];
        return pos;
    }

まとめ:

参考URL:

CSSOM View Module
Viewの仕様。まだドラフト。
CSSOM View Module 日本語訳
日本語訳感謝!
jquery.transform.js/jquery.transform2d.js at master · louisremi/jquery.transform.js
jQueryは詳しくないのですが、なんか似たようなことやってます。
function to get the MouseEvent coordinates for an element that has CSS3 Transforms
作った後に見つけました。
javascript - How to get the MouseEvent coordinates for an element that has CSS3 Transform? - Stack Overflow
バイナリサーチで求めるという奇策が……
Coordinate Conversion Made Easy – the power of GeometryUtils ✩ Mozilla Hacks – the Web developer blog
Firefox33だけどまだー?(1033276 – Enable GeometryUtils APIs in release builds)