feat: add user centric features
This commit is contained in:
+8
-1
@@ -17,9 +17,11 @@ const BFF_BASE =
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
handle: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
plan: "free" | "premium";
|
||||
email_verified_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -110,12 +112,17 @@ export const auth = {
|
||||
resendVerification: () =>
|
||||
authFetch<{ ok: boolean }>("/auth/resend-verification", { method: "POST" }),
|
||||
|
||||
updateProfile: (name?: string, avatarURL?: string | null) =>
|
||||
updateProfile: (
|
||||
name?: string,
|
||||
avatarURL?: string | null,
|
||||
handle?: string,
|
||||
) =>
|
||||
authFetch<User>("/users/me", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
name: name ?? undefined,
|
||||
avatar_url: avatarURL === undefined ? undefined : avatarURL,
|
||||
handle: handle ?? undefined,
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Browser-side BFF client for the social features: posts, bookmarks,
|
||||
* collections, public profiles.
|
||||
*
|
||||
* All requests run with `credentials: "include"` so the auth cookie set
|
||||
* by /auth/login is sent along automatically. Errors come back as
|
||||
* `SocialError` instances carrying the HTTP status code and the message
|
||||
* from the BFF envelope.
|
||||
*
|
||||
* Endpoint paths mirror the BFF routes 1:1 — there's no Next.js proxy
|
||||
* for these in the current setup (BFF is on the same registrable domain
|
||||
* in prod, and localhost is same-site in dev).
|
||||
*/
|
||||
|
||||
const BFF_BASE =
|
||||
process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
|
||||
|
||||
export class SocialError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SocialError";
|
||||
}
|
||||
}
|
||||
|
||||
type Envelope<T> = {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: number; message: string };
|
||||
meta?: { page: number; limit: number; total: number; total_pages: number };
|
||||
};
|
||||
|
||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
if (res.status === 204) return undefined as T;
|
||||
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
|
||||
if (!res.ok || body.success === false) {
|
||||
throw new SocialError(res.status, body.error?.message || res.statusText);
|
||||
}
|
||||
return (body.data ?? (undefined as unknown as T)) as T;
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type PostImage = { id: string; url: string; position: number };
|
||||
|
||||
export type Post = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
caption: string;
|
||||
source_url: string;
|
||||
source_title: string;
|
||||
images: PostImage[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
author_name?: string;
|
||||
author_handle?: string;
|
||||
author_avatar_url?: string | null;
|
||||
};
|
||||
|
||||
export type Bookmark = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
source_url: string;
|
||||
source_title: string;
|
||||
excerpt: string | null;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Collection = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
cover_url: string | null;
|
||||
is_public: boolean;
|
||||
item_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type PublicProfile = {
|
||||
handle: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
joined_at: string;
|
||||
post_count: number;
|
||||
public_collections: Collection[];
|
||||
};
|
||||
|
||||
// ─── Posts ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const posts = {
|
||||
create(input: {
|
||||
caption: string;
|
||||
source_url: string;
|
||||
source_title?: string;
|
||||
image_urls?: string[];
|
||||
}) {
|
||||
return call<Post>("/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
caption: input.caption,
|
||||
source_url: input.source_url,
|
||||
source_title: input.source_title ?? "",
|
||||
image_urls: input.image_urls ?? [],
|
||||
}),
|
||||
});
|
||||
},
|
||||
get(id: string) {
|
||||
return call<Post>(`/posts/${id}`);
|
||||
},
|
||||
delete(id: string) {
|
||||
return call<void>(`/posts/${id}`, { method: "DELETE" });
|
||||
},
|
||||
/** Swap the post's image set. Empty array removes all images. */
|
||||
replaceImages(id: string, image_urls: string[]) {
|
||||
return call<Post>(`/posts/${id}/images`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ image_urls }),
|
||||
});
|
||||
},
|
||||
mine() {
|
||||
return call<Post[]>("/posts/mine");
|
||||
},
|
||||
/** R2 presigned PUT for a single post image. Returns the public URL the post should persist. */
|
||||
async uploadImage(file: File): Promise<string> {
|
||||
const { upload_url, public_url } = await call<{
|
||||
upload_url: string;
|
||||
public_url: string;
|
||||
}>("/posts/image-presign", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content_type: file.type }),
|
||||
});
|
||||
const put = await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
});
|
||||
if (!put.ok) throw new SocialError(put.status, "image upload failed");
|
||||
return public_url;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Bookmarks ───────────────────────────────────────────────────────────
|
||||
|
||||
export const bookmarks = {
|
||||
create(input: {
|
||||
source_url: string;
|
||||
source_title?: string;
|
||||
excerpt?: string;
|
||||
note?: string;
|
||||
}) {
|
||||
return call<Bookmark>("/bookmarks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
source_url: input.source_url,
|
||||
source_title: input.source_title ?? "",
|
||||
excerpt: input.excerpt ?? null,
|
||||
note: input.note ?? null,
|
||||
}),
|
||||
});
|
||||
},
|
||||
list() {
|
||||
return call<Bookmark[]>("/bookmarks");
|
||||
},
|
||||
delete(id: string) {
|
||||
return call<void>(`/bookmarks/${id}`, { method: "DELETE" });
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Collections ─────────────────────────────────────────────────────────
|
||||
|
||||
export const collections = {
|
||||
create(input: {
|
||||
title: string;
|
||||
description?: string;
|
||||
cover_url?: string;
|
||||
is_public?: boolean;
|
||||
}) {
|
||||
return call<Collection>("/collections", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
cover_url: input.cover_url ?? null,
|
||||
is_public: !!input.is_public,
|
||||
}),
|
||||
});
|
||||
},
|
||||
update(
|
||||
id: string,
|
||||
input: Partial<{
|
||||
title: string;
|
||||
description: string;
|
||||
cover_url: string;
|
||||
is_public: boolean;
|
||||
}>,
|
||||
) {
|
||||
return call<Collection>(`/collections/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
title: input.title ?? undefined,
|
||||
description: input.description ?? undefined,
|
||||
cover_url: input.cover_url === undefined ? undefined : input.cover_url,
|
||||
is_public: input.is_public ?? undefined,
|
||||
}),
|
||||
});
|
||||
},
|
||||
delete(id: string) {
|
||||
return call<void>(`/collections/${id}`, { method: "DELETE" });
|
||||
},
|
||||
mine() {
|
||||
return call<Collection[]>("/collections");
|
||||
},
|
||||
/** Public-or-private depending on visibility. Anonymous viewers get 403 on private collections. */
|
||||
get(id: string) {
|
||||
return call<{ collection: Collection; posts: Post[] }>(`/collections/${id}`);
|
||||
},
|
||||
addItem(collectionID: string, postID: string) {
|
||||
return call<{ ok: boolean }>(`/collections/${collectionID}/items`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ post_id: postID }),
|
||||
});
|
||||
},
|
||||
removeItem(collectionID: string, postID: string) {
|
||||
return call<void>(`/collections/${collectionID}/items/${postID}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
/** Returns the ids of the caller's collections that already contain
|
||||
* the given post. The save-to-collection drawer uses this to disable
|
||||
* rows so the post can't be added a second time. */
|
||||
async containingPost(postID: string): Promise<string[]> {
|
||||
const r = await call<{ collection_ids: string[] }>(
|
||||
`/collections/containing/${postID}`,
|
||||
);
|
||||
return r.collection_ids ?? [];
|
||||
},
|
||||
/** Rewrite the position of every post in `post_ids` to match the array
|
||||
* order. The BFF runs the updates in a single tx — readers see either
|
||||
* the old order or the new one, never a half-applied state. */
|
||||
reorderItems(collectionID: string, post_ids: string[]) {
|
||||
return call<void>(`/collections/${collectionID}/items/order`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ post_ids }),
|
||||
});
|
||||
},
|
||||
/** R2 presigned PUT for the collection cover. */
|
||||
async uploadCover(file: File): Promise<string> {
|
||||
const { upload_url, public_url } = await call<{
|
||||
upload_url: string;
|
||||
public_url: string;
|
||||
}>("/collections/cover-presign", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content_type: file.type }),
|
||||
});
|
||||
const put = await fetch(upload_url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
});
|
||||
if (!put.ok) throw new SocialError(put.status, "cover upload failed");
|
||||
return public_url;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Public profile ──────────────────────────────────────────────────────
|
||||
|
||||
export const profile = {
|
||||
get(handle: string) {
|
||||
return call<PublicProfile>(`/u/${encodeURIComponent(handle)}`);
|
||||
},
|
||||
feed(handle: string, limit = 20, offset = 0) {
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
return call<Post[]>(`/u/${encodeURIComponent(handle)}/feed?${qs}`);
|
||||
},
|
||||
};
|
||||
@@ -247,6 +247,128 @@ export function getSourceReference(): {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-only variant of getSourceReference — pure function, safe on server.
|
||||
*
|
||||
* For `/books/...` paths we can derive a clean citation (e.g.
|
||||
* "Bhagavad-gītā As It Is — Chapter 1, Verse 1") from the path alone.
|
||||
* Mirrors the logic in `lib/og.ts`'s `ogForVerse` so post cards display
|
||||
* the same reference the OG image carries.
|
||||
*
|
||||
* For non-book paths the title lives in the page DOM (h1) which we don't
|
||||
* have at card-render time, so we return null — callers should fall back
|
||||
* to whatever `source_title` was saved at compose time.
|
||||
*/
|
||||
export function referenceFromUrl(
|
||||
sourceUrl: string,
|
||||
): { title: string; detail: string | null } | null {
|
||||
if (!sourceUrl) return null;
|
||||
let path: string;
|
||||
try {
|
||||
if (sourceUrl.startsWith("/")) {
|
||||
path = sourceUrl.split("?")[0];
|
||||
} else {
|
||||
const u = new URL(sourceUrl);
|
||||
path = u.pathname;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
if (parts[0] !== "books" || parts.length < 2) return null;
|
||||
|
||||
const code = parts[1];
|
||||
const bookName = BOOK_NAMES[code] || code.toUpperCase();
|
||||
|
||||
// Verse-based: /books/bg/[chapter]/[verse]
|
||||
if (code === "bg" && parts.length >= 4) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
|
||||
};
|
||||
}
|
||||
// SB: /books/sb/[canto]/[chapter]/[verse]
|
||||
if (code === "sb" && parts.length >= 5) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Canto ${parts[2]}, Chapter ${parts[3]}, Verse ${parts[4]}`,
|
||||
};
|
||||
}
|
||||
// CC: /books/cc/[lila]/[chapter]/[verse]
|
||||
if (code === "cc" && parts.length >= 5) {
|
||||
const lila = CC_LILAS[parts[2]] || parts[2];
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `${lila}, Chapter ${parts[3]}, Verse ${parts[4]}`,
|
||||
};
|
||||
}
|
||||
// CC lila index: /books/cc/[lila]
|
||||
if (code === "cc" && parts.length === 3) {
|
||||
const lila = CC_LILAS[parts[2]] || parts[2];
|
||||
return { title: bookName, detail: lila };
|
||||
}
|
||||
// SB1962: /books/sb1962/[chapter]/[verse]
|
||||
if (code === "sb1962" && parts.length >= 4) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
|
||||
};
|
||||
}
|
||||
// ISO / NOI: /books/{code}/[mantra]
|
||||
if ((code === "iso" || code === "noi") && parts.length >= 3) {
|
||||
const num = parts[2];
|
||||
const label = code === "iso" ? "Mantra" : "Text";
|
||||
return { title: bookName, detail: `${label} ${num}` };
|
||||
}
|
||||
// BS/MM/NBS verse: /books/{code}/[chapter]/[verse]
|
||||
if (
|
||||
(code === "bs" || code === "mm" || code === "nbs") &&
|
||||
parts.length >= 4
|
||||
) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
|
||||
};
|
||||
}
|
||||
// KB: /books/kb/[volume]/[slug]
|
||||
if (code === "kb" && parts.length >= 4) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Volume ${parts[2]}, Chapter ${parts[3]}`,
|
||||
};
|
||||
}
|
||||
// Generic prose: /books/{code}/[chapter]
|
||||
if (parts.length >= 3) {
|
||||
const chSlug = parts[2];
|
||||
const chNum = parseInt(chSlug, 10);
|
||||
const detail = isNaN(chNum)
|
||||
? chSlug.replace(/-/g, " ")
|
||||
: `Chapter ${chNum}`;
|
||||
return { title: bookName, detail };
|
||||
}
|
||||
// Book index
|
||||
return { title: bookName, detail: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a one-line citation string for a post/bookmark card.
|
||||
*
|
||||
* For book URLs we always prefer the URL-derived reference (canonical
|
||||
* format, no stale data). For non-book content (letters, essays, etc.)
|
||||
* the saved `source_title` is the source of truth.
|
||||
*/
|
||||
export function displaySourceLabel(
|
||||
sourceUrl: string,
|
||||
storedTitle?: string | null,
|
||||
): string {
|
||||
const ref = referenceFromUrl(sourceUrl);
|
||||
if (ref) {
|
||||
return ref.detail ? `${ref.title} — ${ref.detail}` : ref.title;
|
||||
}
|
||||
return storedTitle?.trim() || "Source";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format selected text with source attribution appended.
|
||||
*/
|
||||
|
||||
+506
-6
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user