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 = { 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 = { dark: 'theme-dark-bg', light: 'theme-light-bg', classic: 'theme-classic-bg', }; /** Inject 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(/>([^<]+) { return `>${text.replace(pattern, '$1')}<`; }); } const renderContent = (html: string, fontSize: number, endMarkers: string[]) => { return (
{html ? (
) : (

ไม่มีข้อความในหน้านี้

)} {/* End markers from raw tags containing "จบ" */} {endMarkers.length > 0 && (
{endMarkers.map((title, i) => (

{title}

))}
)}
); }; /** 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 containing "ขอนอบน้อม" (homage) let splitIdx = -1; if (html) { const homagePos = html.indexOf('ขอนอบน้อม'); if (homagePos !== -1) { const endH4 = html.indexOf('', 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 (
{/* Volume title — centered explicitly */}

{line1}

{line2 && (

{line2}

)}
{html ? ( <> {/* Top: everything before/at homage — centered, clean */}
{/* Bottom: after homage — normal h4 styling, text-justify */}
) : (

ไม่มีข้อความในหน้านี้

)}
); }; 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(null); const [loading, setLoading] = React.useState(true); const [refPos, setRefPos] = React.useState(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({ 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 (
setRefPos(null)} onJump={scrollToFootnote} />
{loading && (

กำลังอัญเชิญข้อความ…

)} {!loading && ( {content ? ( {currentPage === 1 || content?.page_number === 1 ? ( renderFirstPage( highlightHtml(content.content_html_formatted || content.content_html || '', highlightQuery), fontSize, content.title || '' ) ) : ( <>

{content.title}

{content.sections?.filter((s: {level: number}) => s.level === 0).map((sec: {title: string}, i: number) => (

{sec.title}

))} {content.sections?.filter((s: {level: number}) => s.level === 1).map((sec: {title: string}, i: number) => (

{sec.title}

))}
{renderContent( highlightHtml(content.content_html_formatted || content.content_html || '', highlightQuery), fontSize, content.end_markers || [] )} )} {/* Footnotes Section */} {content.footnotes && content.footnotes.length > 0 && (
เชิงอรรถ
{content.footnotes.map((fn: any, idx: number) => { const cleanId = fn.id.replace(/[()\[\]-]/g, '').trim(); return (
scrollToElement(`ref-${cleanId}`)} title="คลิกเพื่อกลับไปยังเนื้อหา" > {fn.id} {fn.content}
); })}
)}
— เล่ม {currentVolume} หน้า {currentPage} —
) : (
📖

ยังไม่ได้เลือกข้อความ

เลือกเล่มจากสารบัญ หรือค้นหาคำในพระไตรปิฎก

)} )}
); }; export default ReaderPanel;