2022年1月20日

JavaScript 選択範囲がノードを含んでいるかチェックする


選択範囲の位置を比較したり移動したりする方法をメモメモ…



ユーザーが選択した文字列や要素に対する処理を書く機会があったので、学習したことを簡単にメモしておきます。まずは次のデモを試してみてください。テキストの選択範囲に応じて情報が表示されます。

オツベルはケースを握ったまま、もうくしゃくしゃに潰(つぶ)れていた。早くも門があいていて、グララアガアグララアガア、象がどしどしなだれ込む。
選択文字列:
要素1:
要素2:

選択範囲を取得する

getSelection() と getRangeAt()


ユーザーの選択範囲を取得するのは簡単です。Window.getSelection() あるいは Document.getSelection() を使えば、選択範囲を抜き出して Selection オブジェクトとして取得できます。さらに getRangeAt() を使って Range オブジェクトに変換しておくと、位置関係の比較や操作がしやすくなります。

// 選択範囲を取得する
const selection = window.getSelection();
const selectionRange = selection.getRangeAt(0);

「あれ?ユーザーの選択範囲って1つしかないのに getRangeAt(0) みたいにインデックス指定する必要あるの?」と疑問が浮かぶかもしれませんが、たとえば Firefox なんかは Ctrl を押しながら操作すると複数選択が可能になっているので、どの選択範囲かを指定する必要があるのです。

選択範囲の有無


ただし、このままだと何も選択されていない場合にエラーを吐く可能性があるため、選択範囲の有無を確認しておいたほうがいいかもしれません。"Failed to execute 'getRangeAt' on 'Selection': 0 is not a valid index." というエラーはこれで防げます。

// getRangeAt(0) を使う前に選択範囲が存在するかどうかチェックする
if(!selection || selection.rangeCount < 1) return;

それから、選択範囲の始点と終点が重なっている状態、つまり「選択範囲は存在するが実質何も選択していない状態」を検知するには Range オブジェクトの collapsed プロパティが便利です。

// Range オブジェクトの始点と終点が同じ場合もチェックする
if(selectionRange.collapsed) return;

選択範囲内の文字列


選択テキストを取得するには、選択範囲に .toString() をつけるだけでOK。Selection オブジェクトでも Range オブジェクトでもどちらからでも取得できます。

// 選択されたテキストを出力する
const selection = window.getSelection();
console.log(selection.toString());

selectionchange イベント


「ユーザーがテキスト選択範囲を変更した」ということを検知するには selectionchange イベントが使えます。選択の操作が終わったタイミングでのみ実行させたい場合は mouseup イベントや pointerup イベントで代替するか、ユーザーが好きなタイミングで実行できるように専用のボタンを用意してもいいかもしれません。

// ページのテキスト選択が変更されるたびに実行
document.addEventListener("selectionchange", checkSelection);


選択範囲と要素の位置を比較する

要素の範囲を取得する


対象のノードとの前後関係を比較するために、まずそのノードの範囲(Range オブジェクト)を求めましょう。Document.createRange() で Range オブジェクトを作成し selectNode() で要素を指定します。

// IDから要素を探し Range オブジェクトとして取得する
const target = document.getElementById("targetID");
const targetRange = document.createRange();
targetRange.selectNode(target);

compareBoundaryPoints()


これで準備は整いました。いよいよ選択範囲とノードの位置関係をチェックします。単純に範囲とノードが交差しているかどうかを調べるだけなら intersectsNode() というメソッドも用意されていますが、もう少し細かく前後関係を判定したい場合は Range オブジェクトの compareBoundaryPoints() を使います。

range.compareBoundaryPoints(how, sourceRange);

これは「rangeの始点あるいは終点」と「sourceRangeの始点あるいは終点」の位置を比べて、rangeのほうがsourceRangeよりも前にあれば -1 を、後ろにあれば 1を、どちらも同じ位置にあれば 0 を返すメソッドです。始点か終点のどちらを比べるかは1つめの引数(how)で次のいずれかを指定します。

Range.START_TO_STARTrangeの始点とsourceRangeの始点を比較
Range.START_TO_ENDrangeの終点とsourceRangeの始点を比較
Range.END_TO_STARTrangeの始点とsourceRangeの終点を比較
Range.END_TO_ENDrangeの終点とsourceRangeの終点を比較

…説明を聞いてもなんのこっちゃわからないと思うので、具体例を見てみましょう。たとえば

const campare = rangeA.compareBoundaryPoints(Range.START_TO_END, rangeB);

と書いてあれば Range.START_TO_END なので rangeA の終点と rangeB の始点の前後関係を比較します。もしこの値が -1 なら rangeA の終点(=範囲の末尾)が rangeB の始点(=範囲の先頭)より前にあるということになり、2つの範囲は「rangeA が rangeB の前にあって交差していない」ということがわかります。
rangeA
rangeB
「えーと…じゃあ選択範囲が要素の内部にあるということは…選択範囲の始点が要素の始点より後で、かつ選択範囲の終点が要素の終点より前にあればいいから…まずは Range.START_TO_START を指定して…値が -1 で…」と考えることになるのですが、毎回それをやっていると頭が爆発しそうになるので、パターン別に条件をまとめておきます。

