515 lines
22 KiB
TypeScript
515 lines
22 KiB
TypeScript
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 } 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<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 };
|
||
};
|
||
|
||
/**
|
||
* 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 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);
|
||
|
||
// موبایل: از ابعاد 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<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 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 handleLinkClick = useCallback((linkUrl: string) => {
|
||
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 (
|
||
<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 justify-center flex-1 max-md:min-h-0 items-center"
|
||
style={{ overflow: isDesktop ? 'hidden' : 'visible' }}
|
||
>
|
||
{/* Border و Shadow عمودی در وسط (فقط دوسوّره/دسکتاپ) */}
|
||
<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
|
||
dir="ltr"
|
||
className="max-w-full flex justify-center"
|
||
style={{ overflow: isDesktop ? 'hidden' : 'visible', isolation: 'isolate' }}
|
||
>
|
||
<HTMLFlipBook
|
||
key={flipKey}
|
||
ref={bookRef}
|
||
width={pagePixelWidth}
|
||
height={pagePixelHeight}
|
||
size="fixed"
|
||
minWidth={pagePixelWidth}
|
||
minHeight={pagePixelHeight}
|
||
maxWidth={pagePixelWidth}
|
||
maxHeight={pagePixelHeight}
|
||
drawShadow={isDesktop}
|
||
flippingTime={800}
|
||
usePortrait={usePortrait}
|
||
startPage={startPage}
|
||
autoSize={false}
|
||
maxShadowOpacity={isDesktop ? 0.5 : 0}
|
||
showCover={true}
|
||
mobileScrollSupport={!usePortrait}
|
||
clickEventForward={true}
|
||
useMouseEvents={true}
|
||
swipeDistance={usePortrait ? 48 : 30}
|
||
startZIndex={1}
|
||
showPageCorners={true}
|
||
disableFlipByClick={true}
|
||
className="flipbook-container mx-auto max-w-full"
|
||
style={{}}
|
||
onFlip={onFlipForwarded}
|
||
onInit={handleBookInit}
|
||
onChangeState={handleChangeState}
|
||
>
|
||
{pages.map((page) => (
|
||
<BookPage
|
||
key={page.id}
|
||
page={page}
|
||
scale={contentScale}
|
||
pageWidth={pagePixelWidth}
|
||
pageHeight={pagePixelHeight}
|
||
onLinkClick={handleLinkClick}
|
||
backgroundType={backgroundType}
|
||
backgroundColor={backgroundColor}
|
||
backgroundImageUrl={backgroundImageUrl}
|
||
/>
|
||
))}
|
||
</HTMLFlipBook>
|
||
</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">
|
||
{/* دکمه راست: فارسی = صفحه بعد | لاتین = صفحه قبل */}
|
||
<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>
|
||
|
||
{/* دکمه چپ: فارسی = صفحه قبل | لاتین = صفحه بعد */}
|
||
<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>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default BookViewer;
|