X-0031 選択範囲のリンクを収集する ~ DOM2 RangeのcompareBoundaryPointsの使い方

TBEの「選択範囲のリンクを全てタブで開く」などの機能で利用している「選択範囲内のリンクを収集する」処理についての解説です。DOM2 RangeのcompareBoundaryPointsの使い方の解説を含んでいますので、そこだけ見てもOKです。

処理の流れ

  1. ページ中の選択範囲を取得
  2. 選択範囲からRangeオブジェクトを取得
  3. それぞれのRangeについてリンクを収集

Mozillaでは、Ctrlキーを押しながらtableのセルをクリックすることで、連続していないセルを複数選択することができます。このような場合、一つの「選択範囲オブジェクト」から複数のRangeを取り出して処理しなくてはなりません。

Rangeの中に含まれるリンクを収集するにあたっては、どうやってRangeの中にあるリンクだけを収集するかが問題になります。また、部分選択されたリンクの扱いにも工夫が必要です。

これらの点についてそれぞれ解説します。

選択範囲の取得と、対応するRangeオブジェクトの取得

Mozillaでは、WindowオブジェクトのgetSelection()メソッドによって選択範囲オブジェクト(nsISelection)を取得することができます。

このオブジェクトにはrangeCountというプロパティとgetRangeAt()というメソッドがあり、これらを使ってDOM2 RangeのRangeオブジェクトを取得できます。以下に例を示します。

function getSelectionLinksInFrame(aWindow)
{
  var links = [];
  var selection = aWindow.getSelection();

  // 何も選択されていない場合は、何もしない。
  if (!selection || !selection.rangeCount) return links;

  const count = selection.rangeCount;
  var selectionRange;
  for (var i = 0; i < count; i++)
  {
    selectionRange = selection.getRangeAt(i);
    ...
  }

  return link;
}

ここから先の処理は、こうして取得した「それぞれのRange」について行うことになります。

なお、選択範囲オブジェクトは「始点が終点よりも後にある(=下から上へ向けて選択された)」という場合があり得ますが、DOM2 Rangeのオブジェクトについては常に「始点は終点よりも前」となります(仕様上、始点が終点より後になることはあり得ない)。よって、選択範囲オブジェクトとRangeオブジェクトとで始点と終点が入れ替わることもあります。

Rangeの中に含まれるリンクを収集する

その1:元のDOMツリーと切り離して処理する場合

「選択範囲のリンクのリンク先URLを配列として返す」などのような単純な処理の場合、元のDOMツリーとRangeとを切り離してしまうと、非常に楽に処理できます。

var documentFragment = selectionRange.cloneContents();
var elements = documentFragment.getElementsByTagName('*');
for (var i = 0; i < elements.length; i++)
  if (elements[i].href)
    links.push(elements[i]);

RangeのcloneContents()メソッドは、Rangeの内容を含んだ全く新しい文書片を生成します。よって、この中で「全ての要素」について処理を行えば、それは同時に「選択範囲に含まれる要素だけについての処理」にもなるわけです。また、部分選択されたノードは親要素も含めて「開始タグを補完する」ような形で文書片に組み込まれますので、「途中から」「途中まで」選択されたリンクについても問題なく取得できます。

なお、ここでは単純に、hrefというプロパティを持っていたらリンクと見なすことにしています。

その2:元のDOMツリーの中で処理する場合

「選択範囲のリンクを全て既訪問にする」などのような元のDOMツリーへの変更を伴う処理を必要とする場合、前述の方法は使えませんので、「DOMツリーの走査」「そのノードがRange内にあるかどうかの判断」などを自分で行わなくてはなりません。

DOMツリーの走査

DOM2 Rangeには「Rangeの先頭と末尾それぞれのノードの共通の親となっているノード」を返すcommonAncestorContainerというプロパティがありますが、これは今回は役に立ちません。これを使ってrange.commonAncestorContainer.getElementsByTagName('*')などと書いてしまったら、選択範囲外の要素ノードまで取得してしまうからです。ヘタをしたら、本来走査しなくてはならないノードの何十倍もの数のノードを取得してしまいかねません。ループ処理が低速なJavaScriptでは百害あって一利無しです。

というわけで、DOMツリーを地道に走査することを考えましょう。

var node = selectionRange.startContainer;

traceTree:
while (true)
{
  if (/* nodeがrangeの外にある場合、ループを抜ける */) {
    break;
  }
  else if (node.href)
    links.push(node);

  if (node.hasChildNodes()) { // 子要素がある場合、子要素の走査に移る
    node = node.firstChild;
  }
  else { // 子要素がない場合、次の要素に移る
    // 次の要素がない場合、親要素の次の要素に移る
    while (!node.nextSibling)
    {
      // 最後の要素に到達してしまったら、ループを抜ける
      node = node.parentNode;
      if (!node) break traceTree;
    }
    node = node.nextSibling;
  }
}

