正規表現に当てはまった対象のDOMをハイライトする処理方法について
お世話になっております。
正規表現でマッチしたDOMをハイライトするプログラムを作成しております。
ざっくりとした質問で申し訳ございませんが、お助けいただけますでしょうか。。
不明点
HTMLのDOMの数(スペースやタブ文字?)等が増えると、Regex.exec
の結果オブジェクトとテキストカウンタがズレはじめるのか、ハイライトする箇所が上手くいかなくなります。
カウンタの加算が間違っているのか、そもそもアルゴリズムが間違っているのかヘルプいただけますでしょうか。
概要
DOMのスタイルがインラインスタイルでないDOM(ブロックノードとする)のtextContent
と、チェックリストの正規表現をRegex.exec
メソッドを使用して、ブロックノード配下にハイライトすべきDOMがあるか確認しています。
もしハイライト対象がある場合、ブロックノードの下層ノードを探索(都度nodeValue.length
をテキストカウンタに加算)し続け、Regex.exec
の結果オブジェクトのindex
の値(厳密には異なる)になるまで探索し、ハイライト処理に移行します。
- ハイライトする対象を囲むタグ
<span class="highlight"></span>
- テキストカウンタ:
textCount
- チェックリスト(配列):
checkList
const checkList = [
{
"regex": "おすすめ|オススメ|お勧め",
"recommendation": "ページ内で統一"
},
{
"regex": "\\d{4,}(?=円|m|メートル|km|人)",
"recommendation": "西暦をする場合のみ不要"
}
];
要件
- JSのイベントを削除を避けるため、
Regex.replace
メソッド等を使用しない。
ハイライト例
おすすめ
⇒<span class="highlight">おすすめ</span>
1000円
⇒<span class="highlight">1000</span>円
1000<span class="bold">円</span>
⇒<span class="highlight">1000</span><span class="bold">円</span>
コードスニペット
現在作成中のコードは下記のとおりです。
(function() {
let blockElementNodes = []; /* 各ノードにあるブロック要素を格納。添え字が小さいほど上階層のノード */
let ignoreBlocks = []; /* ハイライト処理を終えたエレメントを格納 */
let textCount = 0; /* 文字列をハイライトする際のテキストカウンタ */
let regexResult; /* 正規表現の結果を格納する */
let highlightCompleted = false; /* ハイライト処理が完了した場合true */
let highlightError = false; /* ハイライトできなかった場合true */
let isRemaining = false; /* ハイライト中のノードがまだあるときにtrue */
let remainingCount = 0; /* ハイライト中のノードがまだあるとき、あと何文字ハイライトすべきかを保持 */
let nextHighlightElement = null; /* ハイライト中のノードがまだあるとき、次にハイライトすべきエレメントノードを格納する */
let shouldCheckNextCondition = false; /* 次のチェックに移るべきか判定するフラグ */
let stopSearchingBlock = false; /* blockElementNodes格納時、すべての子要素の探索が終わった場合true */
/* 定数定義 */
const constantsMap = {
targetKeyName: 'regex',
reccomendKeyName: 'recommendation'
};
/* テスト用 */
const checkList = [
{
"regex": "おすすめ|オススメ|お勧め",
"recommendation": "ページ内で統一"
},
{
"regex": "\\d{4,}(?=円|m|メートル|km|人)",
"recommendation": "西暦を記載する場合のみ不要"
},
];
/**
* 引数で渡されたエレメントノードが除外対象であればtrueを返却する
*
* @param node {HTMLElement}
* @returns boolean
*/
function isIgnoredElementNode(node) {
/* 除外対象タグであればtrueを返却して終了 */
return node.tagName === 'SCRIPT' ||
node.tagName === 'NOSCRIPT' ||
node.tagName === 'BR' ||
node.tagName === 'STYLE' ||
node.tagName === 'LINK';
}
/**
*
* @param {Node} node
*/
function hasChild(node) {
return node.childNodes.length !== 0;
}
/**
* ブロック要素のエレメントノードを探索し、見つかった場合は変数 blockElementNodesにセットする
* @param {HTMLElement} node
*/
function setBlockElementNode(node) {
if (!validateNodeToSetBlockElementNodes(node)) { return; }
try {
/* 隣り合うすべてのエレメントに対して、ブロック要素が見つかれば再帰処理 */
let next = node;
do {
/* blockElementNodes配列へ格納 */
blockElementNodes.push(next);
for (let i = 0; i < next.children.length; i++) {
if (stopSearchingBlock) { break; }
const child = next.children[i];
setBlockElementNode(child);
}
if (stopSearchingBlock) { break; }
next = next.nextElementSibling;
if (next === null) {
stopSearchingBlock = true;
break;
}
} while (true);
} catch (e) {
console.log(e);
}
}
/**
*
* @param {*} node
* @returns {Boolean}
*/
function isElementNode(node) {
return node.nodeType === Node.ELEMENT_NODE;
}
/**
*
* @param {*} node
* @returns {Boolean}
*/
function isTextNode(node) {
return node.nodeType === Node.TEXT_NODE;
}
/**
* 変数 blockElementNodesへ格納するべきエレメントノードであればtrueを返却する
*
* @param {HTMLElement} node
* @returns {Boolean}
*/
function validateNodeToSetBlockElementNodes(node) {
/* ANDの検証順序はisElementNodeメソッドが一番最初に呼ばれること */
return isElementNode(node) && !(isIgnoredElementNode(node)) && isBlockElementNode(node) && hasChild(node);
}
let testStr = '';
/**
* 引数のエレメントノードがブロック要素であればtrueを返却
* @param node {HTMLElement}
* @returns {boolean}
*/
function isBlockElementNode(node) {
const style = window.getComputedStyle(node);
/* TODO:判定条件は大雑把 */
let isBlock = style.display !== 'inline' &&
style.display !== 'inline-block' &&
style.display !== 'inline-flex';
return isElementNode && isBlock;
}
/**
* チェックリストの正規表現にあてはまる文字列が存在するかをチェック
*/
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 regexStr = new RegExp(obj[constantsMap.targetKeyName], 'g');
let plainText = closestBlockNode.textContent;
while ((regexResult = regexStr.exec(plainText)) !== null) {
/* 結果オブジェクトにtitle属性に表示する文字列追加 */
regexResult.reccomend = obj[constantsMap.reccomendKeyName];
resetHighlightVariables();
/* テキストノード探索 */
findTextNode(closestBlockNode);
}
});
/* 走査対象外に追加 */
ignoreBlocks.push(closestBlockNode);
}
/**
* ハイライトに関わる変数の初期化
*/
function resetHighlightVariables() {
isRemaining = false;
remainingCount = 0;
highlightCompleted = false;
highlightError = false;
textCount = 0;
shouldCheckNextCondition = false;
}
/**
* ハイライト処理
*/
function highlightText(textNode) {
const textNodeLen = textNode.nodeValue.length;
/* ハイライトタグ作成 */
const span = document.createElement('span');
span.className = 'highlight';
span.setAttribute('title', regexResult.reccomend);
let startIndex;
let endIndex;
if (isRemaining) {
startIndex = 0;
endIndex = remainingCount;
} else {
/* ハイライトタグで囲む開始地点を設定 */
if (textCount < regexResult.index) {
startIndex = regexResult.index - textCount;
} else {
startIndex = textCount - regexResult.index;
}
endIndex = startIndex + regexResult[0].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.log(e);
isRemaining = false;
highlightError = true;
}
}
/**
* 引数のテキストノードが改行や空白を取り除いたときに空文字と等しい場合trueを返却する
* @param {string} str
*/
function getPlainString(str) {
try {
return str.replace(/^\s+|\s+$/g, '').trim();
} catch (error) {
console.log(error);
}
}
/**
* テキストノードを探索する
* @param elementNode {HTMLElement}
*/
function findTextNode(elementNode) {
try {
Array.from(elementNode.childNodes).some((node, i) => {
if (node.nodeValue === '') { return false; }
if (shouldCheckNextCondition) { return true; }
/* 要素ノードかテキストノードかを判定 */
if (isElementNode(node)) {
if (!isRemaining) {
/* チェック済みブロックであれば処理をしない */
let wasCheckedBlock = false;
ignoreBlocks.some(ignoreBlock => {
if (ignoreBlock.contains(node)) {
wasCheckedBlock = true;
return true;
}
});
if (wasCheckedBlock) { return true; }
}
if (node.childNodes.length) {
findTextNode(node);
}
} else if (isTextNode(node) && node.nodeValue !== '') {
checkHighlight(node);
//testStr += node.textContent;
if (shouldCheckNextCondition) { return true; }
}
/* ハイライトが終わっていないノードがある場合 */
if (isRemaining && nextHighlightElement) {
if (nextHighlightElement.childNodes.length) {
findTextNode(nextHighlightElement);
}
}
if (shouldCheckNextCondition) { return true; }
});
} catch (e) {
console.log(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;
}
}
/**
*
*/
function init() {
const start = performance.now();
/* 文言チェック処理 */
document.body.childNodes.forEach((node, i) => {
if (!isElementNode(node)) { return; }
/* 初期化 */
stopSearchingBlock = false;
blockElementNodes = [];
ignoreBlocks = [];
setBlockElementNode(node);
do {
let blockNElementNode = blockElementNodes.pop()
if (!blockElementNodes.length) { break; }
checkWords(blockNElementNode);
} while (blockElementNodes.length);
});
/* 文言チェック処理 */
const end = performance.now();
alert(`チェックが完了しました。\n検索時間:${Math.round((end - start) / 1000)}秒`);
}
init();
})();
.highlight { background: yellow; }
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<section>
<div class="card__bottomItem">
<p>
<span>テストおすす<span>め</span>です。
1000000<span class="card__priceSmall">円</span>
</p>
</div>
</section>
</body>
</html>