選択範囲のリンクを取得する2008年10月18日 20時05分

Web ページの選択範囲に含まれるリンクを取得する方法として、Piro さんによる DOM 2 Range の compareBoundaryPoints メソッドを使ったやり方があります。これはリンクを探すのに DOM Core の機能を使って文書ツリーをたどっていますが、今現在ノードを探すといわれて真っ先に思いつくのは XPath でしょう。そこで、XPath を使って選択範囲のリンクを取得する方法を考えてみました。もちろん、選択範囲を扱う以上 DOM 2 Range も利用します。

  1. 基本的な考え方
  2. Range オブジェクトの取得
  3. 選択範囲の始点より前にあるリンクの数の取得
  4. 選択範囲の終点より前にあるリンクの取得
  5. 選択されているようには見えないリンクの除外
  6. まとめ

基本的なアイデアは、選択範囲の終点より前にあるリンクで、選択範囲の始点より前にはないものが求めるリンクというものです。たとえば次に示した状態で、link1 と link2 はいずれも選択範囲終点より前にありますが、link1 は選択範囲始点よりも前にあるので求める結果には入りません。

<p>
  <a id="link1" href="...">link1</a>
  【選択範囲始点】<a id="link2" href="...">link2</a>【選択範囲終点】
</p>

また、リンク同士は入れ子にならない (a 要素を入れ子にしてはいけない) というのも重要です。もしもこれが入れ子を許す場合はどうなるでしょうか。たとえば、選択範囲に含まれる (選択範囲を一部でも含む) div 要素の取得を考えてみます。

<div id="div1">
  <div id="div2">...</div>
  【選択範囲始点】<div id="div3">...</div>【選択範囲終点】
</div>

上の状態で、求める div 要素は div1 と div3 となり、文書順で見た場合要素が飛び飛びになっています。入れ子にならないのならこのようなことは起こらず、求める結果は文書順に並んだ対象要素の列中で連続した集合となります。つまり、選択範囲始点より前にあるリンクを具体的に求める必要はなく、その総数がわかればいいのです。

では実際にソースコードを書いていきましょう。ここでは選択範囲に含まれるリンクを配列に収めて返すことにします。まずは選択範囲に対応する Range オブジェクトを取得します。

var links = [];
var selection = getSelection();
if (!selection.rangeCount) return links;
var range = selection.getRangeAt(0);
if (range.collapsed) return links;

links が結果となる配列です。選択範囲が存在しなかったり、選択範囲の幅が 0 だったりした場合には空の配列を返します。

var startNode = range.startContainer;
var startExpr = 'count(preceding::*[@href])';
if (startNode.nodeType == Node.ELEMENT_NODE) {
  if (range.startOffset < startNode.childNodes.length)
    startNode = startNode.childNodes[range.startOffset];
  else
    startExpr += ' + count(descendant::*[@href])';
}
var startResult = document.evaluate(startExpr, startNode, null,
                    XPathResult.NUMBER_TYPE, null);

いよいよ XPath を使う場面です。preceding 軸は、コンテキストノードの祖先ノードを除く、コンテキストノードより文書順で前にあるノードを選択します。選択範囲始点に位置するノードをコンテキストノードとすることで、選択範囲より前にあるリンクの数を得られます。ただし、ある要素の終了タグ直前に始点がある場合 (else 節に入るパターン) は、その要素の子孫ノードからもリンクを探します。なお、ここでは href 属性を持つ要素なら何でもリンクとして扱います。

var endNode = range.endContainer;
var endExpr = 'ancestor::*[@href] | preceding::*[@href]';
if (endNode.nodeType == Node.ELEMENT_NODE) {
  if (range.endOffset < endNode.childNodes.length)
    endNode = endNode.childNodes[range.endOffset];
  else
    endExpr += ' | descendant-or-self::*[@href]';
}
var endResult = document.evaluate(endExpr, endNode, null,
                  XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);

大体の流れは始点より前にあるリンク数の取得と同じですが、数ではなく文書順に並んだノード集合を取得します。縦線 "|" を使ってノードセットの和集合を取っています。

選択範囲の始点がリンクの終了タグ直前にある、あるいは終点がリンクの開始タグ直後にある場合、そのリンクは選択されていないように見えます。たとえば次の状態で、二つのリンクはともに機械的には選択範囲に含まれると判断されますが、人の目から見れば選択されているようには見えません。

<a href="...">link1【選択範囲始点】</a>
...
<a href="...">【選択範囲終点】link2</a>

このようなリンクを除外し、人の目から見ても選択されているリンクのみを返すコードが次になります。

