diff --git a/src/pages/catalogue/components/CatalogueItem.tsx b/src/pages/catalogue/components/CatalogueItem.tsx index 62f369b..7b4489f 100644 --- a/src/pages/catalogue/components/CatalogueItem.tsx +++ b/src/pages/catalogue/components/CatalogueItem.tsx @@ -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 = ({ item, refetch }) => {
- +
diff --git a/src/pages/viewer/Viewer.tsx b/src/pages/viewer/Viewer.tsx index f4fe756..0cd0d57 100644 --- a/src/pages/viewer/Viewer.tsx +++ b/src/pages/viewer/Viewer.tsx @@ -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 (
@@ -63,7 +66,7 @@ const Viewer: FC = () => { } return ( -
+
); diff --git a/src/pages/viewer/components/BookViewer.tsx b/src/pages/viewer/components/BookViewer.tsx index 4dde3cc..7276949 100644 --- a/src/pages/viewer/components/BookViewer.tsx +++ b/src/pages/viewer/components/BookViewer.tsx @@ -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 = ({ 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 = ({ 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 = ({ pages, catalogSize, documentSettings dir="rtl" >
+
{/* Border و Shadow عمودی در وسط (فقط حالت دوصفحه‌ای دسکتاپ) */} {!usePortrait && (
= ({ pages, catalogSize, documentSettings ))}
+
@@ -582,6 +571,20 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings {hasMultiplePages && autoPlay && ( )} + +
); diff --git a/src/pages/viewer/hooks/useBrowserFullscreen.ts b/src/pages/viewer/hooks/useBrowserFullscreen.ts new file mode 100644 index 0000000..93f74ff --- /dev/null +++ b/src/pages/viewer/hooks/useBrowserFullscreen.ts @@ -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]); +} diff --git a/src/pages/viewer/hooks/useIsBrowserFullscreen.ts b/src/pages/viewer/hooks/useIsBrowserFullscreen.ts new file mode 100644 index 0000000..31573cb --- /dev/null +++ b/src/pages/viewer/hooks/useIsBrowserFullscreen.ts @@ -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); +} diff --git a/src/pages/viewer/hooks/useViewerViewport.ts b/src/pages/viewer/hooks/useViewerViewport.ts new file mode 100644 index 0000000..46aaacd --- /dev/null +++ b/src/pages/viewer/hooks/useViewerViewport.ts @@ -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(() => + 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; +} diff --git a/src/pages/viewer/utils/viewerFullscreen.ts b/src/pages/viewer/utils/viewerFullscreen.ts new file mode 100644 index 0000000..1de054f --- /dev/null +++ b/src/pages/viewer/utils/viewerFullscreen.ts @@ -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; +} diff --git a/src/router/MainRouter.tsx b/src/router/MainRouter.tsx index b01b404..118fca6 100644 --- a/src/router/MainRouter.tsx +++ b/src/router/MainRouter.tsx @@ -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 ( -
- {shouldRenderSideBar ? : null} -
+
+ {!isViewerRoute && shouldRenderSideBar ? : null} + {!isViewerRoute ? ( +
+ ) : null}
-
-
+
+
} /> } /> diff --git a/src/shared/Header.tsx b/src/shared/Header.tsx index d1d4b4f..93d3c65 100644 --- a/src/shared/Header.tsx +++ b/src/shared/Header.tsx @@ -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 = ({ hasMainSidebar = true, sidebarSize = 'default className='flex items-center' aria-label={t('header.open_viewer')} title={t('header.open_viewer')} + onClick={requestViewerFullscreen} >