134 lines
3.5 KiB
JavaScript
134 lines
3.5 KiB
JavaScript
/**
|
||
* Utility functions do podświetlania wyszukiwanych fraz
|
||
* Używane przez komponenty search
|
||
*/
|
||
|
||
/**
|
||
* Escape RegExp special characters
|
||
*/
|
||
export function escapeRegExp(str) {
|
||
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
/**
|
||
* Podświetlenie w czystym tekście (np. title)
|
||
*
|
||
* @param {string} text - Tekst do podświetlenia
|
||
* @param {string} query - Fraza do wyszukania
|
||
* @returns {string|Array} - Tekst lub array z elementami JSX
|
||
*
|
||
* @example
|
||
* highlightText("Hello World", "world")
|
||
* // => ["Hello ", <mark class="f-hl">World</mark>]
|
||
*/
|
||
export function highlightText(text, query) {
|
||
const trimmedQuery = (query || '').trim();
|
||
if (!trimmedQuery) return text;
|
||
|
||
const re = new RegExp(escapeRegExp(trimmedQuery), 'ig');
|
||
const parts = String(text || '').split(re);
|
||
|
||
if (parts.length === 1) return text;
|
||
|
||
// split() gubi match – więc budujemy przez exec/match
|
||
const matches = String(text || '').match(re) || [];
|
||
|
||
const result = [];
|
||
for (let i = 0; i < parts.length; i++) {
|
||
result.push(parts[i]);
|
||
if (i < matches.length) {
|
||
result.push(<mark class="f-hl">{matches[i]}</mark>);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Podświetlenie wewnątrz HTML (po markdown)
|
||
* Omija tagi PRE, CODE, SCRIPT, STYLE
|
||
*
|
||
* @param {string} html - HTML do podświetlenia
|
||
* @param {string} query - Fraza do wyszukania
|
||
* @returns {string} - HTML z podświetleniem
|
||
*
|
||
* @example
|
||
* const html = "<p>Hello World</p>";
|
||
* highlightHtml(html, "world")
|
||
* // => "<p>Hello <mark class="f-hl">World</mark></p>"
|
||
*/
|
||
export function highlightHtml(html, query) {
|
||
const trimmedQuery = (query || '').trim();
|
||
if (!trimmedQuery) return html;
|
||
|
||
const re = new RegExp(escapeRegExp(trimmedQuery), 'ig');
|
||
|
||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||
const root = doc.body;
|
||
|
||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
||
|
||
// Funkcja sprawdzająca czy node jest w tagu do pominięcia
|
||
const shouldSkipNode = (node) => {
|
||
const parent = node.parentElement;
|
||
if (!parent) return true;
|
||
|
||
const tagName = parent.tagName;
|
||
return tagName === 'SCRIPT' ||
|
||
tagName === 'STYLE' ||
|
||
tagName === 'CODE' ||
|
||
tagName === 'PRE';
|
||
};
|
||
|
||
// Zbierz wszystkie text nodes
|
||
const textNodes = [];
|
||
let node;
|
||
while ((node = walker.nextNode())) {
|
||
textNodes.push(node);
|
||
}
|
||
|
||
// Podświetl każdy text node
|
||
for (const textNode of textNodes) {
|
||
if (shouldSkipNode(textNode)) continue;
|
||
|
||
const text = textNode.nodeValue || '';
|
||
if (!re.test(text)) continue;
|
||
|
||
// Reset RegExp state (test() z /g/ przesuwa lastIndex)
|
||
re.lastIndex = 0;
|
||
|
||
const fragment = doc.createDocumentFragment();
|
||
let lastIndex = 0;
|
||
let match;
|
||
|
||
while ((match = re.exec(text))) {
|
||
const matchStart = match.index;
|
||
const matchEnd = matchStart + match[0].length;
|
||
|
||
// Tekst przed match
|
||
if (matchStart > lastIndex) {
|
||
fragment.appendChild(
|
||
doc.createTextNode(text.slice(lastIndex, matchStart))
|
||
);
|
||
}
|
||
|
||
// Match w <mark>
|
||
const mark = doc.createElement('mark');
|
||
mark.className = 'f-hl';
|
||
mark.textContent = text.slice(matchStart, matchEnd);
|
||
fragment.appendChild(mark);
|
||
|
||
lastIndex = matchEnd;
|
||
}
|
||
|
||
// Tekst po ostatnim match
|
||
if (lastIndex < text.length) {
|
||
fragment.appendChild(doc.createTextNode(text.slice(lastIndex)));
|
||
}
|
||
|
||
// Zastąp text node fragmentem
|
||
textNode.parentNode?.replaceChild(fragment, textNode);
|
||
}
|
||
|
||
return root.innerHTML;
|
||
} |