|||||||||||||||||||||

なんぶ電子

- 更新: 

ブラウザのHTMLをJSで動的にハイライト

JavaScriptで動的にコードハイライト

前回ブラウザでコードエディタを自作しようとしたときに、Webで表示したHTMLに対して動的にJavaScriptで文字を検索し、ハイライトをしました。その際のコードが不完全だったので、改めて検索・ハイライト用のコードを作成しました。

苦労したのは、検索対象語がタグによって分割されている場合の対応です。

たとえば、「対象」という文字を検索したい場合、

<span>対</span><span>象</span>

といったようになっていると、innerHTMLから取得したテキストを使って正規表現で置換といった技が使えません。

また、innerTextやtextContentsを使うと、目的の文字は見つけられるのですが、今度は元通りのDOMに戻すのに大変です。

結局、一字ずつ走査するという泥臭い対応になりました。

ダイジェスト

時間のない方向けに筆者の書いたコード使い方を先に説明します。保証はできませんが、おおむね使えるのではないかと思います。

  1. コードのダウンロード

    githubにjsファイルのコードがありますのでダウンロードしてください。

  2. HTMLへ取り込み

    HTMLのheadかbodyのどこかでそのスクリプトをロードしてください。

  3. スクリプトへ追加

    処理のためのスクリプトを書きます、jsファイルが読み込まれた後に実行される場所に記述してください。

    HighlightStringのインスタンスから、highlightStringメソッドを呼んで下さい。

    この時の引数は次のようになっています。

    • 検索対象文字(必須)
    • 検索対象要素

      省略した場合は最初のbody要素が検索対象となります。

    • 装飾要素

      見つかった文字を装飾するための要素のタグ名を設定してください。デフォルトはspanです。

    • 装飾クラス

      見つかった文字を装飾するための要素のクラス名を設定してください。デフォルトはhighlightです。

    戻り値に元の要素をクローンしてハイライト用の要素を付与した要素が返ります。

    highlightStringメソッド実行後getOriginalDataメソッドを呼ぶと1世代前の状態の要素のクローンを取得できます。

  4. CSSの設定

    ハイライト用に設定した要素と、クラスに任意のCSSを設定します。

使用例は次のようになります。

sample.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <style>
      span.highlight {
        background-color: blue;
      }
    </style>
  </head>
  <body>
    <div id="search">
      <p>ここに検索したい文章が入っているとします。</p>
      <p><span>検索</span><span>文字</span></p>
    </div>
    <script src="./dom-highlight.js"></script>
    <script>
      window.onload = () => {
        let h = new HighlightString();
        document.body.appendChild(h.highlightString("検索文字"),document.getElementById('search'));
      };
    </script>
  </body>
</html>

コード解説

ソースコードを解説と共に掲載します。

dom-highlight.js

class CheckPoint {
  // ハイライト用の位置情報を保持

  // 定数
  CHECK_POINT_TYPE_START = 0; // 開始位置
  CHECK_POINT_TYPE_END = 1; // 終了位置

  constructor(node, index, type) {
    this.node = node;
    this.index = index;
    this.type = type;
  }
}

class CheckString {
  // 検索文字との照合

  // 定数
  RESULT_NOT_HIT = 0; // 一致無し
  RESULT_PART_HIT = 1; // 一致中
  RESULT_ALL_HIT = 2; // 一致完了

  // 子クラスの定数取得用
  CHECK_POINT_CONST = new CheckPoint(null, 0, 0);

  #intMaxLength = 0; // 検索対象文字の最終位置

  points = []; // ハイライト用の位置を記憶 CheckPointクラスの配列
  previousNode = null; // ひとつ前のノード

  constructor(strSearch) {
    this.strSearch = strSearch;
    this.intMaxLength = strSearch.length;
    this.intCurrentSearchIndex = 0; // 照合文字の位置
    this.previousNode = null;
    this.points = [];
  }

  // public
  check(textNode, index) {
    // 1文字チェック
    let blnNodeChange = false;
    let char = textNode.textContent.charAt(index);

    if (this.previousNode !== textNode) {
      this.previousNode = textNode;
      blnNodeChange = true;
    }

    if (this.intCurrentSearchIndex == 0) {
      this.points = []; // リセットは初回時にする
      this.points.push(
        new CheckPoint(
          textNode,
          index,
          this.CHECK_POINT_CONST.CHECK_POINT_TYPE_START
        )
      );
    } else {
      if (blnNodeChange) {
        // もし前の要素と違ったら前の要素の終了と現在の要素の開始を入れる

        this.points.push(
          new CheckPoint(
            this.points[this.points.length - 1].node,
            this.points[this.points.length - 1].node.textContent.length - 1,
            this.CHECK_POINT_CONST.CHECK_POINT_TYPE_END
          )
        );

        this.points.push(
          new CheckPoint(
            textNode,
            index, // 0のはず
            this.CHECK_POINT_CONST.CHECK_POINT_TYPE_START
          )
        );
      }
    }

    if (this.strSearch.charAt(this.intCurrentSearchIndex) == char) {
      this.intCurrentSearchIndex++;
      if (this.intCurrentSearchIndex == this.intMaxLength) {
        // 全てチェックし終えた
        this.points.push(
          new CheckPoint(
            textNode,
            index,
            this.CHECK_POINT_CONST.CHECK_POINT_TYPE_END
          )
        );

        this.intCurrentSearchIndex = 0; // リセット
        return this.RESULT_ALL_HIT;
      } else {
        return this.RESULT_PART_HIT;
      }
    } else {
      this.intCurrentSearchIndex = 0;
      return this.RESULT_NOT_HIT;
    }
  }