var i = startResult.numberValue;
var length = endResult.snapshotLength;
var contentRange = document.createRange();
if (i < length) {
  var lastNode = endResult.snapshotItem(i);
  while (lastNode.lastChild)
    lastNode = lastNode.lastChild;
  contentRange.selectNodeContents(lastNode);
  contentRange.setStart(range.startContainer, range.startOffset);
  if (contentRange.collapsed) i++;
}
if (i < length) {
  var firstNode = endResult.snapshotItem(length - 1);
  while (firstNode.firstChild)
    firstNode = firstNode.firstChild;
  contentRange.selectNodeContents(firstNode);
  contentRange.setEnd(range.endContainer, range.endOffset);
  if (contentRange.collapsed) length--;
}
for (; i < length; i++)
  links.push(endResult.snapshotItem(i));
return links;

今、次のような状態であると考えましょう。

<a href="..."><em>foo</em>【選択範囲始点】</a>

ここで、最初の if 文内における lastNode はテキストノード "foo" となり、selectNodeContents メソッド呼び出し直後の contentRange の境界点は次のようになります。

<a href="..."><em>【contentRange 始点】foo【contentRange 終点】</em>【選択範囲始点】</a>

こうなれば後は contentRange の終点と選択範囲の始点との位置を比較するだけです。contentRange 終点が選択範囲始点と同位置かそれより前にあれば、選択されているようには見えないリンクであると判断できます。通常、境界点の位置の比較には compareBoundaryPoints メソッドを使いますが、Safari 3.1 以下ではこのメソッドの引数に渡す定数 Range.START_TO_END と Range.END_TO_START の意味が逆転しているというバグがあるので、単純にこのメソッドを使ってもうまくいきません。

そこで、ここでは contentRange の始点を選択範囲の始点と同位置に設定しています。Range オブジェクトは終点の後に始点が来ることはないと保証されているため、終点より後に始点を設定しようとすると終点も始点と同位置に再設定されるのです。よって、contentRange の終点が選択範囲の始点より前にあった場合、conentRange の始点と終点が一致することになります。後は collapsed プロパティを見て、contentRange が折りたたまれている、すなわち始点と終点が同位置にあれば、選択されているようには見えないリンクと判断できるのです。

選択範囲の終点に一番近いリンクも、同様にして選択されているようには見えないリンクかどうか判断できます。これで選択範囲終点より前にあるリンクのうち、どの部分を抜き出せばいいのかわかったので、それを配列に収めて返してやります。

以上をひとつの関数にまとめると次のようになります。

function getLinksInFirstSelection() {
  var links = [];
  var selection = getSelection();
  if (!selection.rangeCount) return links;
  var range = selection.getRangeAt(0);
  if (range.collapsed) return links;

  var startNode = range.startContainer;
  var startExpr = 'count(preceding::*[@href])';
  if (startNode.nodeType == Node.ELEMENT_NODE) {
    if (range.startOffset < startNode.childNodes.length)
      startNode = startNode.childNodes[range.startOffset];
    else
      startExpr += ' + count(descendant::*[@href])';
  }
  var startResult = document.evaluate(startExpr, startNode, null,
                      XPathResult.NUMBER_TYPE, null);

  var endNode = range.endContainer;
  var endExpr = 'ancestor::*[@href] | preceding::*[@href]';
  if (endNode.nodeType == Node.ELEMENT_NODE) {
    if (range.endOffset < endNode.childNodes.length)
      endNode = endNode.childNodes[range.endOffset];
    else
      endExpr += ' | descendant-or-self::*[@href]';
  }
  var endResult = document.evaluate(endExpr, endNode, null,
                    XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);

  var i = startResult.numberValue;
  var length = endResult.snapshotLength;
  var contentRange = document.createRange();
  if (i < length) {
    var lastNode = endResult.snapshotItem(i);
    while (lastNode.lastChild)
      lastNode = lastNode.lastChild;
    contentRange.selectNodeContents(lastNode);
    contentRange.setStart(range.startContainer, range.startOffset);
    if (contentRange.collapsed) i++;
  }
  if (i < length) {
    var firstNode = endResult.snapshotItem(length - 1);
    while (firstNode.firstChild)
      firstNode = firstNode.firstChild;
    contentRange.selectNodeContents(firstNode);
    contentRange.setEnd(range.endContainer, range.endOffset);
    if (contentRange.collapsed) length--;
  }
  for (; i < length; i++)
    links.push(endResult.snapshotItem(i));
  return links;
}

これは Piro さんの作ったものに比べ、選択範囲の始点が子要素を持つリンク内にあった場合も適切な結果を返す、Safari 3.1 以下でも期待通り動くといった利点があります。また、選択範囲のリンクを取得するサンプルで試してみると、実行速度もこちらのほうが速いようです。

なお、これはもともと WEB+DB PRESS Vol. 47 の連載「JavaScript + ブラウザ探検」第 4 回「DOM 2 Style & DOM 2 Range」にサンプルコードとして掲載するつもりだったのを、分量の都合で連載記事から削ったものです。Range オブジェクトの基本的な操作と使い方の例については 10 月 24 日発売の WEB+DB PRESS Vol. 47 をご覧ください。DOM 2 Range、DOM 3 XPath を実装していない IE でこのコードは動きませんが、IE が実装している TextRange オブジェクトについての解説もあります。