🖍️

テキストハイライト機能を実装する

2025/02/21に公開

はじめに

読書アプリや学習アプリには、テキストの一部を選択してハイライトをつける機能がよくあります。参考書にマーカーペンで印をつけるようなイメージです。
本記事では、JavaScript を用いたハイライト機能の実装例を紹介します。

結果

動作するサンプルプログラムを CodePen で公開しています。

本記事で解説する機能

  • HTML 文書で、マウスドラッグで選択したテキストにハイライトを適用する
  • ハイライト位置を保存し、ページを再読み込みしても同じ位置にハイライトを表示する

使用する Web API

Selection API

テキスト選択範囲の取得には Selection API を使用します。

Selection API は、ユーザーが選択したドキュメント部分にアクセスし、操作できる API です。

Window.getSelection() または Document.getSelection()Selection オブジェクトを取得し、選択範囲を操作します。

Range インターフェイス

選択範囲は Range インターフェイス で表されます。

Range は、ノードやテキストノードの一部を含む文書の範囲を表します。

Range.startContainerRange.startOffset で範囲の始点を、Range.endContainerRange.endOffset で終点を取得します。
Range.toString() で範囲内のテキストを取得できます。

ハイライトの実装

対象範囲の設定

本記事およびサンプルプログラムでは、contentElement 要素をハイライト対象とします。特定の要素を対象範囲としたい場合は、任意の Node オブジェクトに置き換えてください。

const contentElement = document.getElementById("content") || document.body;

選択範囲の特定

選択範囲を DB に保存するために、「文書の先頭から数えて l 文字目から r 文字目まで」という形式で表します。
Selection.getRangeAt(0) で取得した Range を使い、先頭からの文字数を数えて範囲の始点と終点を特定します。

const positionsToRange = (start, end) => {
  const selection = document.getSelection();
  if (selection) {
    const range = document.createRange();
    range.setStart(contentElement, 0);
    range.setEnd(contentElement, 0);
    selection.removeAllRanges();
    selection.addRange(range);

    const offsetRange = document.createRange();
    offsetRange.setStart(contentElement, 0);

    while (offsetRange.toString().length < start) {
      selection.modify("move", "forward", "character");
      offsetRange.setEnd(
        selection.getRangeAt(0).startContainer,
        selection.getRangeAt(0).startOffset
      );
    }

    while (offsetRange.toString().length < end) {
      selection.modify("extend", "forward", "character");
      offsetRange.setEnd(
        selection.getRangeAt(0).endContainer,
        selection.getRangeAt(0).endOffset
      );
    }

    return selection.getRangeAt(0);
  }
};

ハイライトの適用

選択範囲のテキストを span タグで囲み、背景色を変更してハイライトします。

const nextNode = (node) => {
  while (node && !node.nextSibling) {
    node = node.parentNode;
  }
  return node?.nextSibling;
};

const highlight = (range) => {
  const startDummyNode = document.createTextNode("");
  const endDummyNode = document.createTextNode("");

  const cloneRange = range.cloneRange();
  cloneRange.insertNode(startDummyNode);
  cloneRange.collapse();
  cloneRange.insertNode(endDummyNode);

  let current = nextNode(startDummyNode);
  while (current && current !== endDummyNode) {
    if (current.nodeType === Node.TEXT_NODE && current.textContent) {
      const span = document.createElement("span");
      span.style.backgroundColor = "rgba(242, 204, 13, 0.5)";
      const currentRange = document.createRange();
      currentRange.selectNodeContents(current);
      currentRange.surroundContents(span);
      current = nextNode(span);
    } else {
      current = current.firstChild || nextNode(current);
    }
  }

  startDummyNode.remove();
  endDummyNode.remove();
};

ここで span タグ要素にクリックイベントハンドラを設定できます。

span.addEventListener("click", () => {
  console.debug("clicked!");
});

ハイライトの復元

{start: number, end: number} の配列でハイライト位置を管理し、ページロード時にハイライト表示を復元します。

positionsArray.forEach(({ start, end }) => {
  const range = positionsToRange(start, end);
  highlight(range);
});

CSS Custom Highlight API

CSS Custom Highlight API を使えば、DOM 操作なしでハイライトが可能です。ただし、次の点に注意が必要です。

  • サポートブラウザが限られる
    • Firefox や 2023 年以前の古いバージョンのブラウザなどで非対応
  • クリックイベントが取れない
    • ハイライトのクリックによる削除・色の変更・メモの追加などユースケースが多いので、これが使えないのは痛い
  • 使用できるスタイルが限定的

おわりに

本記事では、Selection API と Range インターフェイスを使ったハイライト機能の実装を紹介しました。
React などのフレームワークでは ref を活用する必要がありますが、基本的な流れは変わりません。
CSS Custom Highlight API が普及した後でも役に立つ情報だと思ったため、今回記事にできて良かったです。

株式会社MICIN

Discussion