670 lines
27 KiB
TypeScript
670 lines
27 KiB
TypeScript
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 { 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);
|
||
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 {
|
||
scheduleEntranceForSpread,
|
||
getEntrancePhase,
|
||
reset: resetEntrance,
|
||
} = useBookEntranceController({
|
||
pages,
|
||
portrait: usePortrait,
|
||
showCover: hasMultiplePages,
|
||
});
|
||
|
||
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));
|
||
scheduleEntranceForSpread(toLogicalIndex(flipIdx));
|
||
}, [flipIndexCount, scheduleEntranceForSpread, toLogicalIndex]);
|
||
|
||
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') هم باید انیمیشن ورود بگیرد
|
||
scheduleEntranceForSpread(logicalIdx);
|
||
},
|
||
[flipIndexCount, scheduleEntranceForSpread, toLogicalIndex],
|
||
);
|
||
|
||
/** بعد از اتمام انیمیشن ورق، ایندکس واقعی کتاب را میگیریم و انیمیشن ورود را یکبار شروع میکنیم */
|
||
const handleChangeState = useCallback(
|
||
(e: PageFlipStateEvent) => {
|
||
if (e.data !== "read") return;
|
||
syncPageIndexFromBook();
|
||
commitEntranceAtCurrentSpread();
|
||
},
|
||
[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;
|
||
scheduleEntranceForSpread(logicalIdx);
|
||
},
|
||
[pages.length, scheduleEntranceForSpread, 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="hidden md:block absolute inset-y-0 left-1/2 -translate-x-1/2 w-px border-r border-gray-300 pointer-events-none 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="max-w-full flex justify-center shrink-0 rounded-sm"
|
||
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) {
|
||
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 ? "صفحه قبل" : "صفحه بعد"}
|
||
>
|
||
<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) {
|
||
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 ? "صفحه بعد" : "صفحه قبل"}
|
||
>
|
||
<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;
|