CSS Custom Highlight APIを用いページ内検索をリアルタイムでハイライトする方法

Cocoonカスタマイズ集
この記事は約9分で読めます。
記事内に広告が含まれています。

はじめに

以下を実現します。

Webサイトに「ページ内検索」を実装する際、入力したキーワードをリアルタイムでハイライト表示したい場面は多いでしょう。
特に長文コンテンツでは、ユーザーが目的の情報を素早く見つけられるかどうかがUXに大きく影響します。

従来、このようなハイライト機能はJavaScriptでDOMを書き換え、<mark>タグなどを挿入することで実現するのが一般的でした。
しかしこの方法には、HTML構造が壊れるリスクなどの課題があり、実装・保守ともに手間がかかるものでした。

そこで登場したのがCSS Custom Highlight APIです。

このAPIは、DOMを書き換えることなくテキスト範囲にスタイルを適用できる新しい仕組みで、より安全かつ高速にハイライト処理を実現できます。

この記事では、この方法について説明します。

使用方法

以下にショートコードを示します。

[my_live_search]
オプション
オプション説明値初期値
target検索対象のCSSセレクタ.entry-content
placeholderプレースホルダーテキストページ内検索…

完成イメージ

検索キーを入力し、送信すると該当箇所がハイライト表示されます。

実装手順

以下の手順で実装します。

// ショートコードを追加
add_shortcode('my_live_search', function($atts) {
  $atts = shortcode_atts(array(
    'target'      => '.entry-content',
    'placeholder' => 'ページ内検索...',
  ), $atts);

  ob_start();
  ?>
  <div class="live-search">
    <input type="text" class="live-search__input" id="liveSearch" placeholder="<?php echo esc_attr($atts['placeholder']); ?>" data-target="<?php echo esc_attr($atts['target']); ?>" />
    <span class="live-search__count" id="matchCount"></span>
  </div>
  <?php
  return ob_get_clean();
});


// フロント用JS・CSSを追加
add_action('wp_enqueue_scripts', function() {
  ob_start();
  ?>
  ::highlight(search-highlight) {
    background-color: yellow;
  }
  ::highlight(active-highlight) {
    background-color: orange;
  }
  <?php

  $handle = 'live-search';

  wp_register_style($handle, false);
  wp_enqueue_style($handle);
  wp_add_inline_style($handle, ob_get_clean());

  ob_start();
  ?>
  document.addEventListener("DOMContentLoaded", () => {
    // Highlight API未対応なら処理を終了
    if (!("highlights" in CSS)) {
      return;
    }

    // 入力欄要素の取得
    const input = document.getElementById("liveSearch");

    // 件数表示用要素の取得
    const countEl = document.getElementById("matchCount");

    if (!input) return;

    // 検索対象となるコンテナ要素の取得
    const targetSelector = input.dataset.target || ".entry-content";
    const target = document.querySelector(targetSelector);

    if (!target) {
      return;
    }

    // 検索ヒットしたRangeを保持する配列
    let ranges = [];

    // 現在の選択インデックス(-1は未選択状態を表す)
    let currentIndex = -1;

    /**
     * 文字の表記ゆれ(全角/半角・大文字小文字)を統一
     *
     * @param {string} str 対象の文字列
     * @return {string} 正規化された文字列
     */
    function normalize(str) {
      return str.toLowerCase().normalize("NFKC");
    }

    /**
     * 検索キーワードに基づいてページ内テキストを走査しハイライトを設定
     *
     * @param {string} keyword 検索キーワード
     * @return {void}
     */
    function search(keyword) {
      // 既存のハイライトを削除
      CSS.highlights.delete("search-highlight");
      CSS.highlights.delete("active-highlight");

      // 検索結果を初期化
      ranges = [];
      currentIndex = -1;

      // キーワードが空の場合は件数をクリアして終了
      if (!keyword) {
        countEl.textContent = "";
        return;
      }

      // 検索ワードを正規化
      const searchWord = normalize(keyword);

      // テキストノードのみを走査するツリーウォーカーを生成
      const walker = document.createTreeWalker(
        target,
        NodeFilter.SHOW_TEXT
      );

      let node;
      let count = 0;

      // ページ内の全テキストノードを順にチェック
      while ((node = walker.nextNode())) {
        // ノードのテキストを正規化
        const text = normalize(node.nodeValue);

        let index = 0;

        // 同一ノード内の一致箇所を全て探す
        while ((index = text.indexOf(searchWord, index)) !== -1) {
          // 一致箇所をRangeとして記録
          const range = new Range();
          range.setStart(node, index);
          range.setEnd(node, index + searchWord.length);

          // 範囲オブジェクトを配列に保存
          ranges.push(range);

          count++;

          // 次の検索位置へインデックスを進める
          index += searchWord.length;
        }
      }

      // ヒット箇所が存在する場合は全体ハイライトを適用
      if (ranges.length) {
        CSS.highlights.set(
          "search-highlight",
          new Highlight(...ranges)
        );
      }

      // 検索結果のヒット件数を画面に表示
      countEl.textContent = count + "件ヒット";
    }

    /**
     * 検索結果の次のヒット箇所へ移動しフォーカス
     *
     * @return {void}
     */
    function moveNext() {
      // ヒットがなけれ処理を終了
      if (!ranges.length) {
        return;
      }

      // インデックスを次へ移動(最後尾に到達した場合は先頭へループ)
      currentIndex = (currentIndex + 1) % ranges.length;

      // 現在のRangeオブジェクトを取得
      const range = ranges[currentIndex];

      // 現在位置をアクティブ用ハイライトとして設定
      CSS.highlights.set(
        "active-highlight",
        new Highlight(range)
      );

      // 該当する要素の位置へスムーズにスクロール
      range.startContainer.parentElement.scrollIntoView({
        behavior: "smooth",
        block: "center"
      });

      // 「現在のインデックス / 総ヒット数」の形式で件数表示を更新
      countEl.textContent =
        (currentIndex + 1) + " / " + ranges.length;
    }

    // 入力イベント時のタイマー変数
    let timer;

    // 検索入力フィールドへの入力イベントを設定
    input.addEventListener("input", () => {
      // 連続入力時の過剰な負荷を軽減するためタイマーをクリア
      clearTimeout(timer);

      timer = setTimeout(() => {
        search(input.value);
      }, 200);
    });

    // Enterキークリック時に次の検索結果へ移動するイベントを設定
    input.addEventListener("keydown", (event) => {
      if (event.key === "Enter") {
        event.preventDefault();
        moveNext();
      }
    });
  });
  <?php
  wp_add_inline_script('jquery', ob_get_clean(), 'after');
});

さいごに

今回紹介した方法を使えば、従来のようにDOMを書き換えることなく、安全かつ高速にページ内検索のハイライト機能を実装できます。
特に長文記事や技術ブログでは、ユーザーの回遊性や可読性の向上に大きく貢献します。

タイトルとURLをコピーしました