ここまでは簡単ですが、問題は、そのノードがRangeの中に含まれているかどうかをどうやって調べるかです。

そのノードがRangeの中にあるかどうかを調べる

DOM2 RangeのRangeオブジェクトは「そのノードがRangeの中にあるかどうか」を調べる機能はありませんが、その代わり、「二つのRangeの位置関係を比較する」機能を持っています。それがcompareBoundaryPoints()というメソッドです。これを使えばノードとRangeの位置関係を調べることができます。

しかしこのメソッド、引数の扱いも含めて使い方が非常にややこしいです。仕様書原文)の説明等を読んでも、「どっちの始点がどっちの終点よりも前にある」とか、頭の悪い僕にはなにがなにやらサッパリ理解できませんでした。図を使って解説した方がナンボかマシです。ということで……

var nodeRange = aWindow.document.createRange();
nodeRange.selectNode(node);

このようにして取得した「処理対象のノードだけを含むRange」であるnodeRangeと、元の「選択範囲のRange」であるselectionRangeとの位置関係を比較してみましょう。

まずは、nodeRangeselectionRangeよりも前の位置にある場合。

コード コードの意味 返り値 返り値の意味
nodeRangeからselectionRangeを見る場合
nodeRange.compareBoundaryPoints( Range.START_TO_START, selectionRange ) 二つのRangeの始点同士の位置を比較 -1 nodeRangeの始点は、selectionRangeの始点よりも前にある
nodeRange.compareBoundaryPoints( Range.START_TO_END, selectionRange ) selectionRangeの始点と、nodeRangeの終点を比較 -1 or 0 nodeRangeの終点は、selectionRangeの始点よりも前、または、同じ位置にある
nodeRange.compareBoundaryPoints( Range.END_TO_START, selectionRange ) selectionRangeの終点と、nodeRangeの始点を比較 -1 nodeRangeの始点は、selectionRangeの終点よりも前にある
nodeRange.compareBoundaryPoints( Range.END_TO_END, selectionRange ) 二つのRangeの終点同士の位置を比較 -1 nodeRangeの終点は、selectionRangeの終点よりも前にある
selectionRangeからnodeRangeを見る場合
selectionRange.compareBoundaryPoints( Range.START_TO_START, nodeRange ) 二つのRangeの始点同士の位置を比較 1 selectionRangeの始点は、nodeRangeの始点よりも後にある
selectionRange.compareBoundaryPoints( Range.START_TO_END, nodeRange ) nodeRangeの始点と、selectionRangeの終点を比較 1 selectionRangeの終点は、nodeRangeの始点よりも後にある
selectionRange.compareBoundaryPoints( Range.END_TO_START, nodeRange ) nodeRangeの終点と、selectionRangeの始点を比較 1 or 0 selectionRangeの始点は、nodeRangeの終点よりも後、または、同じ位置にある
selectionRange.compareBoundaryPoints( Range.END_TO_END, nodeRange ) 二つのRangeの終点同士の位置を比較 1 selectionRangeの終点は、nodeRangeの終点よりも後にある

次に、nodeRangeselectionRangeに含まれている場合。

コード コードの意味 返り値 返り値の意味
nodeRangeからselectionRangeを見る場合
nodeRange.compareBoundaryPoints( Range.START_TO_START, selectionRange ) 二つのRangeの始点同士の位置を比較 1 or 0 nodeRangeの始点は、selectionRangeの始点よりも後、または、同じ位置にある
nodeRange.compareBoundaryPoints( Range.START_TO_END, selectionRange ) selectionRangeの始点と、nodeRangeの終点を比較 1 nodeRangeの終点は、selectionRangeの始点よりも後にある
nodeRange.compareBoundaryPoints( Range.END_TO_START, selectionRange ) selectionRangeの終点と、nodeRangeの始点を比較 -1 nodeRangeの始点は、selectionRangeの終点よりも前にある
nodeRange.compareBoundaryPoints( Range.END_TO_END, selectionRange ) 二つのRangeの終点同士の位置を比較 -1 or 0 nodeRangeの終点は、selectionRangeの終点よりも前、または、同じ位置にある
selectionRangeからnodeRangeを見る場合
selectionRange.compareBoundaryPoints( Range.START_TO_START, nodeRange ) 二つのRangeの始点同士の位置を比較 -1 or 0 selectionRangeの始点は、nodeRangeの始点よりも前、または、同じ位置にある
selectionRange.compareBoundaryPoints( Range.START_TO_END, nodeRange ) nodeRangeの始点と、selectionRangeの終点を比較 1 selectionRangeの終点は、nodeRangeの始点よりも後にある
selectionRange.compareBoundaryPoints( Range.END_TO_START, nodeRange ) nodeRangeの終点と、selectionRangeの始点を比較 -1 selectionRangeの始点は、nodeRangeの終点よりも前にある
selectionRange.compareBoundaryPoints( Range.END_TO_END, nodeRange ) 二つのRangeの終点同士の位置を比較 1 or 0 selectionRangeの終点は、nodeRangeの終点よりも後、または、同じ位置にある

