Files
dpage-editor/src/pages/viewer/components/BookViewer.tsx
T
2026-05-06 14:44:45 +03:30

515 lines
22 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 { 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;