  // public
  getCheckPoints() {
    // チェックポイントリストを返す
    return this.points;
  }
}

class ScanNode {
  // 要素を走査して文字を返す

  constructor(elRoot) {
    this.elRoot = elRoot;
    this.textNodes = null;
    this.intNodeIndex = 0;
    this.intStringIndex = -1;
    this.position = null;
  }

  // private
  getTextNodes(node) {
    // テキストノードのシーケンスを取得する
    switch (node.nodeType) {
      case Node.ELEMENT_NODE:
        // スクリプトとスタイルタグ内は検索しない
        switch (node.tagName) {
          case "SCRIPT":
          case "STYLE":
            break;
          default:
            for (let i = 0; i < node.childNodes.length; i++) {
              this.getTextNodes(node.childNodes[i]);
            }
        }
        break;
      case Node.TEXT_NODE:
        this.textNodes.push(node);
        break;
      default:
        // 無視
        break;
    }
  }

  // public
  next() {
    if (this.textNodes == null) {
      // 初回時にテキストノードを出現順に記憶する
      this.textNodes = [];
      this.getTextNodes(this.elRoot); // txtNodesに追加される
    }

    // 次の文字を検索し、そのメタ情報を返す(テキストノードと、文字index)
    // 全ての文字が終わっていた場合はnullを返す
    if (this.textNodes.length <= this.intNodeIndex) {
      return null;
    } else {
      this.intStringIndex++;
      if (
        this.intStringIndex <
        this.textNodes[this.intNodeIndex].textContent.length
      ) {
        return {
          node: this.textNodes[this.intNodeIndex],
          index: this.intStringIndex,
        };
      } else {
        // 次のテキストノード検索
        this.intStringIndex = -1;
        this.intNodeIndex++;
        return this.next();
      }
    }
  }

  // public
  savePosition() {
    this.position = {
      node: this.intNodeIndex,
      string: this.intStringIndex,
    };
  }
  // public
  clearPosition() {
    this.position = null;
  }
  // public
  backToSavedPosition() {
    if (this.position) {
      this.intNodeIndex = this.position.node;
      this.intStringIndex = this.position.string;
    }
  }
}

class HighlightString {
  // 文字検索メイン

  // 子クラスの定数取得用
  CHECK_POINT_CONST = new CheckPoint(null, 0, 0);

  // public
  getOriginalData() {
    // 元の値を返す
    if (this.original) {
      return this.original;
    } else {
      return null;
    }
  }

  // public
  highlightString(
    strSearch,
    elTarget = document.querySelector("body"),
    strWrapTagName = "span",
    strWrapClassName = "highlight"
  ) {
    // 元の値を保存する
    this.original = elTarget.cloneNode(true);
    this.strWrapClassName = strWrapClassName;
    this.strWrapTagName = strWrapTagName;
    let elResult = elTarget.cloneNode(true); // 戻り値
    let check = new CheckString(strSearch);
    let scan = new ScanNode(elResult);
    let checkPoints = []; // ハイライト箇所のリスト
    let intPrevStatus = 0;
    let intCurrentStatus = 0;

    while (true) {
      let r = scan.next();

      if (r == null) {
        break;
      }

      intCurrentStatus = check.check(r.node, r.index);
      // 文字チェック
      switch (intCurrentStatus) {
        case check.RESULT_ALL_HIT:
          // 文字が見つかった
          switch (intPrevStatus) {
            case check.RESULT_ALL_HIT:
            case check.RESULT_PART_HIT:
            case check.RESULT_NOT_HIT:
            default:
              // 結果を保存
              checkPoints = checkPoints.concat(check.getCheckPoints());
              // ヒットしたのでその次の文字からスキャン
              scan.clearPosition();
              break;
          }
          break;
        case check.RESULT_PART_HIT:
          // 文字の一部が見つかった
          switch (intPrevStatus) {
            case check.RESULT_ALL_HIT:
            case check.RESULT_NOT_HIT:
              // 初めて一部ヒットした場合はその場所を覚えておく、不成立時に覚えておいた場所の次からスタートする
              // いいえを検索する際「いいいえ」があった場合不成立になった文字からの検索では拾えない
              scan.savePosition();
              break;
            case check.RESULT_PART_HIT:
            // 前回からの継続ならなにもしない
            default:
              break;
          }
          break;
        case check.RESULT_NOT_HIT:
          switch (intPrevStatus) {
            case check.RESULT_PART_HIT:
              // 前回まで見つかっていたら検索位置を戻す
              scan.backToSavedPosition();
              break;
            case check.RESULT_ALL_HIT:
            case check.RESULT_NOT_HIT:
            default:
              // 念のためのポジションのリセット
              // scan.clearPosition();
              break;
          }
          break;
        default:
      }
      intPrevStatus = intCurrentStatus;
    }

    // 要素挿入処理
    this.reflect(checkPoints);

    // チェック済みから要素を再構築して返す
    return elResult;
  }

