Files
spsays-training/lib/text-highlight.ts
T
2026-05-06 00:40:24 +05:30

196 lines
6.0 KiB
TypeScript

// ---------------------------------------------------------------------------
// Text Fragment Highlight — compact URL encoding for selected text
// ---------------------------------------------------------------------------
/**
* Encode a text selection into a compact URL-safe fragment.
*
* Format: `?hl=<prefix>,<suffix>,<length>`
* - prefix: first 4 words of the selection
* - suffix: last 4 words of the selection (omitted if same as prefix)
* - length: character count of the full selection (for disambiguation)
*
* This keeps URLs short while being specific enough to locate the text.
*/
export function encodeTextFragment(text: string): string {
const words = text.split(/\s+/).filter(Boolean);
if (words.length === 0) return "";
const prefixWords = words.slice(0, 4).join(" ");
const suffixWords = words.length > 6 ? words.slice(-4).join(" ") : "";
const len = text.length;
const parts = [prefixWords];
if (suffixWords) parts.push(suffixWords);
parts.push(String(len));
return parts.join(",");
}
/**
* Decode a text fragment from URL params.
* Returns { prefix, suffix, length } or null if invalid.
*/
export function decodeTextFragment(
hl: string,
): { prefix: string; suffix: string; length: number } | null {
const parts = hl.split(",");
if (parts.length < 2) return null;
const lengthStr = parts[parts.length - 1];
const length = parseInt(lengthStr, 10);
if (isNaN(length)) return null;
if (parts.length === 2) {
// prefix + length only (short selections)
return { prefix: parts[0], suffix: "", length };
}
// prefix + suffix + length
const prefix = parts.slice(0, -2).join(","); // in case prefix itself had commas
const suffix = parts[parts.length - 2];
return { prefix, suffix, length };
}
/**
* Build the full URL with highlight fragment for the selected text.
*/
export function buildHighlightUrl(selectedText: string): string {
const fragment = encodeTextFragment(selectedText);
if (!fragment) return window.location.href;
const url = new URL(window.location.href);
// Remove any existing hl param
url.searchParams.delete("hl");
url.searchParams.set("hl", fragment);
return url.toString();
}
/**
* Find and highlight text in the DOM that matches a decoded text fragment.
* Returns true if found and highlighted.
*/
export function findAndHighlightText(fragment: {
prefix: string;
suffix: string;
length: number;
}): boolean {
// Walk all text nodes in main/article/card-content areas
const contentAreas = document.querySelectorAll(
"main, [data-slot=card-content], article, [data-content-area]",
);
if (contentAreas.length === 0) return false;
const allTextNodes: Text[] = [];
contentAreas.forEach((area) => {
const walker = document.createTreeWalker(area, NodeFilter.SHOW_TEXT);
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
if (node.textContent && node.textContent.trim().length > 0) {
allTextNodes.push(node);
}
}
});
// Build a concatenated string of all text content with node boundaries
const segments: { node: Text; start: number; end: number }[] = [];
let fullText = "";
for (const node of allTextNodes) {
const text = node.textContent || "";
segments.push({
node,
start: fullText.length,
end: fullText.length + text.length,
});
fullText += text;
}
// Normalize whitespace for matching
const normalizedFull = fullText.replace(/\s+/g, " ");
// Search for the prefix in the full text
const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ");
let searchFrom = 0;
let matchStart = -1;
while (searchFrom < normalizedFull.length) {
const idx = normalizedFull.indexOf(normalizedPrefix, searchFrom);
if (idx === -1) break;
// If we have a suffix, verify it appears near the expected length
if (fragment.suffix) {
const normalizedSuffix = fragment.suffix.replace(/\s+/g, " ");
const expectedEnd = idx + fragment.length;
const suffixSearchStart = Math.max(
idx,
expectedEnd - normalizedSuffix.length - 20,
);
const suffixSearchEnd = Math.min(normalizedFull.length, expectedEnd + 20);
const searchWindow = normalizedFull.slice(
suffixSearchStart,
suffixSearchEnd,
);
if (searchWindow.includes(normalizedSuffix)) {
matchStart = idx;
break;
}
} else {
// No suffix — just match prefix + approximate length
matchStart = idx;
break;
}
searchFrom = idx + 1;
}
if (matchStart === -1) return false;
const matchEnd = Math.min(
matchStart + fragment.length,
normalizedFull.length,
);
// Map the match range back to DOM text nodes and wrap with <mark>
for (const seg of segments) {
const overlapStart = Math.max(matchStart, seg.start);
const overlapEnd = Math.min(matchEnd, seg.end);
if (overlapStart >= overlapEnd) continue;
const node = seg.node;
const text = node.textContent || "";
const localStart = overlapStart - seg.start;
const localEnd = overlapEnd - seg.start;
// Split the text node and wrap the matching portion in a <mark>
const before = text.slice(0, localStart);
const matched = text.slice(localStart, localEnd);
const after = text.slice(localEnd);
const parent = node.parentNode;
if (!parent) continue;
const frag = document.createDocumentFragment();
if (before) frag.appendChild(document.createTextNode(before));
const mark = document.createElement("mark");
mark.className = "bg-amber-400/30 text-foreground rounded-sm px-0.5";
mark.setAttribute("data-text-highlight", "true");
mark.textContent = matched;
frag.appendChild(mark);
if (after) frag.appendChild(document.createTextNode(after));
parent.replaceChild(frag, node);
}
// Scroll to the first highlight
const firstMark = document.querySelector("[data-text-highlight]");
if (firstMark) {
setTimeout(() => {
firstMark.scrollIntoView({ behavior: "smooth", block: "center" });
}, 100);
}
return true;
}