はじめに
この記事は FORK Advent Calendar 2019 の22日目の記事です。
表題のとおりに挙動をするブックマークレットを作成する機会があり、アドベントカレンダーの記事にしてみました。
至らない点多々あると思いますが、お手柔らかにお願いします。
作成したもの
下記ページにあるリンクをブックマークバーへ追加して様々なページでクリックしてみてください。
クリスマス、Qiita、Advent Calendar がハイライトされると思います。
※Chrome のみ動作確認してます
#プログラムの概要
おおまかな挙動について記述しています。
コードの全文は下記からご確認ください。
https://github.com/yshrkn/highlight-string
要件
今回作成したプログラムの要件は下記2つです。
- ハイライト後もページ内の既存の JS が動作する。(アコーディオン、タブなど)
- ハイライトした文字列にホバーをすると任意の文字列を表示できる。
「1.」の要件があったため String.prototype.replace()
などを使用して DOM を置き換えることができず、地道に DOM を見ていく必要があります...。
「2.」の要件は title
属性を使用しました。
実装の流れ(抜粋)
1. チェックリストを用意
配列にそれぞれハイライトさせるテキストの正規表現と、マウスホバー時に表示させるテキストをセットします。
※外部ファイルとして読み込むようにしていましたが、今回は記事用に直接記述してます。
/* チェックする文言リスト */
const checkList = [
{
'regex': 'クリスマス',
'recommendation': '「クリスマス」にハイライト'
},
{
'regex': 'Qiita',
'recommendation': '「Qiita」にハイライト'
},
{
'regex': 'Advent Calendar',
'recommendation': '「Advent Calendar」にハイライト'
},
];
2. body タグ直下の要素ノードごとに、インライン要素でないノード全て格納した配列を作成する
単純に body タグ直下の要素ノードの textContent
とチェックリストの正規表現がマッチするかを判別したいところですが、ブロック要素を跨いだテキストをハイライトすることは避ける必要があります。
そのため、body タグ直下の要素ノードの中にある要素ノードを全て探索し、スタイルがインライン要素でないものをここでは blockNElementNode
に格納しています。
/* 文言チェック処理 */
document.body.childNodes.forEach(node => {
const isElement = isElementNode(node);
if (!isElement) { return; }
if (isElement) {
/* 初期化 */
stopSearchingBlock = false;
blockElementNodes = [];
setBlockElementNode(node);
if (!blockElementNodes.length) { return; }
do {
let blockNElementNode = blockElementNodes.pop();
checkWords(blockNElementNode);
if (!blockElementNodes.length) { break; }
} while (blockElementNodes.length);
}
});
###3. ハイライトするかどうかの判定
正規表現で1回にチェックするノードの範囲が分かったら、各ノードごとにチェックリストにある正規表現とマッチする文字列があるか検索します。
チェックリストの正規表現とマッチするかどうかの判定には、RegExp.prototype.exec()
メソッドを使用しています。
/**
* チェックリストに当てはまる文字列が存在するか
* チェックに使用する親要素は blockElementNodes.length - 1 の要素(直近の親)
*/
function checkWords(closestBlockNode) {
if (getPlainString(closestBlockNode.textContent) === '') { return; }
/* チェックリストを走査する */
checkList.forEach((obj, i) => {
/* チェックリストのマッチ条件が空の場合はなにもしない */
const keyIsEmpty = obj[constantsMap.targetKeyName] === '' || obj[constantsMap.targetKeyName] === undefined;
if (keyIsEmpty) { return; }
const regex = new RegExp(obj[constantsMap.targetKeyName], 'g');
let plainText = closestBlockNode.textContent;
while ((regexResult = regex.exec(plainText)) !== null) {
/* 結果オブジェクトにtitle属性に表示する文字列追加 */
regexResult.recommend = obj[constantsMap.recommendKeyName];
resetHighlightVariables();
/* テキストノード探索 */
findTextNode(closestBlockNode);
}
});
}
###4. ハイライト処理対象のテキストノードを探索する
チェックリストの正規表現にマッチした場合、マッチした文字の位置になるまでテキストノードを探していきます。
マッチした文字列を含むテキストノードがみつかった場合、ハイライト処理に移行します。
/**
* テキストノードを探索する
* @param elementNode {HTMLElement}
*/
function findTextNode(elementNode) {
try {
Array.from(elementNode.childNodes).some(node => {
if (node.nodeValue === '') { return false; }
if (shouldCheckNextCondition) { return true; }
/* 要素ノードかテキストノードかを判定 */
if (isElementNode(node)) {
if (node.childNodes.length) {
findTextNode(node);
}
} else if (isTextNode(node) && node.nodeValue !== '') {
checkHighlight(node);
if (shouldCheckNextCondition) { return true; }
}
/* ハイライトが終わっていないノードがある場合 */
if (isRemaining && nextHighlightElement) {
if (nextHighlightElement.childNodes.length) {
findTextNode(nextHighlightElement);
}
}
if (shouldCheckNextCondition) { return true; }
});
} catch (e) {
console.error(e);
}
}
/**
* ハイライトするべきテキストノードであれば、ハイライト処理へ移行する
* @param {Node} TEXT_NODE
*/
function checkHighlight(node) {
if (isRemaining) {
highlightText(node);
return;
}
const shouldHighlight = regexResult.index < textCount + node.nodeValue.length;
if (shouldHighlight) {
highlightText(node);
} else {
textCount += node.nodeValue.length;
}
}
###5. ハイライト処理
ハイライトするべきテキストノードが決まったら、対象のテキストノードのどこからどこまでを囲むかを設定します。
ハイライトするテキストをタグで囲む処理は、Range オブジェクトを使用しています。
/**
* ハイライト処理
*/
function highlightText(textNode) {
const textNodeLen = textNode.nodeValue.length;
/* ハイライトタグ作成 */
const span = document.createElement('span');
span.className = 'rh-highlight';
span.setAttribute('title', regexResult.recommend);
/* ハイライトタグで囲む開始/終了地点を設定 */
let startIndex;
let endIndex;
if (isRemaining) {
startIndex = 0;
endIndex = remainingCount;
} else {
startIndex = Math.abs(regexResult.index - textCount);
endIndex = startIndex + regexResult[0].length;
}
/**
* テキストノードのlengthがハイライト対象文字数より少ない場合の処理
**/
const gap = Math.abs(startIndex - endIndex);
const idealTextNodeLen = startIndex + gap; /* ハイライトに最低限必要なテキストノードの長さ */
const isShorter = textNodeLen < idealTextNodeLen;
if (isShorter) {
isRemaining = true;
remainingCount = Math.abs(textNodeLen - idealTextNodeLen);
/* ハイライトできる反映のみハイライトする */
endIndex = textNodeLen;
}
try {
/* Rangeオブジェク作成 */
const range = document.createRange();
range.setStart(textNode, startIndex);
range.setEnd(textNode, endIndex);
range.surroundContents(span);
/* 以降ハイライト正常終了時 */
if (isRemaining) {
if (!isShorter) {
remainingCount -= textNodeLen;
/* ハイライト途中のノードをすべてハイライトし終えたとき */
if (remainingCount < 0) {
isRemaining = false;
highlightCompleted = true;
shouldCheckNextCondition = true;
return;
}
}
nextHighlightElement = span.nextElementSibling;
} else {
shouldCheckNextCondition = true;
highlightCompleted = true;
}
} catch (e) {
console.error(e);
isRemaining = false;
highlightError = true;
}
}
反省
下記2点がうまくいかず、まだまだ要改善です。。。
- ハイライト対象の文言に上位のブロック要素があればあるほど、重複してハイライトされる。
- 実行時間がすこぶる遅い場合がある。
ハイライトタグが重複する件については、そもそもハイライトするテキストを検索するアルゴリズムに問題があるような気がしてます。
まとめ
ネイティブな JS で DOM を制御したことがあまり無かったため、実際かなり手こずり時間がかかりました。
しかし、ノードに関する苦手意識がだいぶ無くなったのでやって良かったです。(未完成ですが。。)
明日は@megurockさんお願いします!!
FORK Advent Calendar 2019
21日目 TouchDesignerでアタック25のアレを作る @sunnyplace
23日目 弊社アドベントカレンダーで一番いいね!を獲得したユーザは誰か? @megurock