Range と Selection でテキスト範囲を操作する

ブラウザ上でテキストをドラッグして選択する操作は日常的に行われていますが、この選択範囲をプログラムから制御できることはあまり知られていません。Range と Selection は、DOM ツリー内の任意の範囲を指定・操作するための API です。テキストのハイライト、リッチテキストエディタの構築、検索結果の強調表示など、テキスト範囲を精密に扱いたい場面で欠かせない存在です。

Range の基本

Range オブジェクトは、DOM ツリー内の連続した範囲を表現します。範囲の始点と終点をノードとオフセットのペアで指定する仕組みです。

const range = document.createRange();

// テキストノードの 3 文字目から 8 文字目までを範囲に設定
const textNode = document.getElementById('target').firstChild;
range.setStart(textNode, 3);
range.setEnd(textNode, 8);

setStart と setEnd の第 1 引数はノード、第 2 引数はオフセットです。テキストノードの場合、オフセットは文字位置を示します。要素ノードの場合は子ノードのインデックスを示すという違いがあります。

テキストノードでのオフセット

文字単位の位置を指定する。オフセット 3 なら 4 文字目の手前を意味する。

要素ノードでのオフセット

子ノードのインデックスを指定する。オフセット 2 なら 3 番目の子ノードの手前を意味する。

この二重の意味を理解しておかないと、Range の操作で意図しない範囲を掴んでしまうことがあります。

Range の主要メソッド

Range には範囲の設定以外にも、DOM を操作するための強力なメソッドが揃っています。

const range = document.createRange();
const el = document.getElementById('content');

// 要素の中身全体を範囲に設定
range.selectNodeContents(el);

// 要素自体(タグも含む)を範囲に設定
range.selectNode(el);

// 範囲内のノードを DocumentFragment として抽出(元の DOM からは削除される)
const fragment = range.extractContents();

// 範囲内のノードを複製して DocumentFragment として取得
const clone = range.cloneContents();

// 範囲の始点にノードを挿入
range.insertNode(document.createTextNode('挿入テキスト'));

// 範囲内のノードを削除
range.deleteContents();

extractContents は範囲内の要素を DOM から切り取って返すメソッドで、カット操作の実装に使えます。cloneContents は元を残したままコピーを作るので、コピー操作に相当します。このように Range は単なる範囲指定にとどまらず、DOM の切り貼りを範囲単位で行える道具です。

Selection の基本

Selection オブジェクトは、ユーザーが画面上で選択しているテキスト範囲を表現します。window.getSelection() で取得できます。

const selection = window.getSelection();

// 選択されたテキストを取得
console.log(selection.toString());

// 選択範囲の数(通常は 0 か 1)
console.log(selection.rangeCount);

// 最初の Range オブジェクトを取得
if (selection.rangeCount > 0) {
  const range = selection.getRangeAt(0);
  console.log(range.startOffset, range.endOffset);
}

Selection と Range の関係は「Selection が Range を内包している」という構造です。ユーザーがテキストをドラッグ選択すると、ブラウザ内部で Range が生成され、それが Selection に格納されます。プログラム側からも Range を作って Selection に渡すことで、選択状態をコードから制御できます。

Range を作成して範囲を設定する

Selection に addRange で Range を追加する

ブラウザ上でテキストが選択状態になる

プログラムから選択範囲を設定する

特定のテキストをコードからハイライト選択する方法を見てみましょう。

HTML
CSS
JavaScript
<p id="sample-text">JavaScript の Range と Selection はテキスト操作の強力なツールです。</p>
<button id="select-btn">Range と Selection を選択</button>
<button id="clear-btn">選択解除</button>
#sample-text {
  font-size: 16px;
  line-height: 1.8;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}
document.getElementById('select-btn').addEventListener('click', () => {
  const textNode = document.getElementById('sample-text').firstChild;
  const text = textNode.textContent;
  const start = text.indexOf('Range と Selection');
  const end = start + 'Range と Selection'.length;

  const range = document.createRange();
  range.setStart(textNode, start);
  range.setEnd(textNode, end);

  const selection = window.getSelection();
  selection.removeAllRanges();
  selection.addRange(range);
});

document.getElementById('clear-btn').addEventListener('click', () => {
  window.getSelection().removeAllRanges();
});

ボタンを押すと Range と Selection という文字列がプログラム的に選択されます。removeAllRanges で既存の選択を解除してから addRange で新しい Range を設定するのが定型パターンです。

選択範囲にスタイルを適用する

ユーザーが選択したテキストにハイライトを付ける機能は、リッチテキストエディタや学習アプリでよく見かけます。Range の surroundContents メソッドを使うと、範囲を任意の要素で囲めます。

function highlightSelection() {
  const selection = window.getSelection();
  if (selection.rangeCount === 0 || selection.isCollapsed) return;

  const range = selection.getRangeAt(0);

  const mark = document.createElement('mark');
  mark.style.backgroundColor = '#fff59d';

  try {
    range.surroundContents(mark);
  } catch (e) {
    // 範囲が要素の境界をまたぐ場合はエラーになる
    console.warn('この範囲には surroundContents を適用できません');
  }

  selection.removeAllRanges();
}

