diff --git a/src/pages/viewer/components/BookViewer.tsx b/src/pages/viewer/components/BookViewer.tsx index 912fed7..6e0c9c8 100644 --- a/src/pages/viewer/components/BookViewer.tsx +++ b/src/pages/viewer/components/BookViewer.tsx @@ -26,6 +26,8 @@ const BOOK_TOP_GAP_MOBILE = 32; 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); @@ -135,12 +137,47 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings 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; - const displayPages = useMemo(() => (isRtlBook ? [...pages].reverse() : pages), [pages, isRtlBook]); - const toLogicalIndex = useCallback((flipIndex: number) => (isRtlBook ? pages.length - 1 - flipIndex : flipIndex), [isRtlBook, pages.length]); - const toFlipIndex = useCallback((logicalIndex: number) => (isRtlBook ? pages.length - 1 - logicalIndex : logicalIndex), [isRtlBook, pages.length]); - const displayStyle = documentSettings?.displayStyle ?? "double"; + /** + * 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; @@ -158,7 +195,6 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings const { width: bookWidth, height: bookHeight } = useMemo(() => getPaperDimensions(catalogSize ?? "a4"), [catalogSize]); const hasMultiplePages = pages.length > 1; const viewport = useViewerViewport(); - const usePortrait = !isDesktop || displayStyle === "single"; const viewportFit = useMemo(() => { const padX = isDesktop ? 48 : 20; @@ -183,7 +219,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings const pagePixelHeight = viewportFit.h; const contentScale = viewportFit.contentScale; - const flipKey = `${usePortrait ? "portrait" : "spread"}-${pagePixelWidth}-${pagePixelHeight}`; + const flipKey = `${usePortrait ? "portrait" : "spread"}-${pagePixelWidth}-${pagePixelHeight}-${flipIndexCount}`; const { scheduleEntranceForSpread, @@ -198,10 +234,10 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings const commitEntranceAtCurrentSpread = useCallback(() => { const api = bookRef.current?.pageFlip(); const idx = api?.getCurrentPageIndex?.(); - if (typeof idx !== "number" || pages.length <= 0) return; - const flipIdx = Math.max(0, Math.min(idx, pages.length - 1)); + if (typeof idx !== "number" || flipIndexCount <= 0) return; + const flipIdx = Math.max(0, Math.min(idx, flipIndexCount - 1)); scheduleEntranceForSpread(toLogicalIndex(flipIdx)); - }, [pages.length, scheduleEntranceForSpread, toLogicalIndex]); + }, [flipIndexCount, scheduleEntranceForSpread, toLogicalIndex]); useEffect(() => { resetEntrance(); @@ -284,8 +320,8 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings // اگر flipIndex منفی است یا بزرگتر از تعداد صفحات، آن را محدود می‌کنیم if (flipIndex < 0) { flipIndex = 0; - } else if (flipIndex >= pages.length) { - flipIndex = pages.length - 1; + } else if (flipIndex >= flipIndexCount) { + flipIndex = flipIndexCount - 1; } const pageNum = toLogicalIndex(flipIndex); @@ -297,7 +333,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings setCurrentPage(pageNum); }, - [pages.length, pageFlipSound, playPageFlipSound, toLogicalIndex], + [flipIndexCount, pageFlipSound, playPageFlipSound, toLogicalIndex], ); pageFlipHandlerRef.current = handlePageFlip; @@ -309,23 +345,23 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings const syncPageIndexFromBook = useCallback(() => { const api = bookRef.current?.pageFlip(); const idx = api?.getCurrentPageIndex?.(); - if (typeof idx !== "number" || pages.length <= 0) return; - const flipIdx = Math.max(0, Math.min(idx, pages.length - 1)); + if (typeof idx !== "number" || flipIndexCount <= 0) return; + const flipIdx = Math.max(0, Math.min(idx, flipIndexCount - 1)); setCurrentPage(toLogicalIndex(flipIdx)); - }, [pages.length, toLogicalIndex]); + }, [flipIndexCount, toLogicalIndex]); const handleBookInit = useCallback( (e: PageFlipInitEvent) => { const p = e.data?.page; - if (typeof p !== "number" || pages.length <= 0) return; - const flipIdx = Math.max(0, Math.min(p, pages.length - 1)); + 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') هم باید انیمیشن ورود بگیرد scheduleEntranceForSpread(logicalIdx); }, - [pages.length, scheduleEntranceForSpread, toLogicalIndex], + [flipIndexCount, scheduleEntranceForSpread, toLogicalIndex], ); /** بعد از اتمام انیمیشن ورق، ایندکس واقعی کتاب را می‌گیریم و انیمیشن ورود را یک‌بار شروع می‌کنیم */ @@ -436,19 +472,17 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings useEffect(() => { if (pages.length <= 0) return; - const initialFlipPage = isRtlBook ? pages.length - 1 : 0; - setStartPage(initialFlipPage); + setStartPage(toFlipIndex(0)); setCurrentPage(0); lastFlipPageRef.current = 0; - }, [pages.length, pages[0]?.id, isRtlBook]); + }, [pages.length, pages[0]?.id, toFlipIndex]); // پخش خودکار useEffect(() => { if (!isAutoPlayActive || pages.length <= 1) return; const timer = setInterval(() => { if (currentPage >= pages.length - 1) { - const firstFlipPage = isRtlBook ? pages.length - 1 : 0; - bookRef.current?.pageFlip()?.turnToPage?.(firstFlipPage); + bookRef.current?.pageFlip()?.turnToPage?.(toFlipIndex(0)); setCurrentPage(0); lastFlipPageRef.current = 0; return; @@ -461,7 +495,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings } }, AUTO_PLAY_INTERVAL_MS); return () => clearInterval(timer); - }, [isAutoPlayActive, currentPage, pages.length, isRtlBook]); + }, [isAutoPlayActive, currentPage, pages.length, isRtlBook, toFlipIndex]); return (