Files
dpage-editor/src/pages/viewer/components/BookViewer.tsx
T
hamid zarghami 0391513928
deploy to danak / build_and_deploy (push) Has been cancelled
next and prev
2026-07-01 16:30:23 +03:30

705 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<DocumentSettings>;
};
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<AutoPlayToggleButtonProps> = ({ isActive, onToggle }) => (
<button
type="button"
onClick={onToggle}
className="px-3 py-1.5 text-xs md:text-sm font-medium inline-flex items-center justify-center gap-1.5 rounded-full border border-gray-300 hover:bg-gray-100 active:bg-gray-200 transition-all"
aria-label={isActive ? "توقف پخش خودکار" : "شروع پخش خودکار"}
>
{isActive ? <Pause color="black" size={16} className="text-gray-700 md:w-[18px] md:h-[18px]" /> : <Play color="black" size={16} className="text-gray-700 md:w-[18px] md:h-[18px]" />}
<span>{isActive ? "توقف پخش" : "پخش خودکار"}</span>
</button>
);
/**
* 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<BookViewerProps> = ({ pages, catalogSize, documentSettings }) => {
const bookRef = useRef<FlipBookInstance | null>(null);
const bookAreaRef = useRef<HTMLDivElement | null>(null);
const flipbookWrapperRef = useRef<HTMLDivElement | null>(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<number>();
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<HTMLAudioElement | null>(null);
const pageFlipSoundTrimListenerRef = useRef<(() => void) | null>(null);
const lastFlipPageRef = useRef<number | null>(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 (
<div className="flex flex-col items-center gap-4 md:gap-6 w-full h-full max-md:min-h-0 max-md:max-w-full max-md:overflow-x-hidden max-md:pb-[env(safe-area-inset-bottom,0px)]" dir="rtl">
<div className="relative w-full flex flex-col flex-1 max-md:min-h-0 pt-8 md:pt-12 min-h-0 overflow-hidden">
<div ref={bookAreaRef} className="relative flex flex-1 min-h-0 w-full items-center justify-center">
{/* Border و Shadow عمودی در وسط (فقط حالت دوصفحه‌ای دسکتاپ) */}
{!usePortrait && (
<div
className={clx(
"hidden md:block absolute inset-y-0 left-1/2 -translate-x-1/2 w-px border-r border-gray-300 pointer-events-none transition-none",
isBookFlipping ? "z-0" : "z-50",
)}
style={{
boxShadow: "-5px 0 15px rgba(0, 0, 0, 0.15), 5px 0 15px rgba(0, 0, 0, 0.15)",
}}
/>
)}
{/*
page-flip با مختصات LTR کار می‌کند؛ direction:rtl روی روت کتاب باعث ناهماهنگی
ایندکس/onFlip با دکمه‌ها می‌شود. محتوای فارسی داخل BookPage با dir=rtl است.
*/}
<div
ref={flipbookWrapperRef}
dir="ltr"
className={clx(
"max-w-full flex justify-center shrink-0 rounded-sm",
isBookFlipping && "relative z-10",
)}
style={{
cursor: magnifierEnabled ? "zoom-in" : undefined,
overflow: "hidden",
width: usePortrait ? pagePixelWidth : pagePixelWidth * 2,
height: pagePixelHeight,
boxShadow: isDesktop ? undefined : "0 8px 32px rgba(0, 0, 0, 0.14), 0 2px 8px rgba(0, 0, 0, 0.08)",
}}
>
<HTMLFlipBook
key={flipKey}
ref={bookRef}
width={pagePixelWidth}
height={pagePixelHeight}
size="fixed"
minWidth={pagePixelWidth}
minHeight={pagePixelHeight}
maxWidth={pagePixelWidth}
maxHeight={pagePixelHeight}
drawShadow={!legacyIOS}
flippingTime={800}
usePortrait={usePortrait}
startPage={startPage}
autoSize={false}
maxShadowOpacity={legacyIOS ? 0 : isDesktop ? 0.55 : 0.32}
showCover={hasMultiplePages}
mobileScrollSupport={!usePortrait}
clickEventForward={true}
useMouseEvents={hasMultiplePages && !magnifierEnabled}
swipeDistance={hasMultiplePages ? (usePortrait ? 48 : 30) : 9999}
startZIndex={1}
showPageCorners={hasMultiplePages}
disableFlipByClick={true}
className={clx(
"flipbook-container mx-auto max-w-full",
legacyIOS && "flipbook-legacy-ios",
magnifierEnabled && "flipbook-magnifier-active",
)}
style={{}}
onFlip={onFlipForwarded}
onInit={handleBookInit}
onChangeState={handleChangeState}
>
{displayPages.map((page) => {
const background = resolvePageBackground(page, pageBackgroundDefaults);
return (
<BookPage
key={page.id}
page={page}
entrancePhase={getEntrancePhase(page.id)}
scale={contentScale}
pageWidth={pagePixelWidth}
pageHeight={pagePixelHeight}
onLinkClick={handleLinkClick}
backgroundType={background.backgroundType}
backgroundColor={background.backgroundColor}
backgroundGradient={background.backgroundGradient}
backgroundImageUrl={background.backgroundImageUrl}
useHardPage={legacyIOS}
/>
);
})}
</HTMLFlipBook>
</div>
<Magnifier
enabled={magnifierEnabled}
containerRef={bookAreaRef}
interactionRef={flipbookWrapperRef}
pages={displayPages}
pageWidth={pagePixelWidth}
pageHeight={pagePixelHeight}
contentScale={contentScale}
pageDefaults={pageBackgroundDefaults}
useHardPage={legacyIOS}
pageIndex={currentPage}
/>
</div>
</div>
<div className="shrink-0 max-md:min-h-12 flex items-center gap-3 md:gap-6 bg-white rounded-full px-4 md:px-6 py-2 md:py-3 shadow-lg">
{hasMultiplePages && (
<>
{/* دکمه راست: فارسی = صفحه قبل | لاتین = صفحه بعد */}
<button
type="button"
onClick={() => {
stopAutoPlayByUser();
if (leftToRightFlip) {
goToNextPage();
} else {
goToPrevPage();
}
}}
disabled={leftToRightFlip ? currentPage >= pages.length - 1 : currentPage <= 0}
className="p-2 md:p-2 min-h-11 min-w-11 md:min-h-0 md:min-w-0 inline-flex items-center justify-center rounded-full hover:bg-gray-100 active:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition-all"
aria-label={leftToRightFlip ? "صفحه بعد" : "صفحه قبل"}
>
<ArrowRight2 color="black" size={20} className="text-gray-700 md:w-6 md:h-6" />
</button>
</>
)}
{/* شماره صفحه */}
<div className="flex items-center gap-2 min-w-[100px] md:min-w-[120px] justify-center">
<span className="text-xs md:text-sm text-gray-600">
صفحه {Math.min(currentPage + 1, pages.length)} از {pages.length}
</span>
</div>
{hasMultiplePages && (
<>
{/* دکمه چپ: فارسی = صفحه بعد | لاتین = صفحه قبل */}
<button
type="button"
onClick={() => {
stopAutoPlayByUser();
if (leftToRightFlip) {
goToPrevPage();
} else {
goToNextPage();
}
}}
disabled={leftToRightFlip ? currentPage <= 0 : currentPage >= pages.length - 1}
className="p-2 md:p-2 min-h-11 min-w-11 md:min-h-0 md:min-w-0 inline-flex items-center justify-center rounded-full hover:bg-gray-100 active:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition-all"
aria-label={leftToRightFlip ? "صفحه قبل" : "صفحه بعد"}
>
<ArrowLeft2 color="black" size={20} className="text-gray-700 md:w-6 md:h-6" />
</button>
</>
)}
{hasMultiplePages && autoPlay && <AutoPlayToggleButton isActive={isAutoPlayActive} onToggle={toggleAutoPlay} />}
<ZoomButton isActive={magnifierEnabled} onToggle={toggleMagnifier} />
<button
type="button"
onClick={toggleViewerFullscreen}
className="p-2 md:p-2 min-h-11 min-w-11 md:min-h-0 md:min-w-0 inline-flex items-center justify-center rounded-full hover:bg-gray-100 active:bg-gray-200 transition-all"
aria-label={isBrowserFullscreen ? "خروج از تمام‌صفحه" : "تمام‌صفحه مرورگر"}
title={isBrowserFullscreen ? "خروج از تمام‌صفحه" : "تمام‌صفحه مرورگر"}
>
{isBrowserFullscreen ? <RowVertical color="black" size={20} className="text-gray-700 md:w-6 md:h-6" /> : <Maximize4 color="black" size={20} className="text-gray-700 md:w-6 md:h-6" />}
</button>
</div>
</div>
);
};
export default BookViewer;