surroundContents には制約があり、選択範囲が要素ノードの境界をまたいでいると例外を投げます。たとえば段落の途中から次の段落の途中までを選択した場合などがこれに該当します。

この制約を回避するには、surroundContents の代わりに extractContents で範囲を取り出し、mark 要素の中に入れてから insertNode で戻す方法が使えます。

range.extractContents() → mark.appendChild(fragment) → range.insertNode(mark) の手順で、要素境界をまたぐ範囲にもスタイルを適用できる。

実践:テキストハイライトツール

選択したテキストにハイライトを付け、ハイライトをクリックすると解除できるツールを作ってみましょう。

HTML
CSS
JavaScript
<div id="editor" style="padding:12px; border:2px solid #4a90d9; border-radius:4px; line-height:2; font-size:15px;">
  DOM の Range オブジェクトは文書内の連続した範囲を表します。Selection オブジェクトはユーザーの選択状態を表します。この二つを組み合わせることで、テキストの選択やハイライトをプログラムから制御できます。
</div>
<div style="margin-top:10px;">
  <button id="hl-btn" style="padding:6px 14px; cursor:pointer; background:#fff59d; border:1px solid #ccc; border-radius:4px;">選択範囲をハイライト</button>
  <span style="font-size:13px; color:#888; margin-left:8px;">ハイライトをクリックで解除</span>
</div>
document.getElementById('hl-btn').addEventListener('click', () => {
  const selection = window.getSelection();
  if (selection.rangeCount === 0 || selection.isCollapsed) return;

  const range = selection.getRangeAt(0);
  const fragment = range.extractContents();

  const mark = document.createElement('mark');
  mark.style.backgroundColor = '#fff59d';
  mark.style.cursor = 'pointer';
  mark.appendChild(fragment);

  mark.addEventListener('click', function() {
    const parent = this.parentNode;
    while (this.firstChild) {
      parent.insertBefore(this.firstChild, this);
    }
    parent.removeChild(this);
    parent.normalize();
  });

  range.insertNode(mark);
  selection.removeAllRanges();
});

ハイライト解除のロジックでは、mark 要素の子ノードを親に移してから mark 自体を削除しています。最後に normalize を呼ぶのは、分断されたテキストノードを結合してツリーを整理するためです。

Selection のイベント

ユーザーの選択操作に反応するには selectionchange イベントを使います。このイベントは document に対して発火します。

document.addEventListener('selectionchange', () => {
  const selection = window.getSelection();
  const text = selection.toString();

  if (text.length > 0) {
    console.log('選択中:', text);
  }
});

選択が変わるたびに発火するため、マウスのドラッグ中は連続的にイベントが発生します。重い処理を行う場合は debounce が必要になるでしょう。また、selectstart イベントを使えば選択操作の開始を検知でき、特定の要素内での選択を禁止するといった制御も可能です。

// この要素内のテキスト選択を禁止
document.getElementById('no-select').addEventListener('selectstart', e => {
  e.preventDefault();
});

Range の座標情報

Range は getClientRects と getBoundingClientRect メソッドを持っており、選択範囲の画面上の座標を取得できます。ポップアップメニューを選択位置に表示するような UI で活用できます。

document.addEventListener('mouseup', () => {
  const selection = window.getSelection();
  if (selection.isCollapsed) return;

  const range = selection.getRangeAt(0);
  const rect = range.getBoundingClientRect();

  // 選択範囲の上にポップアップを表示
  const popup = document.getElementById('popup');
  popup.style.position = 'fixed';
  popup.style.left = rect.left + rect.width / 2 + 'px';
  popup.style.top = rect.top - 40 + 'px';
  popup.style.display = 'block';
});

getClientRects は複数行にまたがる選択範囲について行ごとの矩形を配列で返し、getBoundingClientRect はすべてを包含する単一の矩形を返します。ツールチップ的な UI には getBoundingClientRect が手軽で、行ごとのハイライト描画には getClientRects が適しています。

isCollapsed と範囲のチェック

Selection や Range が「何も選択していない」状態、つまりカーソルが点滅しているだけの状態を判定するには isCollapsed プロパティを使います。

const selection = window.getSelection();

if (selection.isCollapsed) {
  // カーソルが置かれているだけ(範囲なし)
} else {
  // テキストが選択されている
}

始点と終点が同じ位置にあるとき isCollapsed は true になります。ハイライトやコピー機能のように「選択範囲が存在すること」が前提の処理では、最初にこの判定を入れるのが定石です。選択が空の状態で extractContents や surroundContents を呼んでも意味がないだけでなく、意図しない DOM の変更を引き起こす可能性があるため、必ずガードを入れましょう。

Range の surroundContents メソッドが例外を投げるのはどのような場合ですか?

  • テキストノードの途中を範囲に指定した場合
  • 範囲の長さが 0(isCollapsed が true)の場合
  • 範囲が要素ノードの境界をまたいでいる場合
  • すでに mark 要素で囲まれている範囲に適用した場合
__RESULT__

surroundContents は範囲全体を一つの要素で包む操作ですが、範囲が要素の開始タグと終了タグの間をまたぐ場合、DOM の整合性を保てないため例外を投げます。