196 lines
6.0 KiB
TypeScript
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;
|
|
}
|