位置関係の条件


・選択範囲が要素全体を含む
 
 
// selectionRangeがtargetRange全体を含むとき
if(selectionRange.compareBoundaryPoints(Range.START_TO_START, targetRange) == -1 && selectionRange.compareBoundaryPoints(Range.END_TO_END, targetRange) == 1)


・選択範囲が要素の内部にある
 
 
// selectionRangeがtargetRangeの内部にあるとき
if(selectionRange.compareBoundaryPoints(Range.START_TO_START, targetRange) == 1 && selectionRange.compareBoundaryPoints(Range.END_TO_END, targetRange) == -1)


・選択範囲が要素と交差する
 
 
// selectionRangeがtargetRangeと交差するとき
if(selectionRange.compareBoundaryPoints(Range.START_TO_END, targetRange) == 1 && selectionRange.compareBoundaryPoints(Range.END_TO_START, targetRange) == -1)


「あれ?どの条件式も -1 か 1 かで判定してるけど 0 のときは考えなくていいの?」という疑問が浮かぶかもしれませんが、基本的には 0 の場合(境界がまったく同じ位置にある場合)も考慮したほうがいいです。ただし通常、ユーザーがテキスト選択をする場合、選択位置は Text ノード(=タグ以外の素の文字列)内に限られます。つまり選択位置が getElementById() などで取得した要素の位置とぴったり一致することはないはずなので、今回は 0 を無視しています。

文字列と要素の前後関係をきちんと把握しておかないと意図しない挙動になることがあるので、対象のノードが Element なのか Text なのか適宜確認しながら作業するようにしましょう。


選択範囲を操作する

特定の要素を選択させる


範囲指定の基本は selectNode() あるいは selectNodeContents() です。たとえばユーザーが選択した位置を変更し、強制的に対象のノードを選択させるにはこんな感じ。

// ユーザーの選択位置を対象の要素に合わせる
const selectionRange = window.getSelection().getRangeAt(0);
const targetNode = document.getElementById("target");
selectionRange.selectNode(targetNode);

「選択範囲を操作したいが、ユーザーの選択位置を直接変更したくない」というときは cloneRange() でコピーをとってから利用すればOK。

// copy の範囲に変更を加えてもユーザーの選択位置には影響しない
const selectionRange = window.getSelection().getRangeAt(0);
const copy = selectionRange.cloneRange();

新しい範囲を作成してユーザーの選択範囲として指定したい場合は Selection.addRange() メソッドで選択範囲を追加します。ただし Firefox 以外のブラウザの場合、その前に一旦 Selection.removeAllRanges() で既存の選択範囲をリセットしておかないと適用されないので注意。

// 新しく作成した範囲を選択する
const targetNode = document.getElementById("target");
const targetRange = document.createRange();
targetRange.selectNode(targetNode);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(targetRange);

選択範囲の位置を指定する


さらに位置を細かく指定したいときのために、次のようなメソッドが用意されています。
見るからに複雑そうですが、実際その使い方もかなりややこしいです。たとえば setStart()setEnd() を見てみましょう。

range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);

1つめの引数には対象のノードを指定すればいいのですが、2つめの引数のオフセットは指定したノードの種類によって意味が変わりますText ノードや Comment ノードを指定した場合は「何文字目の位置に移動させるか」という意味になり、それ以外のノードの場合は「何個目の子要素に移動させるか」という意味になります(MDNドキュメントの例がわかりやすいです)。つまり TextElement かで挙動が変わるので、ノードの種類をよく確認しないと、まったく関係ない範囲が選択されているなんてことになりかねません。

これらのメソッドはなるべく使わず、selectNode() で実装できるならそちらを利用したほうが無難です。

範囲内の要素や文字列の移動


たとえば「ユーザーが選択した範囲を span タグで囲ってハイライト表示する」「選択した文字列を別の表の中に移動する」みたいな操作をしたい場合は extractContents()surroundContents() といったメソッドも用意されています。MDNドキュメントには具体例としてコードが載っているので是非チェックしてみてください。

ただし、ユーザー選択というのはかなり自由度が高いので、そのまま何も考えずに実装すると要素が途中で分断されたりして無茶苦茶になりがちです。Range オブジェクトの startContainerendContainercommonAncestorContainer といったプロパティを利用するか、上で説明した compareBoundaryPoints() を使って「選択範囲が対象となるノードの内部にあるかどうか」を必ず確認するようにしましょう。


…ちょっとメモするだけの予定だったのに、長くなってしまいました。おしまい!

0 件のコメント:

コメントを投稿