読者です 読者をやめる 読者になる 読者になる

JavaScriptでキーワードハイライティング

javascript

ページに依存せず、あらかじめ準備が必要なく、安全で、汎用的に使えるハイライト機能がほしいーと思ったので作ってみました。大変必要でした。
はじめはサーバーサイドでハイライトしようと思ったのですが、

  • ajaxとうまくやれない
  • 準備なしでやるにはApacheのoutputfilterでhtmlparserを使ってキーワードを置換しなければいけなくて、そんなのめんどすぎー

ということでやめました。JavaScriptは簡単で便利です。

さっそく試しに例の辞書に追加してみました!
例の辞書

以下ソースです。初心者なので本当に安全なのか分かりません。レイアウトをぶっ壊すかもしれません。一応作っているときに発見した書き換えるとよくないことが起こりそうなタグの中身は書き換えないようにしています。ほかにこのタグも中身を書き換えるとヤバイ、とかあったら教えてください!
パフォーマンスは微妙です。夏目漱石の『吾輩は猫である』を『は』でハイライトすると約10秒かかりました。普通のページは100ミリ秒かからないくらいだと思います。

// namespace
var jp = jp || {};
jp.udp = jp.udp || {};
jp.udp.util = jp.udp.util || {};

jp.udp.util.HIGHLIGHT_CLASS_NAME = 'jp-udp-util-keyword-highlight'; // 内部用
jp.udp.util.NODE_TYPE = {
    "ELEMENT_NODE": 1,
    "TEXT_NODE": 3
};

/*
 * ハイライトON
 *
 * keyword: キーワード
 * className: ハイライト用のクラス
 * root: 開始エレメント
 */
jp.udp.util.highlightKeyword = function(keyword, className, root)
{
    var ELEMENT_NODE = jp.udp.util.NODE_TYPE.ELEMENT_NODE;
    var TEXT_NODE = jp.udp.util.NODE_TYPE.TEXT_NODE;
    var HIGHLIGHT_CLASS_NAME = jp.udp.util.HIGHLIGHT_CLASS_NAME;
    
    var stack = new Array(root || document.body);
    var invalidTagName = /^pre$|^a$|^input$|^textarea$|^select$|^script$|^style$/i;
    var highlightedClassName = new RegExp(
        '(?:^|\\s+)' + HIGHLIGHT_CLASS_NAME + '(?:\\s+|$)'
    );
    var highlightingClassName = new RegExp(
        '(?:^|\\s+)' + className + '(?:\\s+|$)'
    );
    var highlightNodeBase = document.createElement(keyword);
    highlightNodeBase.className = className + ' ' + HIGHLIGHT_CLASS_NAME;
    highlightNodeBase.appendChild(document.createTextNode(keyword));
    
    var node;
    while (node = stack.pop()) {
        var childNodes = node.childNodes;
        if (! childNodes) {
            continue;
        }
        for (var i = 0; i < childNodes.length; ++i) {
            var child = childNodes[i];
            if (child.nodeType == TEXT_NODE) {
                if (child.nodeValue.indexOf(keyword) != -1) {
                    var elements = child.nodeValue.split(keyword);
                    var span = document.createElement('span');
                    for (var j = 0; j < elements.length; ++j) {
                        if (j > 0) {
                            span.appendChild(highlightNodeBase.cloneNode(true));
                        }
                        span.appendChild(document.createTextNode(elements[j]));
                    }
                    node.replaceChild(span, child);
                }
            } else if (child.nodeType == ELEMENT_NODE
                       && !invalidTagName.test(child.tagName))
            {
                var childClassName = child.className;
                if (highlightedClassName.test(childClassName)) {
                    if (!highlightingClassName.test(childClassName)) {
                        child.className = childClassName + ' ' + className;
                    }
                } else {
                    stack.push(child);
                }
            }
        }
    }
}

/*
 * ハイライトOFF
 *
 * className: ハイライト用の(だった)クラス
 * root: 開始エレメント
 */
jp.udp.util.unhighlightKeyword = function(className, root)
{
    var ELEMENT_NODE = jp.udp.util.NODE_TYPE.ELEMENT_NODE;
    
    var stack = new Array(root || document.body);
    var highlightedClassName = new RegExp(
        '(?:^|\\s+)' + jp.udp.util.HIGHLIGHT_CLASS_NAME + '(?:\\s+|$)'
    );
    var highlightingClassName = new RegExp(
        '(?:^|\\s+)' + className + '(?:\\s+|$)'
    );
    
    var node;
    while (node = stack.pop()) {
        var childNodes = node.childNodes;
        if (!childNodes) {
            continue;
        }
        for (var i = 0; i < childNodes.length; ++i) {
            var child = childNodes[i];
            if (child.nodeType == ELEMENT_NODE) {
                var childClassName = child.className;
                if (highlightedClassName.test(childClassName)) {
                    if (highlightingClassName.test(childClassName)) {
                        child.className = childClassName.replace(
                            highlightingClassName,
                            ' '
                        ).replace(/\s+$/, '');
                    }
                } else {
                    stack.push(child);
                }
            }
        }
    }
}