dhammawatthumpra's picture
feat: highlight search keywords on reader page with auto-scroll to first match
05b80f3
Raw
History Blame Contribute Delete
15 kB
import React, { useEffect } from 'react';
import { useReaderStore, useThemeStore } from '../../stores/appStore';
import api from '../../lib/api';
import { motion, AnimatePresence } from 'framer-motion';
import SelectionPopup from '../reader/SelectionPopup';
import ReferencePopup, { type RefPopupPos } from '../reader/ReferencePopup';
import ErrorBoundary from '../common/ErrorBoundary';
import { useSwipeNav } from '../../hooks/useSwipeNav';
const THEME_STYLES: Record<string, { reader: string; muted: string }> = {
dark: { reader: 'bg-[#1e1e36] text-[#e8e4da]', muted: 'text-[#999]' },
light: { reader: 'bg-[#fdfaf5] text-[#1a1a1a]', muted: 'text-[#666]' },
classic: { reader: 'bg-[#faf7f2] text-[#1a1a1a]', muted: 'text-[#555]' },
};
const SHELL_STYLES: Record<string, string> = {
dark: 'theme-dark-bg',
light: 'theme-light-bg',
classic: 'theme-classic-bg',
};
/** Inject <mark> tags around every keyword occurrence in an HTML string.
* Replaces only text nodes (content between > and <) to avoid breaking tags. */
function highlightHtml(html: string, query: string): string {
if (!query.trim()) return html;
const tokens = query.trim().split(/\s+/).filter(Boolean);
if (tokens.length === 0) return html;
const escaped = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(`(${escaped.join('|')})`, 'g');
return html.replace(/>([^<]+)</g, (_match, text) => {
return `>${text.replace(pattern, '<mark class="search-highlight">$1</mark>')}<`;
});
}
const renderContent = (html: string, fontSize: number, endMarkers: string[]) => {
return (
<div style={{ fontSize: `${fontSize}px` }}>
{html ? (
<div
className="content-body reader-content"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<p className="text-[#888] italic text-center py-10">ไม่มีข้อความในหน้านี้</p>
)}
{/* End markers from raw <B> tags containing "จบ" */}
{endMarkers.length > 0 && (
<div className="space-y-1 pt-2">
{endMarkers.map((title, i) => (
<div key={i} className="flex items-center gap-4 py-2 opacity-40">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-[#888] to-transparent" />
<p className="text-[10px] text-[#888] tracking-[0.2em] uppercase font-medium">
{title}
</p>
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-[#888] to-transparent" />
</div>
))}
</div>
)}
</div>
);
};
/** First page — split at the "ขอนอบน้อม" homage h4:
* TOP (everything up to and including homage): centered, clean (no bg/border on h4)
* BOTTOM (after homage): normal (text-left, h4 with bg/border)
* This works for all volumes regardless of where L0/L1 appear relative to homage. */
const renderFirstPage = (html: string, fontSize: number, volumeTitle: string) => {
// Split pitaka name from rest of title
const idx = volumeTitle.indexOf('ปิฎก');
const line1 = idx !== -1 ? volumeTitle.substring(0, idx + 4) : volumeTitle;
const line2 = idx !== -1 ? volumeTitle.substring(idx + 4).trim() : '';
// Find split point: end of the </h4> containing "ขอนอบน้อม" (homage)
let splitIdx = -1;
if (html) {
const homagePos = html.indexOf('ขอนอบน้อม');
if (homagePos !== -1) {
const endH4 = html.indexOf('</h4>', homagePos);
splitIdx = endH4 !== -1 ? endH4 + 5 : html.length;
}
}
const topPart = splitIdx > 0 ? html.substring(0, splitIdx) : '';
const bottomPart = splitIdx >= 0 ? html.substring(splitIdx) : html || '';
return (
<div
style={{ fontSize: `${fontSize}px` }}
>
{/* Volume title — centered explicitly */}
<div className="text-center">
<p className="text-lg md:text-xl font-bold text-[#c8860a] leading-tight">
{line1}
</p>
{line2 && (
<p className="text-base md:text-lg text-[#c8860a] leading-tight">
{line2}
</p>
)}
<div className="border-b border-[#c8860a]/15 my-3" />
</div>
{html ? (
<>
{/* Top: everything before/at homage — centered, clean */}
<div
className="first-page-top text-center"
dangerouslySetInnerHTML={{ __html: topPart }}
/>
{/* Bottom: after homage — normal h4 styling, text-justify */}
<div
className="content-body reader-content"
dangerouslySetInnerHTML={{ __html: bottomPart }}
/>
</>
) : (
<p className="text-[#888] italic text-center py-10">ไม่มีข้อความในหน้านี้</p>
)}
</div>
);
};
const articleVariants = {
enter: (direction: 'forward' | 'backward' | 'none') => ({
y: direction === 'forward' ? 30 : direction === 'backward' ? -30 : 0,
opacity: 0,
}),
center: {
y: 0,
opacity: 1,
},
exit: (direction: 'forward' | 'backward' | 'none') => ({
y: direction === 'forward' ? -30 : direction === 'backward' ? 30 : 0,
opacity: 0,
}),
};
const ReaderPanel: React.FC = () => {
const {
currentVolume, currentPage,
setCurrentContent, setTotalPages,
setVolumeTitle, highlightQuery,
} = useReaderStore();
const { theme, fontSize } = useThemeStore();
const [content, setContent] = React.useState<any>(null);
const [loading, setLoading] = React.useState(true);
const [refPos, setRefPos] = React.useState<RefPopupPos | null>(null);
// Derived state to track direction across page transitions (preserving direction when loading finishes)
const [prevPage, setPrevPage] = React.useState(currentPage);
const [prevVolume, setPrevVolume] = React.useState(currentVolume);
const [direction, setDirection] = React.useState<'forward' | 'backward' | 'none'>('none');
if (currentPage !== prevPage || currentVolume !== prevVolume) {
const isVolChange = currentVolume !== prevVolume;
const dir = isVolChange
? 'none'
: currentPage > prevPage
? 'forward'
: currentPage < prevPage
? 'backward'
: 'none';
setDirection(dir);
setPrevPage(currentPage);
setPrevVolume(currentVolume);
}
const swipeRef = useSwipeNav<HTMLDivElement>({
disabled: loading
});
useEffect(() => {
setLoading(true);
api.get(`/api/pages/${currentVolume}/${currentPage}`)
.then(res => {
const data = res.data;
setContent(data);
setCurrentContent(data.content_text || '');
// Auto-load TTS when page changes
if (data.total_pages) setTotalPages(data.total_pages);
if (data.title) setVolumeTitle(data.title);
})
.catch(err => console.error('Page load error:', err))
.finally(() => setLoading(false));
}, [currentVolume, currentPage]);
// Scroll to first highlighted match after render
useEffect(() => {
if (!highlightQuery || loading) return;
const timer = setTimeout(() => {
const first = document.querySelector('mark.search-highlight') as HTMLElement | null;
if (first) {
first.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 150); // wait for framer-motion animation
return () => clearTimeout(timer);
}, [highlightQuery, loading, content]);
useEffect(() => {
if (!loading && content) {
const container = document.getElementById('reader-scroll-container');
if (container) {
container.scrollTop = 0;
}
}
}, [loading, content]);
const { reader: readerCls, muted: mutedCls } = THEME_STYLES[theme] ?? THEME_STYLES.dark;
const shellCls = SHELL_STYLES[theme] ?? SHELL_STYLES.dark;
const handleArticleClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName.toLowerCase() === 'sup') {
const isAbbrev = target.classList.contains('abbrev-ref');
const isFootnote = target.classList.contains('footnote-ref');
if (isAbbrev || isFootnote) {
const rect = target.getBoundingClientRect();
setRefPos({
x: rect.left + rect.width / 2,
y: rect.bottom,
id: target.innerText,
type: isAbbrev ? 'abbrev' : 'footnote',
vol: currentVolume,
page: currentPage
});
}
}
};
const scrollToElement = (id: string) => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('bg-[#c8860a]/20');
setTimeout(() => element.classList.remove('bg-[#c8860a]/20'), 2000);
}
};
const scrollToFootnote = (fnId: string) => {
const cleanId = fnId.replace(/[()\[\]-]/g, '').trim();
scrollToElement(`fn-item-${cleanId}`);
setRefPos(null); // Close popup after jump
};
return (
<div ref={swipeRef} className={`flex-1 min-h-screen transition-colors duration-300 ${shellCls}`}>
<SelectionPopup />
<ErrorBoundary componentName="ReferencePopup">
<ReferencePopup
pos={refPos}
onClose={() => setRefPos(null)}
onJump={scrollToFootnote}
/>
</ErrorBoundary>
<div className="max-w-3xl mx-auto px-4 py-10 lg:py-16">
{loading && (
<div className="flex flex-col items-center justify-center py-48 gap-4">
<div className="w-10 h-10 border-4 border-[#c8860a]/20 border-t-[#c8860a] rounded-full animate-spin" />
<p className="text-[#c8860a] text-sm animate-pulse">กำลังอัญเชิญข้อความ…</p>
</div>
)}
{!loading && (
<AnimatePresence custom={direction} mode="popLayout">
{content ? (
<motion.article
key={`${currentVolume}-${currentPage}`}
custom={direction}
variants={articleVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.35, ease: 'easeOut' }}
className={`rounded-xl p-8 lg:p-12 ${readerCls}`}
onClick={handleArticleClick}
>
{currentPage === 1 || content?.page_number === 1 ? (
renderFirstPage(
highlightHtml(content.content_html_formatted || content.content_html || '', highlightQuery),
fontSize,
content.title || ''
)
) : (
<>
<header className="mb-4 pb-0">
<p className="text-xs text-[#888] tracking-widest uppercase leading-relaxed">
{content.title}
</p>
{content.sections?.filter((s: {level: number}) => s.level === 0).map((sec: {title: string}, i: number) => (
<p key={`l0-${i}`} className="text-xs text-[#888] tracking-widest uppercase leading-relaxed">
{sec.title}
</p>
))}
{content.sections?.filter((s: {level: number}) => s.level === 1).map((sec: {title: string}, i: number) => (
<p key={`l1-${i}`} className="text-sm text-[#888] tracking-widest uppercase leading-relaxed">
{sec.title}
</p>
))}
</header>
<div className="border-b border-[#c8860a]/15 mb-5" />
{renderContent(
highlightHtml(content.content_html_formatted || content.content_html || '', highlightQuery),
fontSize,
content.end_markers || []
)}
</>
)}
{/* Footnotes Section */}
{content.footnotes && content.footnotes.length > 0 && (
<div className="mt-16 pt-8 border-t border-[#c8860a]/20">
<h5 className="text-[#c8860a] text-xs font-bold tracking-widest uppercase mb-6 flex items-center gap-2">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 4v12l-4-2-4 2V4M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
เชิงอรรถ
</h5>
<div className="space-y-4">
{content.footnotes.map((fn: any, idx: number) => {
const cleanId = fn.id.replace(/[()\[\]-]/g, '').trim();
return (
<div
key={idx}
id={`fn-item-${cleanId}`}
className={`flex gap-3 text-[13px] leading-relaxed ${mutedCls} hover:opacity-100 transition-all p-2 -m-2 rounded-lg cursor-pointer group`}
onClick={() => scrollToElement(`ref-${cleanId}`)}
title="คลิกเพื่อกลับไปยังเนื้อหา"
>
<span className="text-[#c8860a] font-bold min-w-[24px] text-right shrink-0 group-hover:scale-110 transition-transform">{fn.id}</span>
<span className="font-light">{fn.content}</span>
</div>
);
})}
</div>
</div>
)}
<footer className="mt-12 pt-6 border-t border-[#888]/10 text-center">
<span className="text-xs text-[#888]">
— เล่ม {currentVolume} หน้า {currentPage} —
</span>
</footer>
</motion.article>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="text-center py-40 text-[#888]"
>
<div className="text-5xl mb-6 opacity-40">📖</div>
<p className="text-lg">ยังไม่ได้เลือกข้อความ</p>
<p className="text-sm mt-2 opacity-60">
เลือกเล่มจากสารบัญ หรือค้นหาคำในพระไตรปิฎก
</p>
</motion.div>
)}
</AnimatePresence>
)}
</div>
</div>
);
};
export default ReaderPanel;