  // private
  reflect(checkPoints) {
    // 要素挿入メイン(テキストノード別に反映)
    let checkPointsWk = [];
    let preNode = null;

    for (let i = 0; i < checkPoints.length; i++) {
      if (preNode !== checkPoints[i].node) {
        this.reflectSub(checkPointsWk);
        checkPointsWk = [];
      }
      checkPointsWk.push(checkPoints[i]);
      preNode = checkPoints[i].node;
    }
    this.reflectSub(checkPointsWk);
  }

  // private
  reflectSub(checkPointsWk) {
    // 要素挿入処理
    if (checkPointsWk.length == 0) {
      return;
    }
    let nodes = [];
    let strWk = "";
    let intOffset = 0;
    let blnStart = false;
    let text = checkPointsWk[0].node.textContent;

    // ノード生成
    for (let i = 0; i < checkPointsWk.length; i++) {
      switch (checkPointsWk[i].type) {
        case this.CHECK_POINT_CONST.CHECK_POINT_TYPE_START:
          if (blnStart) {
            console.log("irregular value on 'type start' exception");
            // 開始が続くことは想定外
          } else {
            // 開始より前の文字は通常のテキスト
            strWk = text.substring(intOffset, checkPointsWk[i].index);
            if (strWk !== "") {
              nodes.push(document.createTextNode(strWk));
            }
          }
          blnStart = true;
          intOffset = checkPointsWk[i].index;
          break;
        case this.CHECK_POINT_CONST.CHECK_POINT_TYPE_END:
          // 終了文字のindex値なのでsubsringで取得する際は+1が必要
          if (blnStart) {
            strWk = text.substring(intOffset, checkPointsWk[i].index + 1);
            if (strWk !== "") {
              let el = document.createElement(this.strWrapTagName);
              el.classList.add(this.strWrapClassName);
              el.textContent = strWk;
              nodes.push(el);
            }
          } else {
            console.log("irregular value on 'type end' exception");

            // 始まっていない場合は想定外、通常テキストとして追加
            strWk = text.substring(intOffset, checkPointsWk[i].index + 1);
            if (strWk !== "") {
              nodes.push(document.createTextNode(strWk));
            }
          }
          blnStart = false;
          intOffset = checkPointsWk[i].index + 1;
          break;
        default:
          console.log("irregular value on 'type' exception");
      }
    }
    if (blnStart) {
      console.log("irregular value on 'end not exists' exception");
    }
    strWk = text.substring(intOffset);
    if (strWk !== "") {
      nodes.push(document.createTextNode(strWk));
    }

    // 要素に挿入
    let parent = checkPointsWk[0].node.parentNode;
    for (let i = 0; i < nodes.length; i++) {
      parent.insertBefore(nodes[i], checkPointsWk[0].node);
    }
    // 元のテキストノードを分割して新たにセットしたので、
    // 元のテキストノードは削除
    checkPointsWk[0].node.remove();
  }
}

筆者紹介


自分の写真
がーふぁ、とか、ふぃんてっく、とか世の中すっかりハイテクになってしまいました。プログラムのコーディングに触れることもある筆者ですが、自分の作業は硯と筆で文字をかいているみたいな古臭いものだと思っています。 今やこんな風にブログを書くことすらAIにとって代わられそうなほど技術は進んでいます。 生活やビジネスでPCを活用しようとするとき、そんな第一線の技術と比べてしまうとやる気が失せてしまいがちですが、おいしいお惣菜をネットで注文できる時代でも、手作りの味はすたれていません。 提示されたもの(アプリ)に自分を合わせるのでなく、自分の活動にあったアプリを作る。それがPC活用の基本なんじゃなかと思います。 そんな意見に同調していただける方向けにLinuxのDebianOSをはじめとした基本無料のアプリの使い方を紹介できたらなと考えています。

広告