import { type FC, useRef, useCallback, useState, useEffect, useLayoutEffect, useMemo, useSyncExternalStore } from 'react'; import HTMLFlipBook from 'react-pageflip'; import { type PageData } from '../types'; import BookPage from './BookPage'; import { ArrowLeft2, ArrowRight2, Pause, Play } from 'iconsax-react'; import { getPaperDimensions } from '@/config/paperSizes'; import type { DocumentSettings } from '@/pages/editor/store/editorStore'; /** فاصله پیش‌فرض بین هر ورق در پخش خودکار (میلی‌ثانیه) */ const AUTO_PLAY_INTERVAL_MS = 3000; const VIEWER_SCALE = 0.5; /** نسخهٔ فعلی `page-flip.mp3` دو ضربهٔ ورق پشت‌هم دارد؛ فقط تا این سهم از طول پخش می‌شود */ const PAGE_FLIP_SOUND_PLAY_FRACTION = 0.5; /** هم‌راستا با `md` تیلویند (768px) — موبایل: زیر این عرض */ const MOBILE_QUERY = '(min-width: 768px)'; function subscribeDesktopMql(callback: () => void) { const mq = window.matchMedia(MOBILE_QUERY); mq.addEventListener('change', callback); return () => mq.removeEventListener('change', callback); } function useIsDesktop() { return useSyncExternalStore( subscribeDesktopMql, () => window.matchMedia(MOBILE_QUERY).matches, () => true ); } export type BookViewerProps = { pages: PageData[]; catalogSize?: string; documentSettings?: Partial; }; interface PageFlipAPI { flipNext: () => void; flipPrev: () => void; turnToPage: (page: number) => void; getCurrentPageIndex?: () => number; } interface FlipBookInstance { pageFlip: () => PageFlipAPI | undefined; } interface FlipEvent { data: number; } type PageFlipInitEvent = { data: { page: number; mode: string } }; type PageFlipStateEvent = { data: string }; /** مختصات داخلی رندر کتاب (همان فضایی که getBoundsRect و flipNext استفاده می‌کنند) */ type FlipBookRect = { left: number; top: number; width: number; height: number; pageWidth: number }; type PageFlipWithFlipController = PageFlipAPI & { getBoundsRect: () => FlipBookRect; getFlipController: () => { flip: (p: { x: number; y: number }) => void }; }; type AutoPlayToggleButtonProps = { isActive: boolean; onToggle: () => void; }; const AutoPlayToggleButton: FC = ({ isActive, onToggle }) => ( ); /** * page-flip داخل flipPrev با x=10 ثابت می‌زند؛ با disableFlipByClick آزمایش گوشه رد می‌شود * (چون bookPos.x = 10 - rect.left معمولاً ≤ 0). flipNext همان rect.left را جمع می‌کند. */ function triggerFlipPrev(api: PageFlipWithFlipController | undefined) { if (!api?.getBoundsRect || !api.getFlipController) { api?.flipPrev(); return; } const rect = api.getBoundsRect(); api.getFlipController().flip({ x: rect.left + 12, y: rect.top + 2, }); } /** * کامپوننت اصلی کتابخوان با استفاده از react-pageflip * این کامپوننت از HTMLFlipBook استفاده می‌کند که یک wrapper برای PageFlip است * از HTML برای رندر صفحات استفاده می‌شود (نه Canvas) برای سازگاری با Konva در آینده */ const BookViewer: FC = ({ pages, catalogSize, documentSettings }) => { const bookRef = useRef(null); const pageFlipHandlerRef = useRef<(e: FlipEvent) => void>(() => { }); const [currentPage, setCurrentPage] = useState(0); const [isAutoPlayActive, setIsAutoPlayActive] = useState(false); const [startPage, setStartPage] = useState(0); const isDesktop = useIsDesktop(); const autoPlay = documentSettings?.autoPlay ?? false; const pageFlipSound = documentSettings?.pageFlipSound ?? true; const leftToRightFlip = documentSettings?.leftToRightFlip ?? false; const displayStyle = documentSettings?.displayStyle ?? 'double'; const backgroundType = documentSettings?.backgroundType ?? 'color'; const backgroundColor = documentSettings?.backgroundColor ?? '#ffffff'; const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? ''; useEffect(() => { setIsAutoPlayActive(autoPlay); }, [autoPlay]); // سایز کتاب بر اساس catalogSize (A3/A4/A5) const { width: bookWidth, height: bookHeight } = useMemo( () => getPaperDimensions(catalogSize ?? 'a4'), [catalogSize] ); const displayWidth = Math.round(bookWidth * VIEWER_SCALE); const displayHeight = Math.round(bookHeight * VIEWER_SCALE); const hasMultiplePages = pages.length > 1; // موبایل: از ابعاد viewport برای جا دادن کتاب در صفحه const [viewport, setViewport] = useState(() => typeof window !== 'undefined' ? { w: window.innerWidth, h: window.innerHeight } : { w: 1200, h: 800 } ); useLayoutEffect(() => { const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight }); onResize(); window.addEventListener('resize', onResize); window.addEventListener('orientationchange', onResize); return () => { window.removeEventListener('resize', onResize); window.removeEventListener('orientationchange', onResize); }; }, []); const mobileFit = useMemo(() => { if (isDesktop) return null; const padX = 20; const edgeBleed = 6; const reservedNav = 120; const maxW = Math.max(1, viewport.w - padX - edgeBleed * 2); const maxH = Math.max(1, viewport.h - reservedNav - edgeBleed); const baseW = bookWidth * VIEWER_SCALE; const baseH = bookHeight * VIEWER_SCALE; const scale = Math.min(1, maxW / baseW, maxH / baseH); return { w: Math.max(1, Math.round(baseW * scale)), h: Math.max(1, Math.round(baseH * scale)), contentScale: VIEWER_SCALE * scale, }; }, [isDesktop, bookWidth, bookHeight, viewport.w, viewport.h]); // دسکتاپ: دقیقاً مثل قبل — موبایل: مقیاس‌شده const pagePixelWidth = isDesktop ? displayWidth : (mobileFit?.w ?? displayWidth); const pagePixelHeight = isDesktop ? displayHeight : (mobileFit?.h ?? displayHeight); const contentScale = isDesktop ? VIEWER_SCALE : (mobileFit?.contentScale ?? VIEWER_SCALE); const flipKey = isDesktop ? 'desktop' : `mobile-${pagePixelWidth}-${pagePixelHeight}`; // اطمینان از اینکه currentPage همیشه در محدوده معتبر است useEffect(() => { if (currentPage < 0) { setCurrentPage(0); } else if (currentPage >= pages.length) { setCurrentPage(pages.length - 1); } }, [currentPage, pages.length]); const pageFlipSoundRef = useRef(null); const pageFlipSoundTrimListenerRef = useRef<(() => void) | null>(null); const lastFlipPageRef = useRef(null); useEffect(() => { const base = import.meta.env.BASE_URL; const src = `${base}${base.endsWith('/') ? '' : '/'}sounds/page-flip.mp3`; const audio = new Audio(src); audio.preload = 'auto'; pageFlipSoundRef.current = audio; return () => { const trim = pageFlipSoundTrimListenerRef.current; if (trim) { audio.removeEventListener('timeupdate', trim); pageFlipSoundTrimListenerRef.current = null; } audio.pause(); pageFlipSoundRef.current = null; }; }, []); const playPageFlipSound = useCallback(() => { const audio = pageFlipSoundRef.current; if (!audio) return; const prevTrim = pageFlipSoundTrimListenerRef.current; if (prevTrim) { audio.removeEventListener('timeupdate', prevTrim); pageFlipSoundTrimListenerRef.current = null; } const onTimeUpdate = () => { const d = audio.duration; if (!Number.isFinite(d) || d <= 0.04) return; const end = d * PAGE_FLIP_SOUND_PLAY_FRACTION; if (audio.currentTime >= end) { audio.pause(); audio.currentTime = 0; audio.removeEventListener('timeupdate', onTimeUpdate); pageFlipSoundTrimListenerRef.current = null; } }; pageFlipSoundTrimListenerRef.current = onTimeUpdate; try { audio.currentTime = 0; audio.addEventListener('timeupdate', onTimeUpdate); void audio.play().catch(() => { audio.removeEventListener('timeupdate', onTimeUpdate); pageFlipSoundTrimListenerRef.current = null; /* autoplay policy — معمولاً بعد از تعامل کاربر ورق می‌خورد */ }); } catch { audio.removeEventListener('timeupdate', onTimeUpdate); pageFlipSoundTrimListenerRef.current = null; } }, []); const handlePageFlip = useCallback((e: FlipEvent) => { // react-pageflip شماره صفحه را به صورت 0-based برمی‌گرداند // اما باید مطمئن شویم که در محدوده معتبر است let pageNum = e.data; // اگر pageNum منفی است یا بزرگتر از تعداد صفحات، آن را محدود می‌کنیم if (pageNum < 0) { pageNum = 0; } else if (pageNum >= pages.length) { pageNum = pages.length - 1; } if (pageFlipSound && lastFlipPageRef.current !== null && lastFlipPageRef.current !== pageNum) { playPageFlipSound(); } lastFlipPageRef.current = pageNum; setCurrentPage(pageNum); }, [pages.length, pageFlipSound, playPageFlipSound]); pageFlipHandlerRef.current = handlePageFlip; const onFlipForwarded = useCallback((e: FlipEvent) => { pageFlipHandlerRef.current(e); }, []); const syncPageIndexFromBook = useCallback(() => { const api = bookRef.current?.pageFlip(); const idx = api?.getCurrentPageIndex?.(); if (typeof idx !== 'number' || pages.length <= 0) return; const clamped = Math.max(0, Math.min(idx, pages.length - 1)); setCurrentPage(clamped); }, [pages.length]); const handleBookInit = useCallback( (e: PageFlipInitEvent) => { const p = e.data?.page; if (typeof p !== 'number' || pages.length <= 0) return; const clamped = Math.max(0, Math.min(p, pages.length - 1)); setCurrentPage(clamped); lastFlipPageRef.current = clamped; }, [pages.length] ); /** بعد از اتمام انیمیشن، ایندکس واقعی کتاب را می‌گیریم (RTL روی روت کتاب مختصات page-flip را به‌هم می‌زند) */ const handleChangeState = useCallback( (e: PageFlipStateEvent) => { if (e.data !== 'read') return; syncPageIndexFromBook(); }, [syncPageIndexFromBook] ); const goToNextPage = useCallback(() => { if (currentPage < pages.length - 1) { bookRef.current?.pageFlip()?.flipNext(); } }, [currentPage, pages.length]); const goToPrevPage = useCallback(() => { if (currentPage <= 0) return; const api = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined; triggerFlipPrev(api); }, [currentPage]); const stopAutoPlayByUser = useCallback(() => { if (isAutoPlayActive) { setIsAutoPlayActive(false); } }, [isAutoPlayActive]); const toggleAutoPlay = useCallback(() => { if (pages.length <= 1) return; setIsAutoPlayActive(prev => !prev); }, [pages.length]); const handleLinkClick = useCallback((linkUrl: string) => { if (pages.length <= 1) return; if (linkUrl.startsWith('page://')) { const action = linkUrl.replace('page://', ''); const pageFlipAPI = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined; if (!pageFlipAPI) return; if (action === 'next') { goToNextPage(); } else if (action === 'prev') { goToPrevPage(); } else if (action === 'first') { // رفتن به صفحه اول const flipToFirst = () => { if (currentPage > 0) { triggerFlipPrev(pageFlipAPI); setTimeout(() => { setCurrentPage(prev => { if (prev > 1) { flipToFirst(); } return prev - 1; }); }, 300); } }; flipToFirst(); } else if (action === 'last') { // رفتن به صفحه آخر const lastPageIndex = pages.length - 1; const flipToLast = () => { if (currentPage < lastPageIndex) { pageFlipAPI.flipNext(); setTimeout(() => { setCurrentPage(prev => { if (prev < lastPageIndex - 1) { flipToLast(); } return prev + 1; }); }, 300); } }; flipToLast(); } else { // شماره صفحه مشخص (مثل page://3) const pageNum = parseInt(action, 10); if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= pages.length) { const targetIndex = pageNum - 1; const diff = targetIndex - currentPage; const flipToTarget = (remaining: number) => { if (remaining === 0) return; if (remaining > 0) { pageFlipAPI.flipNext(); setTimeout(() => { setCurrentPage(prev => prev + 1); flipToTarget(remaining - 1); }, 300); } else { triggerFlipPrev(pageFlipAPI); setTimeout(() => { setCurrentPage(prev => prev - 1); flipToTarget(remaining + 1); }, 300); } }; flipToTarget(diff); } } } }, [currentPage, pages.length, goToNextPage, goToPrevPage]); // تک‌صفحه‌ای: موبایل یا تنظیم single، دوصفحه‌ای: دسکتاپ + تنظیم double const usePortrait = !isDesktop || displayStyle === 'single'; const prevFlipKeyRef = useRef(flipKey); useEffect(() => { if (prevFlipKeyRef.current !== flipKey) { prevFlipKeyRef.current = flipKey; lastFlipPageRef.current = currentPage; setStartPage(currentPage); } }, [flipKey, currentPage]); // پخش خودکار useEffect(() => { if (!isAutoPlayActive || pages.length <= 1) return; const timer = setInterval(() => { if (currentPage >= pages.length - 1) { bookRef.current?.pageFlip()?.turnToPage?.(0); setCurrentPage(0); lastFlipPageRef.current = 0; return; } bookRef.current?.pageFlip()?.flipNext(); }, AUTO_PLAY_INTERVAL_MS); return () => clearInterval(timer); }, [isAutoPlayActive, currentPage, pages.length]); return (
{/* Border و Shadow عمودی در وسط (فقط حالت دوصفحه‌ای دسکتاپ) */} {!usePortrait && (
)} {/* page-flip با مختصات LTR کار می‌کند؛ direction:rtl روی روت کتاب باعث ناهماهنگی ایندکس/onFlip با دکمه‌ها می‌شود. محتوای فارسی داخل BookPage با dir=rtl است. */}
{pages.map((page) => ( ))}
{hasMultiplePages && ( <> {/* دکمه راست: فارسی = صفحه بعد | لاتین = صفحه قبل */} )} {/* شماره صفحه */}
صفحه {Math.min(currentPage + 1, pages.length)} از {pages.length}
{hasMultiplePages && ( <> {/* دکمه چپ: فارسی = صفحه قبل | لاتین = صفحه بعد */} )} {hasMultiplePages && autoPlay && ( )}
); }; export default BookViewer;