選択範囲の取得について調べた

はてなスターTumblrブックマークレットなど,ブラウザ上の選択文字列をそのままユーザの入力として使えるサイトが増えています。JavaScript からどのようにすれば取得できるのかを調べてみました。

ブラウザ間の差異

DOM における選択範囲の仕様として以下の 2 通りがあります。

IEMicrosoft TextRange のみサポートしているのはまぁ予想通り。注意しなくてはいけないのは,W3C Range は,あくまで DOM 上の「範囲」を示すためのインタフェースであることです。ブラウザ上でユーザがどこを選択しているのか,などは UI の実装になりますから,W3C (Range) では規定されていません*1。そこで W3C Range をサポートしているブラウザは,選択範囲をあらわす W3C Range オブジェクトを取得するためのインタフェースとして Mozilla Selection オブジェクトを(部分的でも)サポートしています。

難しい表現になってしまいましたが W3C Range というのはあくまで「範囲」を示すためのもの*2,「選択範囲」を取得するインタフェースとして Mozilla Selection インタフェースがある,ということをおさえておいてください。同様に Microsoft TextRange も「範囲」を示すだけのものですが,「選択範囲」の取得インタフェースにブラウザ間で違いがあるわけでもないのであまり気にする必要はありません。

で,各ブラウザでのサポート状況は下記のようになります。

  IE 6 / 7 Firefox 2*3 Safari 1.3 Safari 2 Opera 9*4
W3C Range ×
Mozilla Selection ×
MS TextRange × × ×

△は完全にはインプリメントされていません。各プロパティ・メソッドについて詳述すると以下のようになります。

  IE 6 / 7 Firefox 2 Safari 1.3 Safari 2 Opera 9
window.getSelection() ×
document.getSelection()((getSelection がグローバルネームスペースを汚染していることを解決しよう意識がある,ということなのでしょうか。Safari でサポートされていないのが痛いですね。)) × × ×
document.createRange() ×
Selection.toString() ×
Selection.getRangeAt() × ×
Selection.deleteFromDocument() × × ×
document.selection × × ×
d.selection.createRange() × × ×
d.selection.clear() × × × ×
TextRange.text × × ×
TextRange.htmlText × × × ×

Safari 1.3 の場合,Selection オブジェクトに getRangeAt() というメソッドが実装されていないので,後述しますが自力で Range オブジェクトを構築する必要があるそうです(手元に環境がないので不明)。

また,Opera は一応 Internet Explorer 互換をめざしているように見えますが,ところどころ実装されていない部分があるのでより完璧にサポートされている W3C 系 Range オブジェクトを使ったほうがよいでしょう。

選択したテキストを取得する

さて各論に入ります。まずは一番簡単な,選択範囲の文字列の取得です。

<script type="text/javascript">

var getSelectedText
    = function () {
        if (window.getSelection)
            return '' + window.getSelection();
        else if (document.selection)
            return document.selection.createRange().text;
        else
            alert('user selection is not supported');
    };

window.onload = function () {
    document.getElementById('btn').onclick
        = function () {
            var e = document.getElementById('txt_output');
            if (e.innerText != null)
                e.innerText = getSelectedText();
            else
                e.innerHTML = getSelectedText();
        };
};

</script>

<p>blah, blah, blah, ...</p>
<ol>
    <li>foo</li>
    <li>bar</li>
    <li>baz</li>
</ol>

<p><input type="button" id="btn" value="get selection"></p>

<textarea id="txt_output"></textarea>

Mozilla Selection オブジェクトは window.getSelection() メソッドにより取得することができます。これは Object なのですが,toString() メソッドが定義されており,選択範囲の(タグ等含まない)文字列を返します。なので stringify すれば選択範囲を文字列として取得することができます。

いっぽう MS ですが,document.selection プロパティに MS selection オブジェクトが格納されています。このオブジェクトで createRange() メソッドを呼ぶと MS TextRange オブジェクトを返すので,こいつの text プロパティを参照すると選択された文字列を取得*5することができます。

補足

MS TextRange において createRange() されたオブジェクトの text プロパティをダイレクトに呼び出していますが,実は document.selection オブジェクトの type プロパティが "control" の場合,TextRange オブジェクトではなく controlRange コレクションを返します。なので本来この type プロパティをチェックするのが筋(と思われるの)ですが,あくまで技術的サンプルであるのと面倒なので,チェックせずにつきすすんでいます。

