import { getPaperDimensions } from "@/config/paperSizes"; import { clx } from "@/helpers/utils"; import type { DocumentSettings } from "@/pages/editor/store/editorStore"; import { useBookEntranceController } from "@/pages/viewer/hooks/useBookEntranceController"; import { useIsBrowserFullscreen } from "@/pages/viewer/hooks/useIsBrowserFullscreen"; import { useViewerViewport } from "@/pages/viewer/hooks/useViewerViewport"; import { isLegacyIOSSafari } from "@/pages/viewer/utils/isLegacyIOSSafari"; import { resolvePageBackground } from "@/pages/viewer/utils/pageBackground"; import { getVisiblePageIndices } from "@/pages/viewer/utils/visiblePageIndices"; import { toggleViewerFullscreen } from "@/pages/viewer/utils/viewerFullscreen"; import { ArrowLeft2, ArrowRight2, Maximize4, Pause, Play, RowVertical } from "iconsax-react"; import { type FC, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import HTMLFlipBook from "react-pageflip"; import { type PageData } from "../types"; import BookPage from "./BookPage"; import Magnifier from "./Magnifier/Magnifier"; import ZoomButton from "./Toolbar/ZoomButton"; /** فاصله پیش‌فرض بین هر ورق در پخش خودکار (میلی‌ثانیه) */ const AUTO_PLAY_INTERVAL_MS = 3000; const VIEWER_SCALE = 0.5; /** فاصلهٔ کتاب از بالای صفحه (پیکسل) */ const BOOK_TOP_GAP_DESKTOP = 48; const BOOK_TOP_GAP_MOBILE = 32; /** نسخهٔ فعلی `page-flip.mp3` دو ضربهٔ ورق پشت‌هم دارد؛ فقط تا این سهم از طول پخش می‌شود */ const PAGE_FLIP_SOUND_PLAY_FRACTION = 0.5; /** هم‌راستا با `md` تیلویند (768px) — موبایل: زیر این عرض */ const MOBILE_QUERY = "(min-width: 768px)"; /** شناسهٔ صفحهٔ خالیِ کمکی برای اصلاح جفت‌سازی جلد در کتاب RTL با تعداد صفحات فرد (هرگز به کاربر نمایش داده نمی‌شود) */ const RTL_COVER_PAD_PAGE_ID = -1; 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, ); } function useLegacyIOSSafari() { return useSyncExternalStore( () => () => {}, isLegacyIOSSafari, () => false, ); } 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 bookAreaRef = useRef(null); const flipbookWrapperRef = useRef(null); const pageFlipHandlerRef = useRef<(e: FlipEvent) => void>(() => {}); const [currentPage, setCurrentPage] = useState(0); const [isAutoPlayActive, setIsAutoPlayActive] = useState(false); const [startPage, setStartPage] = useState(0); const [magnifierEnabled, setMagnifierEnabled] = useState(false); /** در حالت read خط وسط روی شکاف جلد دیده می‌شود؛ هنگام ورق‌زدن باید زیر ورق باشد */ const [isBookFlipping, setIsBookFlipping] = useState(false); const isDesktop = useIsDesktop(); const legacyIOS = useLegacyIOSSafari(); const isBrowserFullscreen = useIsBrowserFullscreen(); const autoPlay = documentSettings?.autoPlay ?? false; const pageFlipSound = documentSettings?.pageFlipSound ?? true; const leftToRightFlip = documentSettings?.leftToRightFlip ?? false; const displayStyle = documentSettings?.displayStyle ?? "double"; const usePortrait = !isDesktop || displayStyle === "single"; /** react-pageflip از RTL پشتیبانی نمی‌کند؛ ترتیب صفحات برعکس + شروع از آخر (فقط دسکتاپ) */ const isRtlBook = !leftToRightFlip && isDesktop; /** * page-flip فقط صفحهٔ با ایندکس 0 را به‌عنوان جلد تکی در نظر می‌گیرد. بعد از برعکس‌کردن * آرایه برای RTL، جلد واقعی (صفحهٔ منطقی 0) در انتهای آرایه قرار می‌گیرد و اگر تعداد کل * صفحات فرد باشد، جفت‌سازی داخلی کتابخانه آن را با صفحهٔ بعدی هم‌اسپرد می‌کند (به‌جای تکی). * با افزودن یک صفحهٔ خالی نامرئی به ابتدای آرایهٔ برعکس‌شده، توازن جفت‌سازی طوری اصلاح می‌شود * که جلد واقعی همیشه تکی نمایش داده شود. */ const needsRtlCoverPad = isRtlBook && !usePortrait && pages.length > 1 && pages.length % 2 === 1; const displayPages = useMemo(() => { if (!isRtlBook) return pages; const reversed = [...pages].reverse(); if (!needsRtlCoverPad) return reversed; const fillerPage: PageData = { id: RTL_COVER_PAD_PAGE_ID, width: pages[0]?.width ?? 794, height: pages[0]?.height ?? 1123, elements: [], backgroundType: "color", backgroundColor: "#ffffff", }; return [fillerPage, ...reversed]; }, [pages, isRtlBook, needsRtlCoverPad]); const flipIndexCount = displayPages.length; const toLogicalIndex = useCallback( (flipIndex: number) => { if (!isRtlBook) return flipIndex; return needsRtlCoverPad ? pages.length - flipIndex : pages.length - 1 - flipIndex; }, [isRtlBook, needsRtlCoverPad, pages.length], ); const toFlipIndex = useCallback( (logicalIndex: number) => { if (!isRtlBook) return logicalIndex; return needsRtlCoverPad ? pages.length - logicalIndex : pages.length - 1 - logicalIndex; }, [isRtlBook, needsRtlCoverPad, pages.length], ); const backgroundType = documentSettings?.backgroundType ?? "color"; const backgroundColor = documentSettings?.backgroundColor ?? "#ffffff"; const backgroundGradient = documentSettings?.backgroundGradient; const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? ""; const pageBackgroundDefaults = useMemo( () => ({ backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl }), [backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl], ); useEffect(() => { setIsAutoPlayActive(autoPlay); }, [autoPlay]); // سایز کتاب بر اساس catalogSize (A3/A4/A5) const { width: bookWidth, height: bookHeight } = useMemo(() => getPaperDimensions(catalogSize ?? "a4"), [catalogSize]); const hasMultiplePages = pages.length > 1; const viewport = useViewerViewport(); const viewportFit = useMemo(() => { const padX = isDesktop ? 48 : 20; const edgeBleed = isDesktop ? 0 : 6; const reservedNav = isDesktop ? 96 : 120; const reservedTop = isDesktop ? BOOK_TOP_GAP_DESKTOP : BOOK_TOP_GAP_MOBILE; const maxW = Math.max(1, viewport.w - padX - edgeBleed * 2); const maxH = Math.max(1, viewport.h - reservedNav - reservedTop - edgeBleed); const spreadCount = usePortrait ? 1 : 2; const basePageW = bookWidth * VIEWER_SCALE; const basePageH = bookHeight * VIEWER_SCALE; const baseSpreadW = basePageW * spreadCount; const scale = Math.min(maxW / baseSpreadW, maxH / basePageH); return { w: Math.max(1, Math.round(basePageW * scale)), h: Math.max(1, Math.round(basePageH * scale)), contentScale: VIEWER_SCALE * scale, }; }, [isDesktop, bookWidth, bookHeight, viewport.w, viewport.h, usePortrait]); const pagePixelWidth = viewportFit.w; const pagePixelHeight = viewportFit.h; const contentScale = viewportFit.contentScale; const flipKey = `${usePortrait ? "portrait" : "spread"}-${pagePixelWidth}-${pagePixelHeight}-${flipIndexCount}`; const { scheduleEntranceForIndices, getEntrancePhase, reset: resetEntrance, } = useBookEntranceController({ pages }); /** * ایندکس‌های منطقیِ صفحات قابل‌مشاهده در اسپرد جاری را برمی‌گرداند. * جفت‌سازی (کدام دو صفحه با هم دیده می‌شوند) باید در فضای نمایشیِ کتاب * (flip index) انجام شود، نه فضای منطقی: در کتاب RTL این دو فضا برعکس * هم‌اند، پس مثلاً «ایندکس منطقی + ۱» لزوماً هم‌صفحه‌ی واقعی نیست. */ const getVisibleLogicalIndices = useCallback( (flipIdx: number): number[] => { const displayIndices = getVisiblePageIndices(flipIdx, flipIndexCount, { portrait: usePortrait, showCover: hasMultiplePages, }); const seen = new Set(); const result: number[] = []; for (const di of displayIndices) { const logical = toLogicalIndex(di); if (logical >= 0 && logical < pages.length && !seen.has(logical)) { seen.add(logical); result.push(logical); } } return result; }, [flipIndexCount, usePortrait, hasMultiplePages, toLogicalIndex, pages.length], ); const commitEntranceAtCurrentSpread = useCallback(() => { const api = bookRef.current?.pageFlip(); const idx = api?.getCurrentPageIndex?.(); if (typeof idx !== "number" || flipIndexCount <= 0) return; const flipIdx = Math.max(0, Math.min(idx, flipIndexCount - 1)); scheduleEntranceForIndices(getVisibleLogicalIndices(flipIdx)); }, [flipIndexCount, scheduleEntranceForIndices, getVisibleLogicalIndices]); useEffect(() => { resetEntrance(); const frame = requestAnimationFrame(() => { commitEntranceAtCurrentSpread(); }); return () => cancelAnimationFrame(frame); }, [flipKey, pages.length, pages[0]?.id, resetEntrance, commitEntranceAtCurrentSpread]); // اطمینان از اینکه 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 flipIndex = e.data; // اگر flipIndex منفی است یا بزرگتر از تعداد صفحات، آن را محدود می‌کنیم if (flipIndex < 0) { flipIndex = 0; } else if (flipIndex >= flipIndexCount) { flipIndex = flipIndexCount - 1; } const pageNum = toLogicalIndex(flipIndex); if (pageFlipSound && lastFlipPageRef.current !== null && lastFlipPageRef.current !== pageNum) { playPageFlipSound(); } lastFlipPageRef.current = pageNum; setCurrentPage(pageNum); }, [flipIndexCount, pageFlipSound, playPageFlipSound, toLogicalIndex], ); 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" || flipIndexCount <= 0) return; const flipIdx = Math.max(0, Math.min(idx, flipIndexCount - 1)); setCurrentPage(toLogicalIndex(flipIdx)); }, [flipIndexCount, toLogicalIndex]); const handleBookInit = useCallback( (e: PageFlipInitEvent) => { const p = e.data?.page; if (typeof p !== "number" || flipIndexCount <= 0) return; const flipIdx = Math.max(0, Math.min(p, flipIndexCount - 1)); const logicalIdx = toLogicalIndex(flipIdx); setCurrentPage(logicalIdx); lastFlipPageRef.current = logicalIdx; // اولین اسپرد قبل از onChangeState('read') هم باید انیمیشن ورود بگیرد scheduleEntranceForIndices(getVisibleLogicalIndices(flipIdx)); }, [flipIndexCount, scheduleEntranceForIndices, getVisibleLogicalIndices, toLogicalIndex], ); /** بعد از اتمام انیمیشن ورق، ایندکس واقعی کتاب را می‌گیریم و انیمیشن ورود را یک‌بار شروع می‌کنیم */ const handleChangeState = useCallback( (e: PageFlipStateEvent) => { if (e.data === "read") { setIsBookFlipping(false); syncPageIndexFromBook(); commitEntranceAtCurrentSpread(); return; } setIsBookFlipping(true); }, [syncPageIndexFromBook, commitEntranceAtCurrentSpread], ); const goToNextPage = useCallback(() => { if (currentPage >= pages.length - 1) return; const api = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined; if (isRtlBook) { triggerFlipPrev(api); } else { api?.flipNext(); } }, [currentPage, pages.length, isRtlBook]); const goToPrevPage = useCallback(() => { if (currentPage <= 0) return; const api = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined; if (isRtlBook) { api?.flipNext(); } else { triggerFlipPrev(api); } }, [currentPage, isRtlBook]); const stopAutoPlayByUser = useCallback(() => { if (isAutoPlayActive) { setIsAutoPlayActive(false); } }, [isAutoPlayActive]); const toggleAutoPlay = useCallback(() => { if (pages.length <= 1) return; setIsAutoPlayActive((prev) => !prev); }, [pages.length]); const toggleMagnifier = useCallback(() => { setMagnifierEnabled((prev) => !prev); }, []); useEffect(() => { if (!magnifierEnabled) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setMagnifierEnabled(false); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [magnifierEnabled]); const navigateToPageIndex = useCallback( (targetIndex: number) => { const pageFlipAPI = bookRef.current?.pageFlip(); if (!pageFlipAPI) return; const logicalIdx = Math.max(0, Math.min(targetIndex, pages.length - 1)); const flipIdx = toFlipIndex(logicalIdx); pageFlipAPI.turnToPage(flipIdx); lastFlipPageRef.current = logicalIdx; scheduleEntranceForIndices(getVisibleLogicalIndices(flipIdx)); }, [pages.length, scheduleEntranceForIndices, getVisibleLogicalIndices, toFlipIndex], ); const handleLinkClick = useCallback( (linkUrl: string) => { if (pages.length <= 1) return; stopAutoPlayByUser(); if (!linkUrl.startsWith("page://")) return; const action = linkUrl.replace("page://", ""); if (action === "next") { goToNextPage(); } else if (action === "prev") { goToPrevPage(); } else if (action === "first") { navigateToPageIndex(0); } else if (action === "last") { navigateToPageIndex(pages.length - 1); } else { const pageNum = parseInt(action, 10); if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= pages.length) { navigateToPageIndex(pageNum - 1); } } }, [pages.length, goToNextPage, goToPrevPage, navigateToPageIndex, stopAutoPlayByUser], ); const prevFlipKeyRef = useRef(flipKey); useEffect(() => { if (prevFlipKeyRef.current !== flipKey) { prevFlipKeyRef.current = flipKey; lastFlipPageRef.current = currentPage; setStartPage(toFlipIndex(currentPage)); } }, [flipKey, currentPage, toFlipIndex]); useEffect(() => { if (pages.length <= 0) return; setStartPage(toFlipIndex(0)); setCurrentPage(0); lastFlipPageRef.current = 0; }, [pages.length, pages[0]?.id, toFlipIndex]); // پخش خودکار useEffect(() => { if (!isAutoPlayActive || pages.length <= 1) return; const timer = setInterval(() => { if (currentPage >= pages.length - 1) { bookRef.current?.pageFlip()?.turnToPage?.(toFlipIndex(0)); setCurrentPage(0); lastFlipPageRef.current = 0; return; } const api = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined; if (isRtlBook) { triggerFlipPrev(api); } else { api?.flipNext(); } }, AUTO_PLAY_INTERVAL_MS); return () => clearInterval(timer); }, [isAutoPlayActive, currentPage, pages.length, isRtlBook, toFlipIndex]); return (
{/* Border و Shadow عمودی در وسط (فقط حالت دوصفحه‌ای دسکتاپ) */} {!usePortrait && (
)} {/* page-flip با مختصات LTR کار می‌کند؛ direction:rtl روی روت کتاب باعث ناهماهنگی ایندکس/onFlip با دکمه‌ها می‌شود. محتوای فارسی داخل BookPage با dir=rtl است. */}
{displayPages.map((page) => { const background = resolvePageBackground(page, pageBackgroundDefaults); return ( ); })}
{hasMultiplePages && ( <> {/* دکمه راست: فارسی = صفحه قبل | لاتین = صفحه بعد */} )} {/* شماره صفحه */}
صفحه {Math.min(currentPage + 1, pages.length)} از {pages.length}
{hasMultiplePages && ( <> {/* دکمه چپ: فارسی = صفحه بعد | لاتین = صفحه قبل */} )} {hasMultiplePages && autoPlay && }
); }; export default BookViewer;