Spaces:
Running
Running
| 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; | |