text highlight and og image
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared constants used across the app — book names, CC līlā names, etc.
|
||||
*/
|
||||
|
||||
export const BOOK_NAMES: Record<string, string> = {
|
||||
bg: "Bhagavad-gītā As It Is",
|
||||
sb: "Śrīmad-Bhāgavatam",
|
||||
cc: "Śrī Caitanya-caritāmṛta",
|
||||
sb1962: "Śrīmad-Bhāgavatam (1962 Edition)",
|
||||
iso: "Śrī Īśopaniṣad",
|
||||
noi: "The Nectar of Instruction",
|
||||
nod: "The Nectar of Devotion",
|
||||
bs: "Śrī Brahma-saṁhitā",
|
||||
mm: "Mukunda-mālā-stotra",
|
||||
nbs: "Nārada-bhakti-sūtra",
|
||||
kb: "Kṛṣṇa, The Supreme Personality of Godhead",
|
||||
tlc: "Teachings of Lord Caitanya",
|
||||
ej: "Easy Journey to Other Planets",
|
||||
ek: "Elevation to Kṛṣṇa Consciousness",
|
||||
mg: "Kṛṣṇa Consciousness: The Matchless Gift",
|
||||
bbd: "Beyond Birth and Death",
|
||||
owk: "On the Way to Kṛṣṇa",
|
||||
rv: "Rāja-vidyā: The King of Knowledge",
|
||||
poy: "The Perfection of Yoga",
|
||||
pqpa: "Perfect Questions, Perfect Answers",
|
||||
ttp: "Teachings of Lord Kapila",
|
||||
ochkm: "On Chanting Hare Kṛṣṇa",
|
||||
hkh: "The Kṛṣṇa Consciousness Handbook",
|
||||
lcl: "Life Comes from Life",
|
||||
lob: "Light of the Bhāgavata",
|
||||
tlk: "Teachings of Lord Kapila",
|
||||
tqk: "Teachings of Queen Kuntī",
|
||||
gg: "Gītār Gān",
|
||||
mog: "Message of Godhead",
|
||||
cbhc: "Śrī Caitanya-bhāgavata Commentary",
|
||||
bk: "Bhagavāner Kathā",
|
||||
pk: "Prabhupāda-Kṛpā",
|
||||
ssr: "The Science of Self-Realization",
|
||||
ds: "Dialectic Spiritualism",
|
||||
sva: "Songs of the Vaiṣṇava Ācāryas",
|
||||
va: "Vedānta-sūtra Transcript",
|
||||
};
|
||||
|
||||
export const CC_LILAS: Record<string, string> = {
|
||||
adi: "Ādi-līlā",
|
||||
madhya: "Madhya-līlā",
|
||||
antya: "Antya-līlā",
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* OG Image metadata utilities.
|
||||
*
|
||||
* Builds the /api/og URL with appropriate query params for dynamic
|
||||
* Open Graph image generation. Used by page-level metadata exports.
|
||||
*/
|
||||
|
||||
import { BOOK_NAMES, CC_LILAS } from "@/lib/constants";
|
||||
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL || "https://prabhupadasays.org";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OgParams = {
|
||||
title: string;
|
||||
detail?: string;
|
||||
quote?: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the /api/og image URL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a fully-qualified URL to the /api/og image endpoint.
|
||||
*
|
||||
* The URL is kept as short as possible — only non-empty params are included.
|
||||
* The quote is truncated to 260 chars to keep URL length reasonable.
|
||||
*/
|
||||
export function buildOgImageUrl(params: OgParams): string {
|
||||
const url = new URL("/api/og", SITE_URL);
|
||||
|
||||
url.searchParams.set("title", params.title);
|
||||
|
||||
if (params.detail) {
|
||||
url.searchParams.set("detail", params.detail);
|
||||
}
|
||||
|
||||
if (params.quote) {
|
||||
const truncated =
|
||||
params.quote.length > 260
|
||||
? params.quote.slice(0, 257) + "..."
|
||||
: params.quote;
|
||||
url.searchParams.set("quote", truncated);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build OG metadata object for use in Next.js page `metadata` exports.
|
||||
*
|
||||
* Returns the `openGraph` and `twitter` fields to spread into `Metadata`.
|
||||
*/
|
||||
export function buildOgMeta(params: OgParams & { description?: string }) {
|
||||
const imageUrl = buildOgImageUrl(params);
|
||||
const description =
|
||||
params.description ||
|
||||
params.quote ||
|
||||
`${params.title}${params.detail ? ` — ${params.detail}` : ""}`;
|
||||
|
||||
return {
|
||||
openGraph: {
|
||||
title: params.title,
|
||||
description,
|
||||
images: [
|
||||
{
|
||||
url: imageUrl,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: params.title,
|
||||
},
|
||||
],
|
||||
siteName: "Prabhupāda Says",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image" as const,
|
||||
title: params.title,
|
||||
description,
|
||||
images: [imageUrl],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for common page types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** OG metadata for a book page. */
|
||||
export function ogForBook(code: string) {
|
||||
const title = BOOK_NAMES[code] || code.toUpperCase();
|
||||
return buildOgMeta({ title });
|
||||
}
|
||||
|
||||
/** OG metadata for a verse page. */
|
||||
export function ogForVerse(
|
||||
code: string,
|
||||
parts: { division?: string; chapter: number; verse: number },
|
||||
) {
|
||||
const title = BOOK_NAMES[code] || code.toUpperCase();
|
||||
let detail: string;
|
||||
|
||||
if (code === "sb" && parts.division) {
|
||||
detail = `Canto ${parts.division}, Chapter ${parts.chapter}, Verse ${parts.verse}`;
|
||||
} else if (code === "cc" && parts.division) {
|
||||
const lila = CC_LILAS[parts.division] || parts.division;
|
||||
detail = `${lila}, Chapter ${parts.chapter}, Verse ${parts.verse}`;
|
||||
} else {
|
||||
detail = `Chapter ${parts.chapter}, Verse ${parts.verse}`;
|
||||
}
|
||||
|
||||
return buildOgMeta({ title, detail });
|
||||
}
|
||||
|
||||
/** OG metadata for a prose chapter. */
|
||||
export function ogForChapter(code: string, chapterTitle: string) {
|
||||
const bookTitle = BOOK_NAMES[code] || code.toUpperCase();
|
||||
return buildOgMeta({ title: bookTitle, detail: chapterTitle });
|
||||
}
|
||||
|
||||
/** OG metadata for an essay. */
|
||||
export function ogForEssay(title: string) {
|
||||
return buildOgMeta({
|
||||
title,
|
||||
detail: "Essay by Śrīla Prabhupāda",
|
||||
});
|
||||
}
|
||||
|
||||
/** OG metadata for a letter. */
|
||||
export function ogForLetter(recipient: string, meta?: string) {
|
||||
return buildOgMeta({
|
||||
title: `Letter to ${recipient}`,
|
||||
detail: meta || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** OG metadata for a journal entry. */
|
||||
export function ogForJournal(journalTitle: string, entryTitle?: string) {
|
||||
return buildOgMeta({
|
||||
title: journalTitle,
|
||||
detail: entryTitle || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** OG metadata for a magazine issue. */
|
||||
export function ogForMagazine(magazineTitle: string, issueTitle?: string) {
|
||||
return buildOgMeta({
|
||||
title: magazineTitle,
|
||||
detail: issueTitle || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** OG metadata for an other document chapter. */
|
||||
export function ogForOtherDocument(docTitle: string, chapterTitle?: string) {
|
||||
return buildOgMeta({
|
||||
title: docTitle,
|
||||
detail: chapterTitle || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Default OG metadata for the site. */
|
||||
export function ogDefault() {
|
||||
return buildOgMeta({
|
||||
title: "Prabhupāda Says",
|
||||
description:
|
||||
"Browse the books and teachings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source-reference helpers (browser-only)
|
||||
//
|
||||
// These functions access `window` and `document` and therefore can only run in
|
||||
// the browser. They are intentionally NOT marked "use client" because they are
|
||||
// plain utility functions, not React components or hooks. Any React component
|
||||
// that calls them must itself be a client component.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { BOOK_NAMES, CC_LILAS } from "@/lib/constants";
|
||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
||||
|
||||
/**
|
||||
* Parse the current page URL into a human-readable source reference.
|
||||
* Returns { title, detail, url } or null if on a non-content page.
|
||||
*/
|
||||
export function getSourceReference(): {
|
||||
title: string;
|
||||
detail: string | null;
|
||||
url: string;
|
||||
} | null {
|
||||
const url = window.location.href;
|
||||
const path = window.location.pathname;
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
|
||||
// /books/bg/1/1 → Bhagavad-gītā As It Is, Chapter 1, Verse 1
|
||||
if (parts[0] === "books" && parts.length >= 2) {
|
||||
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]}`,
|
||||
url,
|
||||
};
|
||||
}
|
||||
// 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]}`,
|
||||
url,
|
||||
};
|
||||
}
|
||||
// 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]}`,
|
||||
url,
|
||||
};
|
||||
}
|
||||
// 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, url };
|
||||
}
|
||||
// SB1962: /books/sb1962/[chapter]/[verse]
|
||||
if (code === "sb1962" && parts.length >= 4) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
|
||||
url,
|
||||
};
|
||||
}
|
||||
// ISO/NOI: /books/iso/[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}`, url };
|
||||
}
|
||||
// BS/MM/NBS: /books/{code}/[chapter]/[verse]
|
||||
if (
|
||||
(code === "bs" || code === "mm" || code === "nbs") &&
|
||||
parts.length >= 4
|
||||
) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Chapter ${parts[2]}, Verse ${parts[3]}`,
|
||||
url,
|
||||
};
|
||||
}
|
||||
// KB: /books/kb/[volume]/[slug]
|
||||
if (code === "kb" && parts.length >= 4) {
|
||||
return {
|
||||
title: bookName,
|
||||
detail: `Volume ${parts[2]}, Chapter ${parts[3]}`,
|
||||
url,
|
||||
};
|
||||
}
|
||||
// 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, url };
|
||||
}
|
||||
// Book index page
|
||||
return { title: bookName, detail: null, url };
|
||||
}
|
||||
|
||||
// /letters/[slug]
|
||||
if (
|
||||
parts[0] === "letters" &&
|
||||
parts.length === 2 &&
|
||||
parts[1] !== "year" &&
|
||||
parts[1] !== "recipient" &&
|
||||
parts[1] !== "location"
|
||||
) {
|
||||
// h1 contains "Letter to Mukunda" — use it as the title
|
||||
const h1 = document.querySelector("h1");
|
||||
const title = h1?.textContent?.trim() || "Letter";
|
||||
|
||||
// The <p> sibling after h1 contains "15 January, 1968 · Calcutta"
|
||||
const metaP = document.querySelector(
|
||||
"header p.text-muted-foreground, header p[class*='muted']",
|
||||
);
|
||||
const meta = metaP?.textContent?.trim() || null;
|
||||
|
||||
return { title, detail: meta, url };
|
||||
}
|
||||
|
||||
// /essays/[slug]
|
||||
if (parts[0] === "essays" && parts.length === 2) {
|
||||
const h1 = document.querySelector("h1");
|
||||
const title = h1?.textContent?.trim() || parts[1].replace(/-/g, " ");
|
||||
return { title, detail: "Essay by Śrīla Prabhupāda", url };
|
||||
}
|
||||
|
||||
// /journals/[slug]/[chapter]
|
||||
if (parts[0] === "journals" && parts.length >= 3) {
|
||||
const backLink = document.querySelector("header a[href^='/journals/']");
|
||||
const parentTitle =
|
||||
backLink?.textContent?.replace(/^←\s*/, "").trim() || "Journal";
|
||||
const h1 = document.querySelector("h1");
|
||||
const chapterTitle = h1?.textContent?.trim() || null;
|
||||
return { title: parentTitle, detail: chapterTitle, url };
|
||||
}
|
||||
|
||||
// /magazines/[slug]/[issue]
|
||||
if (parts[0] === "magazines" && parts.length >= 3) {
|
||||
const backLink = document.querySelector("header a[href^='/magazines/']");
|
||||
const parentTitle =
|
||||
backLink?.textContent?.replace(/^←\s*/, "").trim() || "Magazine";
|
||||
const h1 = document.querySelector("h1");
|
||||
const issueTitle = h1?.textContent?.trim() || null;
|
||||
return { title: parentTitle, detail: issueTitle, url };
|
||||
}
|
||||
|
||||
// /other-documents/[slug]/[chapter]
|
||||
if (parts[0] === "other-documents" && parts.length >= 3) {
|
||||
const backLink = document.querySelector(
|
||||
"header a[href^='/other-documents/']",
|
||||
);
|
||||
const parentTitle =
|
||||
backLink?.textContent?.replace(/^←\s*/, "").trim() || "Document";
|
||||
const h1 = document.querySelector("h1");
|
||||
const chapterTitle = h1?.textContent?.trim() || null;
|
||||
return { title: parentTitle, detail: chapterTitle, url };
|
||||
}
|
||||
|
||||
// /glossary — general
|
||||
if (parts[0] === "glossary") {
|
||||
return { title: "Glossary", detail: null, url };
|
||||
}
|
||||
|
||||
// Fallback: try the h1
|
||||
const h1 = document.querySelector("h1");
|
||||
if (h1) {
|
||||
return {
|
||||
title: h1.textContent?.trim() || "Prabhupāda Says",
|
||||
detail: null,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format selected text with source attribution appended.
|
||||
*/
|
||||
export function formatCopyText(selectedText: string): string {
|
||||
const ref = getSourceReference();
|
||||
if (!ref) return selectedText;
|
||||
|
||||
const highlightUrl = buildHighlightUrl(selectedText);
|
||||
|
||||
let source = `\n\nSource:\n${ref.title}`;
|
||||
if (ref.detail) source += `\n${ref.detail}`;
|
||||
source += `\n${highlightUrl}`;
|
||||
|
||||
return selectedText + source;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text Fragment Highlight — compact URL encoding for selected text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Encode a text selection into a compact URL-safe fragment.
|
||||
*
|
||||
* Format: `?hl=<prefix>,<suffix>,<length>`
|
||||
* - prefix: first 4 words of the selection
|
||||
* - suffix: last 4 words of the selection (omitted if same as prefix)
|
||||
* - length: character count of the full selection (for disambiguation)
|
||||
*
|
||||
* This keeps URLs short while being specific enough to locate the text.
|
||||
*/
|
||||
export function encodeTextFragment(text: string): string {
|
||||
const words = text.split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return "";
|
||||
|
||||
const prefixWords = words.slice(0, 4).join(" ");
|
||||
const suffixWords = words.length > 6 ? words.slice(-4).join(" ") : "";
|
||||
const len = text.length;
|
||||
|
||||
const parts = [prefixWords];
|
||||
if (suffixWords) parts.push(suffixWords);
|
||||
parts.push(String(len));
|
||||
|
||||
return parts.join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a text fragment from URL params.
|
||||
* Returns { prefix, suffix, length } or null if invalid.
|
||||
*/
|
||||
export function decodeTextFragment(
|
||||
hl: string,
|
||||
): { prefix: string; suffix: string; length: number } | null {
|
||||
const parts = hl.split(",");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
const lengthStr = parts[parts.length - 1];
|
||||
const length = parseInt(lengthStr, 10);
|
||||
if (isNaN(length)) return null;
|
||||
|
||||
if (parts.length === 2) {
|
||||
// prefix + length only (short selections)
|
||||
return { prefix: parts[0], suffix: "", length };
|
||||
}
|
||||
|
||||
// prefix + suffix + length
|
||||
const prefix = parts.slice(0, -2).join(","); // in case prefix itself had commas
|
||||
const suffix = parts[parts.length - 2];
|
||||
return { prefix, suffix, length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL with highlight fragment for the selected text.
|
||||
*/
|
||||
export function buildHighlightUrl(selectedText: string): string {
|
||||
const fragment = encodeTextFragment(selectedText);
|
||||
if (!fragment) return window.location.href;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
// Remove any existing hl param
|
||||
url.searchParams.delete("hl");
|
||||
url.searchParams.set("hl", fragment);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and highlight text in the DOM that matches a decoded text fragment.
|
||||
* Returns true if found and highlighted.
|
||||
*/
|
||||
export function findAndHighlightText(fragment: {
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
length: number;
|
||||
}): boolean {
|
||||
// Walk all text nodes in main/article/card-content areas
|
||||
const contentAreas = document.querySelectorAll(
|
||||
"main, [data-slot=card-content], article, [data-content-area]",
|
||||
);
|
||||
if (contentAreas.length === 0) return false;
|
||||
|
||||
const allTextNodes: Text[] = [];
|
||||
contentAreas.forEach((area) => {
|
||||
const walker = document.createTreeWalker(area, NodeFilter.SHOW_TEXT);
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
if (node.textContent && node.textContent.trim().length > 0) {
|
||||
allTextNodes.push(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Build a concatenated string of all text content with node boundaries
|
||||
const segments: { node: Text; start: number; end: number }[] = [];
|
||||
let fullText = "";
|
||||
for (const node of allTextNodes) {
|
||||
const text = node.textContent || "";
|
||||
segments.push({
|
||||
node,
|
||||
start: fullText.length,
|
||||
end: fullText.length + text.length,
|
||||
});
|
||||
fullText += text;
|
||||
}
|
||||
|
||||
// Normalize whitespace for matching
|
||||
const normalizedFull = fullText.replace(/\s+/g, " ");
|
||||
|
||||
// Search for the prefix in the full text
|
||||
const normalizedPrefix = fragment.prefix.replace(/\s+/g, " ");
|
||||
let searchFrom = 0;
|
||||
let matchStart = -1;
|
||||
|
||||
while (searchFrom < normalizedFull.length) {
|
||||
const idx = normalizedFull.indexOf(normalizedPrefix, searchFrom);
|
||||
if (idx === -1) break;
|
||||
|
||||
// If we have a suffix, verify it appears near the expected length
|
||||
if (fragment.suffix) {
|
||||
const normalizedSuffix = fragment.suffix.replace(/\s+/g, " ");
|
||||
const expectedEnd = idx + fragment.length;
|
||||
const suffixSearchStart = Math.max(
|
||||
idx,
|
||||
expectedEnd - normalizedSuffix.length - 20,
|
||||
);
|
||||
const suffixSearchEnd = Math.min(normalizedFull.length, expectedEnd + 20);
|
||||
const searchWindow = normalizedFull.slice(
|
||||
suffixSearchStart,
|
||||
suffixSearchEnd,
|
||||
);
|
||||
if (searchWindow.includes(normalizedSuffix)) {
|
||||
matchStart = idx;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// No suffix — just match prefix + approximate length
|
||||
matchStart = idx;
|
||||
break;
|
||||
}
|
||||
searchFrom = idx + 1;
|
||||
}
|
||||
|
||||
if (matchStart === -1) return false;
|
||||
|
||||
const matchEnd = Math.min(
|
||||
matchStart + fragment.length,
|
||||
normalizedFull.length,
|
||||
);
|
||||
|
||||
// Map the match range back to DOM text nodes and wrap with <mark>
|
||||
for (const seg of segments) {
|
||||
const overlapStart = Math.max(matchStart, seg.start);
|
||||
const overlapEnd = Math.min(matchEnd, seg.end);
|
||||
|
||||
if (overlapStart >= overlapEnd) continue;
|
||||
|
||||
const node = seg.node;
|
||||
const text = node.textContent || "";
|
||||
const localStart = overlapStart - seg.start;
|
||||
const localEnd = overlapEnd - seg.start;
|
||||
|
||||
// Split the text node and wrap the matching portion in a <mark>
|
||||
const before = text.slice(0, localStart);
|
||||
const matched = text.slice(localStart, localEnd);
|
||||
const after = text.slice(localEnd);
|
||||
|
||||
const parent = node.parentNode;
|
||||
if (!parent) continue;
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
if (before) frag.appendChild(document.createTextNode(before));
|
||||
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = "bg-amber-400/30 text-foreground rounded-sm px-0.5";
|
||||
mark.setAttribute("data-text-highlight", "true");
|
||||
mark.textContent = matched;
|
||||
frag.appendChild(mark);
|
||||
|
||||
if (after) frag.appendChild(document.createTextNode(after));
|
||||
|
||||
parent.replaceChild(frag, node);
|
||||
}
|
||||
|
||||
// Scroll to the first highlight
|
||||
const firstMark = document.querySelector("[data-text-highlight]");
|
||||
if (firstMark) {
|
||||
setTimeout(() => {
|
||||
firstMark.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user