TBEの「選択範囲のリンクを全てタブで開く」などの機能で利用している「選択範囲内のリンクを収集する」処理についての解説です。DOM2 RangeのcompareBoundaryPointsの使い方の解説を含んでいますので、そこだけ見てもOKです。
Mozillaでは、Ctrlキーを押しながらtableのセルをクリックすることで、連続していないセルを複数選択することができます。このような場合、一つの「選択範囲オブジェクト」から複数の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オブジェクトとで始点と終点が入れ替わることもあります。
「選択範囲のリンクのリンク先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というプロパティを持っていたらリンクと見なすことにしています。
「選択範囲のリンクを全て既訪問にする」などのような元のDOMツリーへの変更を伴う処理を必要とする場合、前述の方法は使えませんので、「DOMツリーの走査」「そのノードがRange内にあるかどうかの判断」などを自分で行わなくてはなりません。
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の中に含まれているかどうかをどうやって調べるかです。
DOM2 RangeのRangeオブジェクトは「そのノードがRangeの中にあるかどうか」を調べる機能はありませんが、その代わり、「二つのRangeの位置関係を比較する」機能を持っています。それがcompareBoundaryPoints()
というメソッドです。これを使えばノードとRangeの位置関係を調べることができます。
しかしこのメソッド、引数の扱いも含めて使い方が非常にややこしいです。仕様書(原文)の説明等を読んでも、「どっちの始点がどっちの終点よりも前にある」とか、頭の悪い僕にはなにがなにやらサッパリ理解できませんでした。図を使って解説した方がナンボかマシです。ということで……
var nodeRange = aWindow.document.createRange();
nodeRange.selectNode(node);
このようにして取得した「処理対象のノードだけを含むRange」であるnodeRange
と、元の「選択範囲のRange」であるselectionRange
との位置関係を比較してみましょう。
まずは、nodeRange
がselectionRange
よりも前の位置にある場合。
コード | コードの意味 | 返り値 | 返り値の意味 |
---|---|---|---|
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 の終点よりも後にある |
次に、nodeRange
がselectionRange
に含まれている場合。
コード | コードの意味 | 返り値 | 返り値の意味 |
---|---|---|---|
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 の終点よりも後、または、同じ位置にある |
最後に、nodeRange
がselectionRange
よりも後の位置にある場合。
コード | コードの意味 | 返り値 | 返り値の意味 |
---|---|---|---|
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);
}
(略)
さて。以下のような場合、最初と最後のリンクはどのように扱うべきでしょうか?
<ul>
<li><a href="01.html">最初の【ここから選択→】リンク</a></li>
<li><a href="02.html">真ん中のリンク</a></li>
<li><a href="03.html">【←ここまで選択】最後のリンク</a></li>
</ul>
最初のリンクは、途中からとはいえ選択されていますから、「選択されている」と見なしてよいでしょう。しかし前述までの処理では、これは「選択範囲外のリンク」として扱われてしまいます。
最後のリンクは、一応選択範囲に含まれてはいますが、内容のテキストは一文字も選択されていません。これは行選択をした時などによく起こる現象です(同様の現象が、選択範囲の先頭でも起こり得ます)。前述までの処理では、この「選択されているようには見えない」リンクまでもが「選択範囲内のリンク」として扱われてしまいます。
ということで、これらに対する例外処理を行わなくてはなりません。
リンクの要素ノードそのものは選択範囲外と見なされていても、リンク内のテキストノードは選択範囲内にあると認識されているので、これを利用しましょう。つまり、走査しているノードそのものだけでなく、ノードの祖先要素にリンクがあればそれも収集するということです。
function getParentLink(aNode)
{
var node = aNode;
while (!node.href && node.parentNode)
node = node.parentNode;
return node.href ? node : null ;
}
var link;
var node = selectionRange.startContainer;
var nodeRange = aWindow.document.createRange();
traceTree:
while (true)
{
if (nodeRange.compareBoundaryPoints(Range.START_TO_END, selectionRange) > -1) {
if (nodeRange.compareBoundaryPoints(Range.END_TO_START, selectionRange) > 0) {
break;
}
else if (link = getParentLink(node))
links.push(link);
}
// 既にリンクとして処理した場合は、子要素の走査をスキップする
if (node.hasChildNodes() && !link) {
node = node.firstChild;
}
else {
(略)
テキストノードの、選択範囲に含まれている部分の長さが0であるかどうかを調べれば、そのリンクが「選択されているようには見えない」かどうか知ることができます。これには、RangeのstartOffset
とendOffset
というプロパティを利用します。
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;
}(略)
以上で、「選択範囲のリンクを収集する」処理はできあがりです。ここまでのまとめを以下に示しました。改変も二次利用も一切制限しませんので、好きに使ってください。
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 ;
}