Text Link ラインマーカーの実装の解説

ラインマーカーの実装について受けた質問に対する回答を元にして作成した解説文です。

概要

ラインマーカーは、選択範囲の文字列をマーカーで強調したようにする拡張機能です。この機能を実装するためには、DOM2 Rangeを使用する必要があります。

文字列的処理の限界

GeckoやIEでは、window.getSelection()によって選択範囲を取得することができます。しかしながら、これと既存のテクニック(document.write()HTMLElement.innerHTMLなどを使った文字列的な処理)の組み合わせでラインマーカーと同様の処理を実現しようとすると、大変面倒なことになります。

以下に、起こりうる問題を列挙しました。

  • 選択範囲の文字列がドキュメント中に複数回登場する場合、選択した部分を正しく特定するのが困難である。
  • 選択範囲が複数のノードに跨っている場合(例えばこの行から次の段落の頭まで、とか)、要素の入れ子を考慮しながら適切な処理を行うのは、非常に困難である。

ラインマーカーでは、選択範囲を位置情報込みで扱い、且つDOM2 Rangeを使用することで、この問題を回避しています。というよりも、このような処理を実現するためには現在の所、以下に解説する方法を使うのが最も簡単だと思われます。参考文献一覧で紹介しているドキュメントを、まずは参照することをお勧めします。

なお、以下の例は全てGecko(Mozilla)でのものです。

実装の解説

contextMenuOverlay.jsで定義しているsetMarkerというメソッドが、この処理を担当しています。順を追って見ていきましょう。

選択範囲の取得

選択範囲の情報は、windoe.getSelection()で取得できます。このメソッドは選択範囲の文字列を取得するもの認識している人もいるかも知れませんが、それは実はこのメソッドの提供する機能の一つに過ぎません。

このメソッドの返り値はnsISelectionという型のオブジェクトで、選択範囲の文字列はこのオブジェクトのtoString()メソッドで取得できます(このオブジェクトを文字列値を要求するコンテキストで参照した場合、toString()メソッドが暗黙的に呼ばれるため、一見するとwindoe.getSelection()は選択範囲の文字列を返すだけのメソッドと見えてしまう)。

このオブジェクトのメソッドのうち、今回重要なのはgetRangeAt()メソッドです。このメソッドによって、選択範囲に相当するDOM2 Rangeのオブジェクトを取得することができます。

var range = window.getSelection().getRangeAt(0);

getRangeAt()メソッドの引数には、何番目の選択範囲を取得するかを数値で指定します。これは、Ctrl-クリックによって表のセルを複数選択できるというGeckoの仕様に基づく機能です。通常の文字列選択では0番目、つまり最初の選択範囲が処理対象となります。

次に、選択範囲をマーカー用の要素で囲う処理ですが、これは2つのパターンがあります。以下、「[>]~[<]」は選択範囲を意味します。

  • パターン1:単一の要素ノード内で完結した選択範囲
    <p>文字列[>]文字列[<]文字列</p>
  • パターン2:複数の要素ノードに跨る選択範囲
    <p>文字列文字列[>]文字列</p>
    <p>テキスト[<]テキストテキスト</p>

ラインマーカーでは、それぞれの場合で異なる処理を行っています。

単一の要素ノード内で完結した選択範囲の場合

選択範囲が要素間を跨いでいない場合は、DOM2 RangeのsurroundContents()メソッドが使えます。

var range = window.getSelection().getRangeAt(0);
var marker = document.createElement('span');
marker.setAttribute('style', 'background: red');
range.surroundContents(marker);

このようにすることで、以下のような処理結果が得られます。

<p>文字列<span style="...">文字列</span>文字列</p>

複数の要素ノードに跨る選択範囲の場合

要素間を跨いだ選択範囲では、パターン1の処理ではうまく動きません。そのため、選択範囲に含まれる複数のテキストノードそれぞれをマーカー用の要素でラッピングするようにしています。これは先のソースコードでは、mWrapUpTextsというメソッドで処理を定義しています。

まず、選択範囲の前後でテキストノードを切り分けます。

var tempRange = targetWindow.document.createRange();

// 選択範囲の始点でノードを切り分ける
tempRange.setStart(range.startContainer, range.startOffset);
tempRange.setEndAfter(range.startContainer);
// <p>文字列文字列[tempRange→]文字列[←tempRange]</p>
tempRange.insertNode(tempRange.extractContents());
// <p>文字列文字列[ここで分割された]文字列</p>

// 選択範囲の終点でノードを切り分ける
tempRange.setEnd(range.endContainer, range.endOffset);
tempRange.setStartBefore(range.endContainer);
// <p>[tempRange→]テキスト[←tempRange]テキストテキスト</p>
tempRange.insertNode(tempRange.extractContents());
// <p>テキスト[ここで分割された]テキストテキスト</p>

tempRange.detach();

次に、選択範囲の中に含まれる最初のノードからスタートして、選択範囲の最後のノードに達するまで、全てのノードを一つずつチェックしています。その際、テキストノードに遭遇したときはそれをマーカー用の要素でラッピングしていきます。

ソースコードはここでは省略しますが、具体的には、前述のような例では以下のような処理が行われます。以下、「[≫]~[≪]」は処理中のノードを示します。

  1. 見ているノードがテキストノードである
    <p>文字列文字列[>][≫]文字列[≪]</p>
    <p>テキスト[<]テキストテキスト</p>
  2. テキストノードをマーカー用の要素で包む
    <p>文字列[>]文字列[≫]<span style="...">文字列</span>[≪]</p>
    <p>テキスト[<]テキストテキスト</p>
  3. 次に見たノードは要素ノードである
    <p>文字列[>]文字列<span style="...">文字列</span></p>
    [≫]<p>テキスト[<]テキストテキスト</p>[≪]
  4. 無視して次を見る。次に見たノードはテキストノードである
    <p>文字列[>]文字列<span style="...">文字列</span></p>
    <p>[≫]テキスト[≪][<]テキストテキスト</p>
  5. テキストノードをマーカー用の要素で囲う
    <p>文字列[>]文字列<span style="...">文字列</span></p>
    <p>[≫]<span style="...">テキスト</span>[≪][<]テキストテキスト</p>