Share on Tumblr を解体してみる

ここまで勉強した上で,冒頭に挙げた Tumblr のブックマークレットを読んでみます。

var d=document,w=window,e=w.getSelection,k=d.getSelection,
x=d.selection,s=(e?e():(k)?k():(x?x.createRange().text:0)),
f='http://www.tumblr.com/share',l=d.location,e=encodeURIComponent,
p='?v=3&u='+e(l.href) +'&t='+e(d.title) +'&s='+e(s),u=f+p;
try{if(!/^(.*\.)?tumblr[^.]*$/.test(l.host))throw(0);tstbklt();}
catch(z){a =function(){if(!w.open(u,'t','toolbar=0,resizable=0,
status=1,width=450,height=430'))l.href=u;};
if(/Firefox/.test(navigator.userAgent))setTimeout(a,0);else a();}
void(0)

手で圧縮したのがありありとわかりますが,これをわかりやすく書き換えてみました。

var selection
    = window.getSelection   ? window.getSelection()
    : document.getSelection ? document.getSelection()
    : document.selection    ? document.selection.createRange().text
    :                         0
    ;

var uri
    = 'http://www.tumblr.com/share';

var params
    = '?v=3&u='
        + encodeURIComponent(document.location.href)
    + '&t='
        + encodeURIComponent(document.title)
    + '&s='
        + encodeURIComponent(selection)
    ;

var query = uri + params;

try {
    if (! /^(.*\.)?tumblr[^.]*$/.test(document.location.host))
        throw(0);

    tstbklt();
}
catch (e) {
    do_query
        = function () {
            if (! window.open(query, 't', 'toolbar=0, ...'))
                location.href = query;
        };

    if (/Firefox/.test(navigator.userAgent))
        setTimeout(do_query, 0);
    else
        do_query();
}

void(0)

だいたい理解できますね。

元ページのホストに tumblr が含まれている場合に trycatch で囲んで tstbklt() メソッドを呼んでいるのは,おそらく Tumblr のサイトで他に読み込まれた JavaScript で定義されており,特別な処理を行っているのでしょう。例外処理でかこっているのは tstbklt() が定義されてないページがあったり失敗した場合に備えてだと思います。

Firefox の場合に setTimeout() 経由で呼び出しているのはよくわかりませんでした。誰か教えて。

選択したノードを削除する

<script type="text/javascript">

var deleteSelectedNodes
    = function () {
        if (window.getSelection) {
            var sel = window.getSelection();

            var r;
            if (sel.getRangeAt)
                r = sel.getRangeAt(0);
            else {  /* for Safari 1.3 */
                r = document.createRange();
                r.setStart(sel.anchorNode, sel.anchorOffset);
                r.setEnd(sel.focusNode, sel.focusOffset);
            }

            r.deleteContents();
        }
        else if (document.selection) {
            if (document.selection.clear)
                document.selection.clear();
            else
                document.selection.createRange().text = '';
        }
        else
            alert('user selection is not supported');
    };

window.onload = function () {
    document.getElementById('btn').onclick
        = function () {
            deleteSelectedNodes();
        };
};

</script>

<p>blah, blah, blah, ...</p>
<ol>
    <li>foo</li>
    <li>bar</li>
    <li>baz</li>
</ol>

<p><input type="button" id="btn" value="delete selected nodes"></p>

最初 Mozilla Selection 版では deleteFromDocument() メソッド*6が存在する場合にはそれを呼ぶようにしていたのですが,Opera では deleteFromDocument() が存在するくせに削除はしてくれない,というイヤな挙動を示したのではずしました。また,Safari 1.3 では getRangeAt() メソッドがないので,document.createRange() で W3C Range オブジェクトを作成し自力で調整しています。

MS 版では document.selectionclear() メソッドがある場合それを呼び出すようにしていますが,Opera にはこのメソッドがありません。createRange() 経由で MS TextRange を取得してもさして手間ではないので clear() 部分は実際には必要ないと思います。

しかし(このままのコードで何も考えずに)削除すると,表示されている任意のコンテンツを削除することができる*7ので適用範囲がないかなぁと思います。

補足

Mozilla Selection ですが,getRangeAt() メソッドの引数 0 からもわかる通り,仕様としては複数の選択範囲が存在することが想定されているようです(Range オブジェクトの数は rangeCount プロパティで取得できます)。ですが Ctrl キーとか使っても複数選択はできなさそうだったし面倒(ry

選択したノードをコピーする

<script type="text/javascript">

var cloneSelectedNodesTo
    = function (e) {
        if (window.getSelection) {
            var sel = window.getSelection();

            var r;
            if (sel.getRangeAt)
                r = sel.getRangeAt(0);
            else {  /* for Safari 1.3 */
                r = document.createRange();
                r.setStart(sel.anchorNode, sel.anchorOffset);
                r.setEnd(sel.focusNode, sel.focusOffset);
            }

            e.innerHTML = '';
            e.appendChild(r.cloneContents());
        }
        else if (document.selection) {
            e.innerHTML = document.selection.createRange().htmlText;
        }
        else
            alert('user selection is not supported');
    };

window.onload = function () {
    document.getElementById('btn').onclick
        = function () {
            cloneSelectedNodesTo(document.getElementById('target'));
        };
};

</script>

<p>blah, blah, blah, ...</p>
<ol>
    <li>foo</li>
    <li>bar</li>
    <li>baz</li>
</ol>

<p><input type="button" id="btn" value="clone selected nodes"></p>

<div id="target"></div>

W3C Range 版では Range オブジェクトの cloneContents() メソッドを呼ぶと DocumentFragment が取得できるのでそれを appendChild() 等することができます。

MS 版ではぱっとみ DOM ツリーを取得するプロパティ・メソッドが見当たらなかったので,選択範囲の HTML を返す htmlText プロパティを他の要素の innerHTML プロパティに代入しています。

選択範囲の HTML を取得する

<script type="text/javascript">

var getSelectedHTML
    = function () {
        if (window.getSelection) {
            var sel = window.getSelection();

            var r;
            if (sel.getRangeAt)
                r = sel.getRangeAt(0);
            else {  /* for Safari 1.3 */
                r = document.createRange();
                r.setStart(sel.anchorNode, sel.anchorOffset);
                r.setEnd(sel.focusNode, sel.focusOffset);
            }

            e = document.getElementById('dummy');
            e.innerHTML = '';
            e.appendChild(r.cloneContents());
            
            return e.innerHTML;
        }
        else if (document.selection)
            return document.selection.createRange().htmlText;
        else
            alert('user selection is not supported');
    };

window.onload = function () {
    document.getElementById('btn').onclick
        = function () {
            var e = document.getElementById('txt_output');
            if (e.innerText != null)
                e.innerText = getSelectedHTML();
            else
                e.innerHTML = getSelectedHTML();
        };
};
</script>

<p>blah, blah, blah, ...</p>
<ol>
    <li>foo</li>
    <li>bar</li>
    <li>baz</li>
</ol>

<p><input type="button" id="btn" value="get selection as html"></p>

<textarea id="txt_output"></textarea>

<div id="dummy"></div>

MS 版では先ほど利用したように htmlText プロパティに HTML が入っています。

W3C Range 版では逆に該当するプロパティが見当たらなかったので,ダミー要素の child に代入してそれの innerHTML を取得しています。もっといい方法があったら教えてください。

感想

W3C 版も MS 版もそれぞれ良いところ足りないところがありますね。

しかしブックマークレット等で使おうと思っても選択範囲が膨大だと URL が長くなってしまってブラウザやサーバがうけつけなかったりしそう。テキストならともかく HTML だとなおさら。さりとてローカルサイトで利用しようにも用途が思いつきません*8

参考にしたサイト

*1:このへんニワカなので正確な情報かどうか自信ありません。ご指摘求む。

*2:ただもちろんこの「範囲」を削除したり置き換えたり,などの manipulation は W3C Range で規定されています。

*3:Firefox 2.0.0.12 で確認

*4:Opera 9.24 で確認

*5:後述しますが取得だけでなく設定することもできます

*6:おそらく NN4 時代からのインタフェースだと思います。未検証。

*7:念のため書いとくと,W3C Range 版でも MS 版でも選択範囲の親ノードを取得することができるので,削除可能な範囲を限定することはできます

*8:ネタりかのマーカー機能で利用されてるんじゃないかな。個人的には大嫌いな機能ですが。