book viewer full screen
This commit is contained in:
@@ -9,6 +9,7 @@ import TrashWithConfrim from '@/components/TrashWithConfrim'
|
||||
import { useDleteCatalog } from '@/pages/home/hooks/useHomeData'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { extractErrorMessage } from '@/helpers/utils'
|
||||
import { requestViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen'
|
||||
|
||||
type Props = {
|
||||
item: CatalogItemType,
|
||||
@@ -69,7 +70,7 @@ const CatalogueItem: FC<Props> = ({ item, refetch }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end gap-1 items-center mt-1'>
|
||||
<Link to={Paths.viewer + `/${item.id}`}>
|
||||
<Link to={Paths.viewer + `/${item.id}`} onClick={requestViewerFullscreen}>
|
||||
<div className='size-6 bg-[#EAECF4] rounded-md flex justify-center items-center'>
|
||||
<Eye size={18} color='#0047FF' />
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PageData } from './types';
|
||||
import type { DocumentSettings } from '@/pages/editor/store/editorStore';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGetCatalogById } from '@/pages/home/hooks/useHomeData';
|
||||
import { useBrowserFullscreen } from './hooks/useBrowserFullscreen';
|
||||
|
||||
const Viewer: FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -30,6 +31,8 @@ const Viewer: FC = () => {
|
||||
}
|
||||
}, [data?.data?.content, id]);
|
||||
|
||||
useBrowserFullscreen({ enabled: Boolean(id) });
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
@@ -63,7 +66,7 @@ const Viewer: FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="w-full h-full min-h-0 flex flex-col">
|
||||
<BookViewer pages={pages} catalogSize={data?.data?.size} documentSettings={documentSettings} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { type FC, useRef, useCallback, useState, useEffect, useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
import { type FC, useRef, useCallback, useState, useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
import HTMLFlipBook from 'react-pageflip';
|
||||
import { type PageData } from '../types';
|
||||
import BookPage from './BookPage';
|
||||
import { ArrowLeft2, ArrowRight2, Pause, Play } from 'iconsax-react';
|
||||
import { ArrowLeft2, ArrowRight2, Maximize4, Pause, Play, RowVertical } from 'iconsax-react';
|
||||
import { getPaperDimensions } from '@/config/paperSizes';
|
||||
import type { DocumentSettings } from '@/pages/editor/store/editorStore';
|
||||
import { useBookEntranceController } from '@/pages/viewer/hooks/useBookEntranceController';
|
||||
import { useViewerViewport } from '@/pages/viewer/hooks/useViewerViewport';
|
||||
import { useIsBrowserFullscreen } from '@/pages/viewer/hooks/useIsBrowserFullscreen';
|
||||
import { toggleViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen';
|
||||
|
||||
/** فاصله پیشفرض بین هر ورق در پخش خودکار (میلیثانیه) */
|
||||
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) — موبایل: زیر این عرض */
|
||||
@@ -111,6 +117,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
const [isAutoPlayActive, setIsAutoPlayActive] = useState(false);
|
||||
const [startPage, setStartPage] = useState(0);
|
||||
const isDesktop = useIsDesktop();
|
||||
const isBrowserFullscreen = useIsBrowserFullscreen();
|
||||
|
||||
const autoPlay = documentSettings?.autoPlay ?? false;
|
||||
const pageFlipSound = documentSettings?.pageFlipSound ?? true;
|
||||
@@ -130,54 +137,34 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
() => getPaperDimensions(catalogSize ?? 'a4'),
|
||||
[catalogSize]
|
||||
);
|
||||
const displayWidth = Math.round(bookWidth * VIEWER_SCALE);
|
||||
const displayHeight = Math.round(bookHeight * VIEWER_SCALE);
|
||||
const hasMultiplePages = pages.length > 1;
|
||||
const viewport = useViewerViewport();
|
||||
const usePortrait = !isDesktop || displayStyle === 'single';
|
||||
|
||||
// موبایل: از ابعاد 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 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 - edgeBleed);
|
||||
const baseW = bookWidth * VIEWER_SCALE;
|
||||
const baseH = bookHeight * VIEWER_SCALE;
|
||||
const scale = Math.min(1, maxW / baseW, maxH / baseH);
|
||||
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(baseW * scale)),
|
||||
h: Math.max(1, Math.round(baseH * scale)),
|
||||
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]);
|
||||
}, [isDesktop, bookWidth, bookHeight, viewport.w, viewport.h, usePortrait]);
|
||||
|
||||
// دسکتاپ: دقیقاً مثل قبل — موبایل: مقیاسشده
|
||||
const pagePixelWidth = isDesktop ? displayWidth : (mobileFit?.w ?? displayWidth);
|
||||
const pagePixelHeight = isDesktop ? displayHeight : (mobileFit?.h ?? displayHeight);
|
||||
const contentScale = isDesktop ? VIEWER_SCALE : (mobileFit?.contentScale ?? VIEWER_SCALE);
|
||||
const pagePixelWidth = viewportFit.w;
|
||||
const pagePixelHeight = viewportFit.h;
|
||||
const contentScale = viewportFit.contentScale;
|
||||
|
||||
const flipKey = isDesktop
|
||||
? 'desktop'
|
||||
: `mobile-${pagePixelWidth}-${pagePixelHeight}`;
|
||||
|
||||
const usePortrait = !isDesktop || displayStyle === 'single';
|
||||
const flipKey = `${usePortrait ? 'portrait' : 'spread'}-${pagePixelWidth}-${pagePixelHeight}`;
|
||||
|
||||
const {
|
||||
scheduleEntranceForSpread,
|
||||
@@ -455,9 +442,10 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
dir="rtl"
|
||||
>
|
||||
<div
|
||||
className="relative w-full flex justify-center flex-1 max-md:min-h-0 items-center"
|
||||
className="relative w-full flex flex-col flex-1 max-md:min-h-0 pt-8 md:pt-12 min-h-0"
|
||||
style={{ overflow: isDesktop ? 'hidden' : 'visible' }}
|
||||
>
|
||||
<div className="relative flex flex-1 min-h-0 w-full items-center justify-center">
|
||||
{/* Border و Shadow عمودی در وسط (فقط حالت دوصفحهای دسکتاپ) */}
|
||||
{!usePortrait && (
|
||||
<div
|
||||
@@ -524,6 +512,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
</HTMLFlipBook>
|
||||
</div>
|
||||
</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 && (
|
||||
@@ -582,6 +571,20 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
{hasMultiplePages && autoPlay && (
|
||||
<AutoPlayToggleButton isActive={isAutoPlayActive} onToggle={toggleAutoPlay} />
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
import { exitViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen';
|
||||
|
||||
type Options = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/** Leaves browser fullscreen when the viewer route unmounts. */
|
||||
export function useBrowserFullscreen({ enabled }: Options) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
return () => {
|
||||
exitViewerFullscreen();
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { isViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen';
|
||||
|
||||
function subscribeFullscreen(callback: () => void) {
|
||||
document.addEventListener('fullscreenchange', callback);
|
||||
return () => document.removeEventListener('fullscreenchange', callback);
|
||||
}
|
||||
|
||||
export function useIsBrowserFullscreen() {
|
||||
return useSyncExternalStore(subscribeFullscreen, isViewerFullscreen, () => false);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
export type ViewerViewport = { w: number; h: number };
|
||||
|
||||
function readViewport(): ViewerViewport {
|
||||
const vv = window.visualViewport;
|
||||
return {
|
||||
w: Math.round(vv?.width ?? window.innerWidth),
|
||||
h: Math.round(vv?.height ?? window.innerHeight),
|
||||
};
|
||||
}
|
||||
|
||||
/** Tracks layout size; updates on resize, orientation, fullscreen, and visualViewport changes. */
|
||||
export function useViewerViewport(): ViewerViewport {
|
||||
const [viewport, setViewport] = useState<ViewerViewport>(() =>
|
||||
typeof window !== 'undefined' ? readViewport() : { w: 1200, h: 800 },
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const update = () => setViewport(readViewport());
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('orientationchange', update);
|
||||
document.addEventListener('fullscreenchange', update);
|
||||
window.visualViewport?.addEventListener('resize', update);
|
||||
window.visualViewport?.addEventListener('scroll', update);
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('orientationchange', update);
|
||||
document.removeEventListener('fullscreenchange', update);
|
||||
window.visualViewport?.removeEventListener('resize', update);
|
||||
window.visualViewport?.removeEventListener('scroll', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return viewport;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Enter browser fullscreen (must run inside a user-gesture handler, e.g. link click). */
|
||||
export function requestViewerFullscreen(): void {
|
||||
if (document.fullscreenElement) return;
|
||||
void document.documentElement.requestFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
export function exitViewerFullscreen(): void {
|
||||
if (document.fullscreenElement !== document.documentElement) return;
|
||||
void document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
export function toggleViewerFullscreen(): void {
|
||||
if (document.fullscreenElement) {
|
||||
exitViewerFullscreen();
|
||||
} else {
|
||||
requestViewerFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
export function isViewerFullscreen(): boolean {
|
||||
return document.fullscreenElement === document.documentElement;
|
||||
}
|
||||
+24
-10
@@ -13,24 +13,38 @@ import DesignerRequest from '@/pages/designer/Request'
|
||||
const MainRouter = () => {
|
||||
const { hasSubMenu } = useSharedStore()
|
||||
const location = useLocation()
|
||||
const viewerPathPrefix = `${Paths.viewer}/`
|
||||
const isViewerRoute =
|
||||
location.pathname.startsWith(viewerPathPrefix) &&
|
||||
location.pathname.length > viewerPathPrefix.length
|
||||
const hiddenSideBarRoutes = [Paths.editor]
|
||||
const shouldRenderSideBar = !hiddenSideBarRoutes.includes(location.pathname)
|
||||
const shouldRenderSideBar =
|
||||
!hiddenSideBarRoutes.includes(location.pathname) && !isViewerRoute
|
||||
const routeHasLocalSidebar = location.pathname.includes('editor')
|
||||
const hasSidebarSpace = shouldRenderSideBar || routeHasLocalSidebar
|
||||
const headerSidebarSize = routeHasLocalSidebar ? 'wide' : 'default'
|
||||
|
||||
return (
|
||||
<div className='flex min-h-full flex-col overflow-hidden p-4'>
|
||||
{shouldRenderSideBar ? <SideBar /> : null}
|
||||
<Header hasMainSidebar={hasSidebarSpace} sidebarSize={headerSidebarSize} />
|
||||
<div className={clx(
|
||||
'mt-[68px] flex flex-1 flex-col xl:mt-[81px]',
|
||||
shouldRenderSideBar && 'xl:ms-[269px]',
|
||||
shouldRenderSideBar && hasSubMenu && 'xl:ms-[305px]',
|
||||
routeHasLocalSidebar && 'xl:mr-[374px]',
|
||||
'flex min-h-full flex-col overflow-hidden',
|
||||
isViewerRoute ? 'fixed inset-0 z-50 bg-neutral-100' : 'p-4',
|
||||
)}>
|
||||
<div className='flex-1 flex flex-col overflow-auto w-full max-h-[calc(100vh-113px)]'>
|
||||
<div className='flex-1 h-full flex'>
|
||||
{!isViewerRoute && shouldRenderSideBar ? <SideBar /> : null}
|
||||
{!isViewerRoute ? (
|
||||
<Header hasMainSidebar={hasSidebarSpace} sidebarSize={headerSidebarSize} />
|
||||
) : null}
|
||||
<div className={clx(
|
||||
'flex flex-1 flex-col min-h-0',
|
||||
!isViewerRoute && 'mt-[68px] xl:mt-[81px]',
|
||||
!isViewerRoute && shouldRenderSideBar && 'xl:ms-[269px]',
|
||||
!isViewerRoute && shouldRenderSideBar && hasSubMenu && 'xl:ms-[305px]',
|
||||
!isViewerRoute && routeHasLocalSidebar && 'xl:mr-[374px]',
|
||||
)}>
|
||||
<div className={clx(
|
||||
'flex-1 flex flex-col w-full min-h-0',
|
||||
isViewerRoute ? 'h-full overflow-hidden' : 'overflow-auto max-h-[calc(100vh-113px)]',
|
||||
)}>
|
||||
<div className='flex-1 h-full flex min-h-0'>
|
||||
<Routes>
|
||||
<Route path={Paths.home} element={<Home />} />
|
||||
<Route path={Paths.editor + '/:id'} element={<Editor />} />
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Paths } from '@/config/Paths'
|
||||
import { useSharedStore } from '@/shared/store/sharedStore'
|
||||
import { Eye, HambergerMenu } from 'iconsax-react'
|
||||
import { clx } from '@/helpers/utils'
|
||||
import { requestViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen'
|
||||
|
||||
type SidebarSize = 'default' | 'wide'
|
||||
|
||||
@@ -85,6 +86,7 @@ const Header: FC<HeaderProps> = ({ hasMainSidebar = true, sidebarSize = 'default
|
||||
className='flex items-center'
|
||||
aria-label={t('header.open_viewer')}
|
||||
title={t('header.open_viewer')}
|
||||
onClick={requestViewerFullscreen}
|
||||
>
|
||||
<Eye size={20} color='black' />
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user