最後に、nodeRangeselectionRangeよりも後の位置にある場合。

コード コードの意味 返り値 返り値の意味
nodeRangeからselectionRangeを見る場合
nodeRange.compareBoundaryPoints( Range.START_TO_START, selectionRange ) 二つのRangeの始点同士の位置を比較 1 nodeRangeの始点は、selectionRangeの始点よりも後にある
nodeRange.compareBoundaryPoints( Range.START_TO_END, selectionRange ) selectionRangeの始点と、nodeRangeの終点を比較 1 nodeRangeの終点は、selectionRangeの始点よりも後にある
nodeRange.compareBoundaryPoints( Range.END_TO_START, selectionRange ) selectionRangeの終点と、nodeRangeの始点を比較 1 or 0 nodeRangeの始点は、selectionRangeの終点よりも後、または、同じ位置にある
nodeRange.compareBoundaryPoints( Range.END_TO_END, selectionRange ) 二つのRangeの終点同士の位置を比較 1 nodeRangeの終点は、selectionRangeの終点よりも後にある
selectionRangeからnodeRangeを見る場合
selectionRange.compareBoundaryPoints( Range.START_TO_START, nodeRange ) 二つのRangeの始点同士の位置を比較 -1 selectionRangeの始点は、nodeRangeの始点よりも前にある
selectionRange.compareBoundaryPoints( Range.START_TO_END, nodeRange ) nodeRangeの始点と、selectionRangeの終点を比較 -1 or 0 selectionRangeの終点は、nodeRangeの始点よりも前、または、同じ位置にある
selectionRange.compareBoundaryPoints( Range.END_TO_START, nodeRange ) nodeRangeの終点と、selectionRangeの始点を比較 -1 selectionRangeの始点は、nodeRangeの終点よりも前にある
selectionRange.compareBoundaryPoints( Range.END_TO_END, nodeRange ) 二つのRangeの終点同士の位置を比較 -1 selectionRangeの終点は、nodeRangeの終点よりも前にある

実際の判別

前述の表に従うと、「nodeRangeがselectionRangeの中にあるかどうか」は以下のようにすれば判別できることになります。

var node      = selectionRange.startContainer;
var nodeRange = aWindow.document.createRange();

traceTree:
while (true)
{
  // nodeRangeの終点がselectionRangeの中にある場合
  if (nodeRange.compareBoundaryPoints(Range.START_TO_END, selectionRange) > -1) {
    // nodeRangeの始点がselectionRangeの外にある場合(nodeRangeがselectionRangeよりも後にある)
    if (nodeRange.compareBoundaryPoints(Range.END_TO_START, selectionRange) > 0) {
      // 選択範囲の走査を終えたので、ループを抜ける
      break;
    }
    else if (node.href)
      links.push(node);
  }

(略)

まとめ

以上で、「選択範囲のリンクを収集する」処理はできあがりです。ここまでのまとめを以下に示しました。改変も二次利用も一切制限しませんので、好きに使ってください。

function getSelectionLinksInFrame(aWindow)
{
  var links = [];
  var selection = aWindow.getSelection();

  if (!selection || !selection.rangeCount) return links;

  const count = selection.rangeCount;
  var range,
      node,
      link,
      nodeRange = aWindow.document.createRange();
  for (var i = 0; i < count; i++)
  {
    selectionRange = selection.getRangeAt(i);
    node           = selectionRange.startContainer;

    traceTree:
    while (true)
    {
      nodeRange.selectNode(node);

      if (nodeRange.compareBoundaryPoints(Range.START_TO_END, selectionRange) > -1) {
        if (nodeRange.compareBoundaryPoints(Range.END_TO_START, selectionRange) > 0) {

          if (
            links.length &&
            selectionRange.startContent.nodeType != Node.ELEMENT_NODE &&
            selectionRange.startOffset == selectionRange.startContainer.nodeValue.length &&
            links[0] == getParentLink(selectionRange.startContainer)
            )
            links.splice(0, 1);

          if (
            links.length &&
            selectionRange.endContainer.nodeType != Node.ELEMENT_NODE &&
            selectionRange.endOffset == 0 &&
            links[links.length-1] == getParentLink(selectionRange.endContainer)
            )
            links.splice(links.length-1, 1);

          break;
        }
        else if (link = getParentLink(node))
          links.push(link);
      }

      if (node.hasChildNodes() && !link) {
        node = node.firstChild;
      }
      else {
        while (!node.nextSibling)
        {
          node = node.parentNode;
          if (!node) break traceTree;
        }
        node = node.nextSibling;
      }
    }
  }

  nodeRange.detach();

  return links;
}

function getParentLink(aNode)
{
  var node = aNode;
  while (!node.href && node.parentNode)
    node = node.parentNode;

  return node.href ? node : null ;
}