1044 lines
36 KiB
TypeScript
1044 lines
36 KiB
TypeScript
// ---------------------------------------------------------------------------
|
|
// Text Fragment Highlight — compact URL encoding for selected text
|
|
//
|
|
// These utilities work in both browser and server environments.
|
|
// The DOM-based findAndHighlightText() is browser-only.
|
|
// The string-based extractMatchedText() is server-safe (for OG image generation).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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)
|
|
*
|
|
* Uses `§` (section sign) as delimiter — it never appears in Sanskrit,
|
|
* Devanagari, English, or any Vedic text, unlike `,` (commas in prose)
|
|
* or `|` (pūrṇa virāma in Sanskrit verses).
|
|
*/
|
|
export function encodeTextFragment(text: string): string {
|
|
const words = text.split(/\s+/).filter(Boolean);
|
|
if (words.length === 0) return "";
|
|
|
|
const len = text.length;
|
|
|
|
// Short selections (≤8 words): use the full text as prefix, no suffix needed.
|
|
// The entire selection is specific enough to locate uniquely.
|
|
if (words.length <= 8) {
|
|
return `${text.trim()}§${len}`;
|
|
}
|
|
|
|
// Longer selections: first 4 words + last 4 words + length
|
|
const prefixWords = words.slice(0, 4).join(" ");
|
|
const suffixWords = words.slice(-4).join(" ");
|
|
|
|
return `${prefixWords}§${suffixWords}§${len}`;
|
|
}
|
|
|
|
// Map typographical (curly) quotes to ASCII so matching is resilient to
|
|
// publisher-style differences. Typesense often indexes a straight-quote form
|
|
// of the text while the rendered DOM uses curly quotes (or vice versa). Applied
|
|
// to both sides of every match so prefix/suffix and DOM text agree regardless
|
|
// of which form either one carries. Length-preserving (1 char → 1 char), so
|
|
// it does not disturb the `fragment.length` window used for disambiguation.
|
|
function normalizeQuotes(s: string): string {
|
|
return s
|
|
.replace(/[“”„‟″«»]/g, '"')
|
|
.replace(/[‘’‚‛′]/g, "'");
|
|
}
|
|
|
|
// Strip IAST / Vedic diacritics for matching. Decomposes via NFD then drops
|
|
// codepoints in the Combining Diacritical Marks block. Necessary because
|
|
// Typesense returns snippets from the `_ascii` shadow field (e.g. "Krsna")
|
|
// while the rendered DOM uses the original IAST form ("Kṛṣṇa") — without
|
|
// this normalization, a search-result link to KB 1.34 with prefix "Krsna is
|
|
// so beautiful" never matches the "Kṛṣṇa is so beautiful" in the DOM.
|
|
//
|
|
// Length-preserving for NFC input (one NFC char → one ASCII char): NFD
|
|
// expands an accented char into base + combining mark, the regex deletes
|
|
// only the combining mark, and the base is left. So position maps built
|
|
// from the original collapsed string remain valid after this transform.
|
|
function stripDiacritics(s: string): string {
|
|
return s.normalize("NFD").replace(/[̀-ͯ]/g, "");
|
|
}
|
|
|
|
// Combined normalization used on both sides of every match.
|
|
function normalizeForMatch(s: string): string {
|
|
return stripDiacritics(normalizeQuotes(s.toLowerCase()));
|
|
}
|
|
|
|
/**
|
|
* Decode a text fragment from URL params.
|
|
* Supports `§` (current), `|` (v2 legacy), and `,` (v1 legacy) delimiters.
|
|
* Returns { prefix, suffix, length } or null if invalid.
|
|
*/
|
|
export function decodeTextFragment(
|
|
hl: string,
|
|
): { prefix: string; suffix: string; length: number } | null {
|
|
const delimiter = "§";
|
|
const parts = hl.split(delimiter);
|
|
if (parts.length < 2) return null;
|
|
|
|
const lengthStr = parts[parts.length - 1].trim();
|
|
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(delimiter);
|
|
const suffix = parts[parts.length - 2];
|
|
return { prefix, suffix, length };
|
|
}
|
|
|
|
/**
|
|
* Server-side: extract the full matched text from an array of paragraph strings
|
|
* using a decoded text fragment (prefix, suffix, length).
|
|
*
|
|
* This is the server equivalent of findAndHighlightText() — instead of
|
|
* walking the DOM, it searches a flat array of HTML paragraph strings.
|
|
* HTML tags are stripped before matching.
|
|
*
|
|
* Returns the matched text string, or null if not found.
|
|
*/
|
|
export function extractMatchedText(
|
|
fragment: { prefix: string; suffix: string; length: number },
|
|
paragraphs: string[],
|
|
): string | null {
|
|
// Strip HTML tags and entities to get plain text. The entity regex must be
|
|
// strict — `&[^;]+;` would greedily pair a stray `&` (e.g. "24 & 25 Oct")
|
|
// with a far-away `;` (e.g. "my beloved son;"), eating everything between
|
|
// them. Only match real entities: named (&), decimal ('), hex (').
|
|
const plainParagraphs = paragraphs.map((p) =>
|
|
p
|
|
.replace(/<[^>]*>/g, "")
|
|
.replace(/&(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);/g, " "),
|
|
);
|
|
|
|
// Build a single string with paragraph breaks
|
|
const fullText = plainParagraphs.join(" ");
|
|
const normalized = fullText.replace(/\s+/g, " ");
|
|
const normalizedLower = normalizeForMatch(normalized);
|
|
|
|
const normalizedPrefix = normalizeForMatch(
|
|
fragment.prefix.replace(/\s+/g, " "),
|
|
);
|
|
let searchFrom = 0;
|
|
let matchStart = -1;
|
|
|
|
while (searchFrom < normalizedLower.length) {
|
|
const idx = normalizedLower.indexOf(normalizedPrefix, searchFrom);
|
|
if (idx === -1) break;
|
|
|
|
if (fragment.suffix) {
|
|
const normalizedSuffix = normalizeForMatch(
|
|
fragment.suffix.replace(/\s+/g, " "),
|
|
);
|
|
const expectedEnd = idx + fragment.length;
|
|
const suffixSearchStart = Math.max(
|
|
idx,
|
|
expectedEnd - normalizedSuffix.length - 50,
|
|
);
|
|
const suffixSearchEnd = Math.min(
|
|
normalizedLower.length,
|
|
expectedEnd + 50,
|
|
);
|
|
const searchWindow = normalizedLower.slice(
|
|
suffixSearchStart,
|
|
suffixSearchEnd,
|
|
);
|
|
if (searchWindow.includes(normalizedSuffix)) {
|
|
matchStart = idx;
|
|
break;
|
|
}
|
|
} else {
|
|
matchStart = idx;
|
|
break;
|
|
}
|
|
searchFrom = idx + 1;
|
|
}
|
|
|
|
if (matchStart === -1) return null;
|
|
|
|
// Slice from the original (not lowercased) to preserve original casing
|
|
const matchEnd = Math.min(matchStart + fragment.length, normalized.length);
|
|
const matched = normalized.slice(matchStart, matchEnd).trim();
|
|
|
|
if (matched.length === 0) return null;
|
|
|
|
return matched;
|
|
}
|
|
|
|
/**
|
|
* Extract the most relevant snippet of text from a search result snippet HTML
|
|
* (which contains `<mark>...</mark>` tags around the parts Typesense matched).
|
|
*
|
|
* Returns the continuous span from the first `<mark>` to the last `<mark>`,
|
|
* with all HTML tags stripped — i.e. the actual text the user searched for as
|
|
* it appears in the source document. Returns `null` if there are no marks or
|
|
* the result would be empty.
|
|
*
|
|
* Used to build a `?hl=` URL when navigating from a search result, so the
|
|
* destination page highlights the matched phrase.
|
|
*
|
|
* Examples:
|
|
* "...just <mark>surrender unto</mark> Me..." → "surrender unto"
|
|
* "<mark>letter</mark> to <mark>Tusta</mark> <mark>Krsna</mark>" → "letter to Tusta Krsna"
|
|
*/
|
|
export function extractMatchedTextFromSnippet(
|
|
snippetHtml: string,
|
|
): string | null {
|
|
if (!snippetHtml) return null;
|
|
|
|
const matches = Array.from(
|
|
snippetHtml.matchAll(/<mark[^>]*>([\s\S]*?)<\/mark>/gi),
|
|
);
|
|
if (matches.length === 0) return null;
|
|
|
|
const first = matches[0];
|
|
const last = matches[matches.length - 1];
|
|
|
|
const firstStart = first.index ?? 0;
|
|
const lastEnd = (last.index ?? 0) + last[0].length;
|
|
|
|
// Marked-span text — just the tokens Typesense highlighted.
|
|
const markedSpan = snippetHtml.slice(firstStart, lastEnd);
|
|
const markedPlain = normalizeQuotes(
|
|
decodeSnippetEntities(markedSpan.replace(/<[^>]*>/g, ""))
|
|
.replace(/\s+/g, " ")
|
|
.trim(),
|
|
);
|
|
|
|
// Full-snippet text — includes Typesense's surrounding context. Typesense
|
|
// snippets are direct slices of the source field, so the surrounding text
|
|
// exists verbatim in the destination page. Using it as the prefix makes
|
|
// single-token matches (e.g. `<mark>Therefore</mark> material senses...`)
|
|
// land on the *correct* occurrence on the destination page rather than the
|
|
// first instance of that token alphabetically.
|
|
const fullPlain = normalizeQuotes(
|
|
decodeSnippetEntities(snippetHtml.replace(/<[^>]*>/g, ""))
|
|
.replace(/\s+/g, " ")
|
|
// strip leading/trailing ellipsis Typesense sometimes adds
|
|
.replace(/^(?:…|\.{3,})\s*/, "")
|
|
.replace(/\s*(?:…|\.{3,})$/, "")
|
|
.trim(),
|
|
);
|
|
|
|
// Pick the best candidate:
|
|
// - if the marked span alone is already ≥6 words, it's a multi-token phrase
|
|
// and is specific enough — use it.
|
|
// - otherwise prefer the full snippet (more context = more uniquely placed),
|
|
// capped at 200 chars to keep URLs sane.
|
|
// - fall back to marked span if the full snippet is empty / unusable.
|
|
const markedWordCount = markedPlain
|
|
? markedPlain.split(/\s+/).filter(Boolean).length
|
|
: 0;
|
|
|
|
if (markedWordCount >= 6) {
|
|
return markedPlain || null;
|
|
}
|
|
if (fullPlain && fullPlain.length <= 200) {
|
|
return fullPlain;
|
|
}
|
|
return markedPlain || null;
|
|
}
|
|
|
|
// Decode the HTML entities Typesense commonly emits in match snippets.
|
|
// Curly-quote entities (“ etc.) must be decoded explicitly — if they
|
|
// fall through to the catch-all that maps unknown entities to a space, the
|
|
// resulting `?hl=` fragment loses characters that are present in the DOM,
|
|
// and findAndHighlightText fails to locate the highlight.
|
|
function decodeSnippetEntities(s: string): string {
|
|
return s
|
|
.replace(/ /gi, " ")
|
|
.replace(/&/gi, "&")
|
|
.replace(/</gi, "<")
|
|
.replace(/>/gi, ">")
|
|
.replace(/&(?:quot|#34|#x22);/gi, '"')
|
|
.replace(/&(?:apos|#39|#x27);/gi, "'")
|
|
.replace(/&(?:ldquo|rdquo|bdquo|#8220|#8221|#8222);/gi, '"')
|
|
.replace(/&(?:lsquo|rsquo|sbquo|#8216|#8217|#8218);/gi, "'")
|
|
.replace(/&[a-z]+\d*;/gi, " ")
|
|
.replace(/&#x?[0-9a-f]+;/gi, " ");
|
|
}
|
|
|
|
/**
|
|
* Build a result URL with `?hl=` appended so the destination page highlights
|
|
* the matched phrase. Preserves any other query params already on the URL.
|
|
*
|
|
* Returns the original URL unchanged if no usable highlight text could be
|
|
* extracted from the snippet.
|
|
*/
|
|
export function buildSearchResultUrl(
|
|
resultUrl: string,
|
|
snippetHtml: string,
|
|
): string {
|
|
const matched = extractMatchedTextFromSnippet(snippetHtml);
|
|
if (!matched) return resultUrl;
|
|
|
|
const fragment = encodeTextFragment(matched);
|
|
if (!fragment) return resultUrl;
|
|
|
|
// resultUrl is a relative path like "/books/bg/18/66"; we just need to
|
|
// splice in the `hl` param without disturbing any existing query string.
|
|
const [path, queryAndHash = ""] = resultUrl.split(/(?=[?#])/, 2);
|
|
const hashIdx = queryAndHash.indexOf("#");
|
|
const existingQuery =
|
|
hashIdx >= 0 ? queryAndHash.slice(0, hashIdx) : queryAndHash;
|
|
const hash = hashIdx >= 0 ? queryAndHash.slice(hashIdx) : "";
|
|
|
|
const params = new URLSearchParams(
|
|
existingQuery.startsWith("?") ? existingQuery.slice(1) : existingQuery,
|
|
);
|
|
params.set("hl", fragment);
|
|
|
|
return `${path}?${params.toString()}${hash}`;
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
/**
|
|
* Append (or replace) the `?hl=…` fragment on a path-only URL.
|
|
*
|
|
* Unlike `buildHighlightUrl`, this works without `window` — useful for
|
|
* server-rendered links and for rebuilding bookmark/post source URLs at
|
|
* render time (e.g. a bookmark saved by an older client without an `?hl=`
|
|
* still scrolls to its text when you click it, because we synthesise the
|
|
* fragment from the stored excerpt/caption).
|
|
*
|
|
* Accepts:
|
|
* path — same-origin path, may already include a `?query` (e.g.
|
|
* `/books/bg/1/1` or `/books/bg/1/1?foo=bar`).
|
|
* text — the text we want to highlight on the destination page.
|
|
*
|
|
* Returns the path with `hl` set to the encoded fragment. If `text`
|
|
* produces no fragment (e.g. empty string) returns the input unchanged.
|
|
*/
|
|
export function appendHighlight(path: string, text: string): string {
|
|
if (!text || !path) return path;
|
|
const fragment = encodeTextFragment(text);
|
|
if (!fragment) return path;
|
|
|
|
const hashIdx = path.indexOf("#");
|
|
const hash = hashIdx >= 0 ? path.slice(hashIdx) : "";
|
|
const noHash = hashIdx >= 0 ? path.slice(0, hashIdx) : path;
|
|
|
|
const qIdx = noHash.indexOf("?");
|
|
const head = qIdx >= 0 ? noHash.slice(0, qIdx) : noHash;
|
|
const query = qIdx >= 0 ? noHash.slice(qIdx + 1) : "";
|
|
const params = new URLSearchParams(query);
|
|
params.delete("hl");
|
|
params.set("hl", fragment);
|
|
return `${head}?${params.toString()}${hash}`;
|
|
}
|
|
|
|
/**
|
|
* Returns true if `path` already carries an `?hl=` query param.
|
|
* Cheap string check — avoids a URL parse on the render hot path.
|
|
*/
|
|
export function hasHighlight(path: string): boolean {
|
|
if (!path) return false;
|
|
return /[?&]hl=/.test(path);
|
|
}
|
|
|
|
/**
|
|
* Build a shortened, shareable URL for the current selection. POSTs the
|
|
* `?hl=…` URL to the BFF, which stores the mapping in Postgres (Redis-queued)
|
|
* and returns a short code. The destination `/share/<code>` page resolves
|
|
* back to the same `?hl=…` URL the existing TextHighlighter already knows
|
|
* how to process — no change to highlighting behavior.
|
|
*
|
|
* Falls back to the long URL on any failure so sharing never silently breaks.
|
|
*
|
|
* `buildHighlightUrl` (sync) is intentionally left in place so other callers
|
|
* (e.g. search-result navigation) keep working unchanged.
|
|
*/
|
|
export async function buildShortHighlightUrl(
|
|
selectedText: string,
|
|
): Promise<string> {
|
|
const longUrl = buildHighlightUrl(selectedText);
|
|
let u: URL;
|
|
try {
|
|
u = new URL(longUrl);
|
|
} catch {
|
|
return longUrl;
|
|
}
|
|
const relativePath = u.pathname + u.search + u.hash;
|
|
|
|
const { createShortLink } = await import("@/lib/share");
|
|
const code = await createShortLink(relativePath);
|
|
if (!code) return longUrl;
|
|
|
|
return new URL(`/share/${code}`, u.origin).toString();
|
|
}
|
|
|
|
// HTML block-level tags. When concatenating text nodes for matching, we
|
|
// insert a synthetic space at boundaries between *different* block-level
|
|
// ancestors so cross-paragraph selections still match (React often
|
|
// renders adjacent <p>s with no whitespace between them).
|
|
//
|
|
// Crucially, crossing an *inline* boundary (e.g. <p>In <i>Caitanya</i>
|
|
// (...) it</p>) must NOT insert a space — otherwise the prefix
|
|
// "In Caitanya (X) it" gets reconstructed as "In Caitanya ( X ) it" and
|
|
// the match fails on every passage with italicised Sanskrit terms.
|
|
const BLOCK_TAGS = new Set([
|
|
"P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6",
|
|
"LI", "UL", "OL", "BLOCKQUOTE", "SECTION", "ARTICLE",
|
|
"HEADER", "FOOTER", "NAV", "ASIDE", "MAIN", "FIGURE",
|
|
"FIGCAPTION", "PRE", "TR", "TD", "TH", "TABLE", "ADDRESS",
|
|
"HR", "DL", "DT", "DD", "BODY", "HTML",
|
|
]);
|
|
|
|
function nearestBlockAncestor(el: Element | null): Element | null {
|
|
let cur: Element | null = el;
|
|
while (cur) {
|
|
if (BLOCK_TAGS.has(cur.tagName)) return cur;
|
|
cur = cur.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Find a DOM Range matching a decoded text fragment WITHOUT modifying the
|
|
* DOM. Returns null when no match is found. Used by the bookmark-indicator
|
|
* overlay: we need each bookmark's bounding rect to anchor a floating
|
|
* icon, but we don't want a second wrapping pass on top of whatever
|
|
* findAndHighlightText (`?hl=`) might already have done on the same page.
|
|
*
|
|
* Shares the same prefix+suffix+length normalisation that
|
|
* findAndHighlightText uses so a bookmark's saved fragment matches the
|
|
* same DOM text the toolbar selected.
|
|
*/
|
|
export function findTextRange(fragment: {
|
|
prefix: string;
|
|
suffix: string;
|
|
length: number;
|
|
}): Range | null {
|
|
const contentAreas = document.querySelectorAll(
|
|
"main, [data-slot=card-content], article, [data-content-area]",
|
|
);
|
|
if (contentAreas.length === 0) return null;
|
|
|
|
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);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Same segment/normalisation pipeline as findAndHighlightText; see comments
|
|
// there. We trade a little duplication for guaranteed parity in matching.
|
|
const segments: { node: Text; start: number; end: number }[] = [];
|
|
let rawText = "";
|
|
let normText = "";
|
|
const posMap: number[] = [];
|
|
let prevParent: Element | null = null;
|
|
let prevBlock: Element | null = null;
|
|
for (const node of allTextNodes) {
|
|
const text = node.textContent || "";
|
|
const parent = node.parentElement;
|
|
const block = nearestBlockAncestor(parent);
|
|
if (
|
|
prevParent &&
|
|
prevBlock !== block &&
|
|
normText.length > 0 &&
|
|
!normText.endsWith(" ") &&
|
|
!text.startsWith(" ")
|
|
) {
|
|
posMap.push(rawText.length);
|
|
normText += " ";
|
|
}
|
|
prevParent = parent;
|
|
prevBlock = block;
|
|
const segStart = rawText.length;
|
|
segments.push({ node, start: segStart, end: segStart + text.length });
|
|
for (let i = 0; i < text.length; i++) {
|
|
posMap.push(rawText.length + i);
|
|
normText += text[i];
|
|
}
|
|
rawText += text;
|
|
}
|
|
let collapsedNorm = "";
|
|
const collapsedPosMap: number[] = [];
|
|
let lastWasSpace = false;
|
|
for (let i = 0; i < normText.length; i++) {
|
|
const ch = normText[i];
|
|
const isSpace = /\s/.test(ch);
|
|
if (isSpace) {
|
|
if (!lastWasSpace) {
|
|
collapsedPosMap.push(posMap[i]);
|
|
collapsedNorm += " ";
|
|
}
|
|
lastWasSpace = true;
|
|
} else {
|
|
collapsedPosMap.push(posMap[i]);
|
|
collapsedNorm += ch;
|
|
lastWasSpace = false;
|
|
}
|
|
}
|
|
const normalizedFullLower = normalizeForMatch(collapsedNorm);
|
|
const normalizedPrefix = normalizeForMatch(
|
|
fragment.prefix.replace(/\s+/g, " "),
|
|
);
|
|
let searchFrom = 0;
|
|
let matchStartNorm = -1;
|
|
while (searchFrom < normalizedFullLower.length) {
|
|
const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom);
|
|
if (idx === -1) break;
|
|
if (fragment.suffix) {
|
|
const normalizedSuffix = normalizeForMatch(
|
|
fragment.suffix.replace(/\s+/g, " "),
|
|
);
|
|
const expectedEndNorm = idx + fragment.length;
|
|
const suffixSearchStart = Math.max(
|
|
idx,
|
|
expectedEndNorm - normalizedSuffix.length - 50,
|
|
);
|
|
const suffixSearchEnd = Math.min(
|
|
normalizedFullLower.length,
|
|
expectedEndNorm + 50,
|
|
);
|
|
const searchWindow = normalizedFullLower.slice(
|
|
suffixSearchStart,
|
|
suffixSearchEnd,
|
|
);
|
|
if (searchWindow.includes(normalizedSuffix)) {
|
|
matchStartNorm = idx;
|
|
break;
|
|
}
|
|
} else {
|
|
matchStartNorm = idx;
|
|
break;
|
|
}
|
|
searchFrom = idx + 1;
|
|
}
|
|
if (matchStartNorm === -1) return null;
|
|
const matchEndNorm = Math.min(
|
|
matchStartNorm + fragment.length,
|
|
collapsedNorm.length,
|
|
);
|
|
let matchStart = collapsedPosMap[matchStartNorm] ?? 0;
|
|
let matchEnd =
|
|
matchEndNorm < collapsedPosMap.length
|
|
? collapsedPosMap[matchEndNorm]
|
|
: rawText.length;
|
|
|
|
// Defensive raw-text alignment — same logic as the other matchers.
|
|
// Use fragment.length for range fragments so the selection isn't
|
|
// collapsed to just the prefix.
|
|
const rawLower = rawText.toLowerCase();
|
|
const prefixLower = fragment.prefix.toLowerCase();
|
|
const directIdx = rawLower.indexOf(prefixLower);
|
|
if (directIdx >= 0) {
|
|
matchStart = directIdx;
|
|
const endByLength =
|
|
fragment.suffix && fragment.length > fragment.prefix.length
|
|
? directIdx + fragment.length
|
|
: directIdx + fragment.prefix.length;
|
|
matchEnd = Math.min(endByLength, rawText.length);
|
|
}
|
|
|
|
// Map raw-text offsets back to (textNode, offsetInNode) anchors for the Range.
|
|
let startAnchor: { node: Text; offset: number } | null = null;
|
|
let endAnchor: { node: Text; offset: number } | null = null;
|
|
for (const seg of segments) {
|
|
if (
|
|
!startAnchor &&
|
|
matchStart >= seg.start &&
|
|
matchStart <= seg.end
|
|
) {
|
|
startAnchor = { node: seg.node, offset: matchStart - seg.start };
|
|
}
|
|
if (matchEnd >= seg.start && matchEnd <= seg.end) {
|
|
endAnchor = { node: seg.node, offset: matchEnd - seg.start };
|
|
}
|
|
if (startAnchor && endAnchor) break;
|
|
}
|
|
if (!startAnchor || !endAnchor) return null;
|
|
|
|
try {
|
|
const range = document.createRange();
|
|
range.setStart(startAnchor.node, startAnchor.offset);
|
|
range.setEnd(endAnchor.node, endAnchor.offset);
|
|
return range;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
// We keep two parallel strings:
|
|
// rawText — exact concatenation of text nodes (for accurate segment mapping)
|
|
// normText — with spaces inserted at element boundaries (for matching)
|
|
// And a posMap that maps each normText index back to a rawText index.
|
|
const segments: { node: Text; start: number; end: number }[] = [];
|
|
let rawText = "";
|
|
let normText = "";
|
|
const posMap: number[] = []; // posMap[normIdx] = rawIdx
|
|
let prevParent: Element | null = null;
|
|
let prevBlock: Element | null = null;
|
|
|
|
for (const node of allTextNodes) {
|
|
const text = node.textContent || "";
|
|
const parent = node.parentElement;
|
|
const block = nearestBlockAncestor(parent);
|
|
|
|
// Insert a space in normText only when crossing a *block-level*
|
|
// boundary (so cross-paragraph searches still work), but NOT when
|
|
// crossing inline boundaries like <i>, <em>, <span> — otherwise a
|
|
// selection like "(Madhya-līlā) it" gets reconstructed as
|
|
// "( Madhya-līlā ) it" and the prefix match fails. rawText stays
|
|
// exact so DOM offsets remain accurate for highlighting.
|
|
if (
|
|
prevParent &&
|
|
prevBlock !== block &&
|
|
normText.length > 0 &&
|
|
!normText.endsWith(" ") &&
|
|
!text.startsWith(" ")
|
|
) {
|
|
posMap.push(rawText.length); // the synthetic space maps to current raw position
|
|
normText += " ";
|
|
}
|
|
prevParent = parent;
|
|
prevBlock = block;
|
|
|
|
const segStart = rawText.length;
|
|
segments.push({
|
|
node,
|
|
start: segStart,
|
|
end: segStart + text.length,
|
|
});
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
posMap.push(rawText.length + i);
|
|
normText += text[i];
|
|
}
|
|
rawText += text;
|
|
}
|
|
|
|
// Collapse whitespace in normText for matching, rebuilding posMap
|
|
let collapsedNorm = "";
|
|
const collapsedPosMap: number[] = [];
|
|
let lastWasSpace = false;
|
|
for (let i = 0; i < normText.length; i++) {
|
|
const ch = normText[i];
|
|
const isSpace = /\s/.test(ch);
|
|
if (isSpace) {
|
|
if (!lastWasSpace) {
|
|
collapsedPosMap.push(posMap[i]);
|
|
collapsedNorm += " ";
|
|
}
|
|
lastWasSpace = true;
|
|
} else {
|
|
collapsedPosMap.push(posMap[i]);
|
|
collapsedNorm += ch;
|
|
lastWasSpace = false;
|
|
}
|
|
}
|
|
|
|
// Normalize for matching so prefix/suffix and DOM text agree on quote style
|
|
// and diacritics (search snippets come from the `_ascii` shadow field, the
|
|
// DOM renders the original IAST form). Length-preserving for NFC input, so
|
|
// collapsedPosMap indices remain valid against rawText.
|
|
const normalizedFullLower = normalizeForMatch(collapsedNorm);
|
|
|
|
// Search for the prefix in the full text (case-insensitive)
|
|
const normalizedPrefix = normalizeForMatch(
|
|
fragment.prefix.replace(/\s+/g, " "),
|
|
);
|
|
let searchFrom = 0;
|
|
let matchStartNorm = -1;
|
|
|
|
while (searchFrom < normalizedFullLower.length) {
|
|
const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom);
|
|
if (idx === -1) break;
|
|
|
|
// If we have a suffix, verify it appears near the expected length
|
|
if (fragment.suffix) {
|
|
const normalizedSuffix = normalizeForMatch(
|
|
fragment.suffix.replace(/\s+/g, " "),
|
|
);
|
|
// Search for suffix near the expected end position in the normalized string.
|
|
// Use a wider tolerance window (±50) since normalization can shift positions.
|
|
const expectedEndNorm = idx + fragment.length;
|
|
const suffixSearchStart = Math.max(
|
|
idx,
|
|
expectedEndNorm - normalizedSuffix.length - 50,
|
|
);
|
|
const suffixSearchEnd = Math.min(
|
|
normalizedFullLower.length,
|
|
expectedEndNorm + 50,
|
|
);
|
|
const searchWindow = normalizedFullLower.slice(
|
|
suffixSearchStart,
|
|
suffixSearchEnd,
|
|
);
|
|
if (searchWindow.includes(normalizedSuffix)) {
|
|
matchStartNorm = idx;
|
|
break;
|
|
}
|
|
} else {
|
|
matchStartNorm = idx;
|
|
break;
|
|
}
|
|
searchFrom = idx + 1;
|
|
}
|
|
|
|
if (matchStartNorm === -1) return false;
|
|
|
|
// Map normalized match range back to raw text positions via posMap
|
|
const matchEndNorm = Math.min(
|
|
matchStartNorm + fragment.length,
|
|
collapsedNorm.length,
|
|
);
|
|
|
|
let matchStart = collapsedPosMap[matchStartNorm] ?? 0;
|
|
let matchEnd =
|
|
matchEndNorm < collapsedPosMap.length
|
|
? collapsedPosMap[matchEndNorm]
|
|
: rawText.length;
|
|
|
|
// Defensive raw-text alignment — same logic as markFragmentAsBookmark.
|
|
// Prefer the raw-text index when the prefix is plain enough to match
|
|
// directly, since collapsedPosMap can drift on certain inputs.
|
|
//
|
|
// The matchEnd choice depends on the fragment shape (see encoder):
|
|
// • prefix-only (no suffix) → the prefix IS the full selection, so
|
|
// matchEnd = directIdx + prefix.length.
|
|
// • prefix + suffix → the selection spans from prefix-start to
|
|
// suffix-end, and `fragment.length` is that full span. We must use
|
|
// it; collapsing to just prefix.length here was the bug that made
|
|
// long highlights show only the first 4 words.
|
|
const rawLower = rawText.toLowerCase();
|
|
const prefixLower = fragment.prefix.toLowerCase();
|
|
const directIdx = rawLower.indexOf(prefixLower);
|
|
if (directIdx >= 0) {
|
|
matchStart = directIdx;
|
|
const endByLength =
|
|
fragment.suffix && fragment.length > fragment.prefix.length
|
|
? directIdx + fragment.length
|
|
: directIdx + fragment.prefix.length;
|
|
matchEnd = Math.min(endByLength, rawText.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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Persistent bookmark marks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Wrap the bookmarked passage in a `<mark data-bookmark-id="X">` with a
|
|
* violet wash so the user sees their saved passage every time they visit
|
|
* the page, until they remove the bookmark.
|
|
*
|
|
* Distinct from `findAndHighlightText` in three ways:
|
|
* • Persistent — does NOT scroll into view (only the floating icon
|
|
* handles that affordance) and uses a different colour from `?hl=`
|
|
* (amber) so transient share-link landings stay visually distinct.
|
|
* • Idempotent — if a mark for this bookmark id already exists, we
|
|
* return the existing nodes without re-wrapping.
|
|
* • Returns the created (or existing) `<mark>` elements so the caller
|
|
* can measure their bounding rect to anchor a floating indicator.
|
|
*
|
|
* The matching pipeline mirrors `findAndHighlightText`; we intentionally
|
|
* inline the duplicated logic for now (clear-and-obvious beats clever
|
|
* abstraction while we iterate on this feature).
|
|
*/
|
|
export function markFragmentAsBookmark(
|
|
fragment: { prefix: string; suffix: string; length: number },
|
|
bookmarkId: string,
|
|
): HTMLElement[] {
|
|
// Idempotency — bail if marks for this bookmark are already in the DOM.
|
|
const existing = Array.from(
|
|
document.querySelectorAll<HTMLElement>(
|
|
`[data-bookmark-id="${CSS.escape(bookmarkId)}"]`,
|
|
),
|
|
);
|
|
if (existing.length > 0) return existing;
|
|
|
|
const contentAreas = document.querySelectorAll(
|
|
"main, [data-slot=card-content], article, [data-content-area]",
|
|
);
|
|
if (contentAreas.length === 0) return [];
|
|
|
|
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);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Same segment/normalisation pipeline as findAndHighlightText; see the
|
|
// BLOCK_TAGS comment above for the inline-vs-block boundary rule.
|
|
const segments: { node: Text; start: number; end: number }[] = [];
|
|
let rawText = "";
|
|
let normText = "";
|
|
const posMap: number[] = [];
|
|
let prevParent: Element | null = null;
|
|
let prevBlock: Element | null = null;
|
|
for (const node of allTextNodes) {
|
|
const text = node.textContent || "";
|
|
const parent = node.parentElement;
|
|
const block = nearestBlockAncestor(parent);
|
|
if (
|
|
prevParent &&
|
|
prevBlock !== block &&
|
|
normText.length > 0 &&
|
|
!normText.endsWith(" ") &&
|
|
!text.startsWith(" ")
|
|
) {
|
|
posMap.push(rawText.length);
|
|
normText += " ";
|
|
}
|
|
prevParent = parent;
|
|
prevBlock = block;
|
|
const segStart = rawText.length;
|
|
segments.push({ node, start: segStart, end: segStart + text.length });
|
|
for (let i = 0; i < text.length; i++) {
|
|
posMap.push(rawText.length + i);
|
|
normText += text[i];
|
|
}
|
|
rawText += text;
|
|
}
|
|
let collapsedNorm = "";
|
|
const collapsedPosMap: number[] = [];
|
|
let lastWasSpace = false;
|
|
for (let i = 0; i < normText.length; i++) {
|
|
const ch = normText[i];
|
|
const isSpace = /\s/.test(ch);
|
|
if (isSpace) {
|
|
if (!lastWasSpace) {
|
|
collapsedPosMap.push(posMap[i]);
|
|
collapsedNorm += " ";
|
|
}
|
|
lastWasSpace = true;
|
|
} else {
|
|
collapsedPosMap.push(posMap[i]);
|
|
collapsedNorm += ch;
|
|
lastWasSpace = false;
|
|
}
|
|
}
|
|
const normalizedFullLower = normalizeForMatch(collapsedNorm);
|
|
const normalizedPrefix = normalizeForMatch(
|
|
fragment.prefix.replace(/\s+/g, " "),
|
|
);
|
|
let searchFrom = 0;
|
|
let matchStartNorm = -1;
|
|
while (searchFrom < normalizedFullLower.length) {
|
|
const idx = normalizedFullLower.indexOf(normalizedPrefix, searchFrom);
|
|
if (idx === -1) break;
|
|
if (fragment.suffix) {
|
|
const normalizedSuffix = normalizeForMatch(
|
|
fragment.suffix.replace(/\s+/g, " "),
|
|
);
|
|
const expectedEndNorm = idx + fragment.length;
|
|
const suffixSearchStart = Math.max(
|
|
idx,
|
|
expectedEndNorm - normalizedSuffix.length - 50,
|
|
);
|
|
const suffixSearchEnd = Math.min(
|
|
normalizedFullLower.length,
|
|
expectedEndNorm + 50,
|
|
);
|
|
const searchWindow = normalizedFullLower.slice(
|
|
suffixSearchStart,
|
|
suffixSearchEnd,
|
|
);
|
|
if (searchWindow.includes(normalizedSuffix)) {
|
|
matchStartNorm = idx;
|
|
break;
|
|
}
|
|
} else {
|
|
matchStartNorm = idx;
|
|
break;
|
|
}
|
|
searchFrom = idx + 1;
|
|
}
|
|
if (matchStartNorm === -1) return [];
|
|
|
|
const matchEndNorm = Math.min(
|
|
matchStartNorm + fragment.length,
|
|
collapsedNorm.length,
|
|
);
|
|
let matchStart = collapsedPosMap[matchStartNorm] ?? 0;
|
|
let matchEnd =
|
|
matchEndNorm < collapsedPosMap.length
|
|
? collapsedPosMap[matchEndNorm]
|
|
: rawText.length;
|
|
|
|
// Defensive: cross-check the position against a direct case-insensitive
|
|
// search in rawText. If the prefix is plain enough to find directly,
|
|
// prefer the raw-text index — collapsedPosMap can drift by one when the
|
|
// normalization pipeline produces different code-unit counts than the
|
|
// original (NFD decomposition, Unicode lowercase widening, etc.), and
|
|
// that one-character shift is what dropped the leading character of
|
|
// matches in the wild.
|
|
//
|
|
// For range fragments (prefix + suffix), `fragment.length` is the full
|
|
// span — using prefix.length here would shrink the highlight to just
|
|
// the first few words.
|
|
const rawLower = rawText.toLowerCase();
|
|
const prefixLower = fragment.prefix.toLowerCase();
|
|
const directIdx = rawLower.indexOf(prefixLower);
|
|
if (directIdx >= 0) {
|
|
matchStart = directIdx;
|
|
const endByLength =
|
|
fragment.suffix && fragment.length > fragment.prefix.length
|
|
? directIdx + fragment.length
|
|
: directIdx + fragment.prefix.length;
|
|
matchEnd = Math.min(endByLength, rawText.length);
|
|
}
|
|
|
|
// Tailwind classes — violet wash + slight rounded corners. Picked to be
|
|
// visually distinct from the amber used by ?hl= so a user landing on a
|
|
// share-link sees the transient amber on TOP of any persistent violet
|
|
// bookmark mark (two colours = clearly two concepts).
|
|
const MARK_CLASS =
|
|
"bg-violet-300/45 dark:bg-violet-400/30 text-foreground rounded-sm px-0.5";
|
|
|
|
const created: HTMLElement[] = [];
|
|
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;
|
|
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 = MARK_CLASS;
|
|
mark.setAttribute("data-bookmark-id", bookmarkId);
|
|
mark.textContent = matched;
|
|
frag.appendChild(mark);
|
|
if (after) frag.appendChild(document.createTextNode(after));
|
|
parent.replaceChild(frag, node);
|
|
created.push(mark);
|
|
}
|
|
return created;
|
|
}
|
|
|
|
/**
|
|
* Unwrap bookmark marks. Pass an id to remove a single bookmark's marks;
|
|
* pass nothing to remove every bookmark mark in the document (used when
|
|
* the PageBookmarks overlay unmounts on route change).
|
|
*/
|
|
export function removeBookmarkMarks(bookmarkId?: string): void {
|
|
const selector = bookmarkId
|
|
? `[data-bookmark-id="${CSS.escape(bookmarkId)}"]`
|
|
: `[data-bookmark-id]`;
|
|
document.querySelectorAll<HTMLElement>(selector).forEach((mark) => {
|
|
const parent = mark.parentNode;
|
|
if (!parent) return;
|
|
parent.replaceChild(
|
|
document.createTextNode(mark.textContent ?? ""),
|
|
mark,
|
|
);
|
|
// Merge adjacent text nodes so subsequent matchers see a clean tree.
|
|
parent.normalize();
|
|
});
|
|
}
|