text highlight and og image
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user