feat: add user centric features

This commit is contained in:
gosangam
2026-05-30 23:44:08 +05:30
parent 7bd2952caa
commit dcdd2383c6
63 changed files with 16783 additions and 1735 deletions
+506 -6
View File
@@ -296,6 +296,50 @@ export function buildHighlightUrl(selectedText: string): string {
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)
@@ -327,6 +371,204 @@ export async function buildShortHighlightUrl(
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.
@@ -363,17 +605,22 @@ export function findAndHighlightText(fragment: {
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 when crossing element boundaries
// (so cross-paragraph searches work), but NOT in rawText
// (so segment offsets stay accurate for DOM highlighting).
// 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 &&
parent !== prevParent &&
prevBlock !== block &&
normText.length > 0 &&
!normText.endsWith(" ") &&
!text.startsWith(" ")
@@ -382,6 +629,7 @@ export function findAndHighlightText(fragment: {
normText += " ";
}
prevParent = parent;
prevBlock = block;
const segStart = rawText.length;
segments.push({
@@ -473,12 +721,35 @@ export function findAndHighlightText(fragment: {
collapsedNorm.length,
);
const matchStart = collapsedPosMap[matchStartNorm] ?? 0;
const matchEnd =
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);
@@ -523,3 +794,232 @@ export function findAndHighlightText(fragment: {
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();
});
}