diff --git a/src/index.css b/src/index.css index 6706ac7..fa55163 100644 --- a/src/index.css +++ b/src/index.css @@ -159,7 +159,7 @@ html { isolation: isolate; } -/* سایهٔ هر ورق — فقط inset؛ بدون --simple تا موقع ورق هم بماند */ +/* سایهٔ هر ورق — inset؛ بدون --simple تا موقع ورق هم بماند */ .flipbook-container .stf__item.--left .page { box-shadow: inset -16px 0 24px -12px rgba(0, 0, 0, 0.2), @@ -193,7 +193,6 @@ html { background: linear-gradient(to right, rgba(0, 0, 0, 0.14) 0%, rgba(0, 0, 0, 0.04) 35%, transparent 100%); } -/* تک‌صفحه (portrait): سایهٔ لبهٔ ورق‌زدن */ .flipbook-container .stf__wrapper.--portrait .stf__item .page { box-shadow: inset 14px 0 22px -12px rgba(0, 0, 0, 0.16), @@ -226,6 +225,35 @@ html { } } +/* iOS قدیمی: inset box-shadow و contain/isolate خراب می‌شود */ +.flipbook-container.flipbook-legacy-ios .stf__item { + contain: none !important; + isolation: auto !important; +} + +.flipbook-container.flipbook-legacy-ios .stf__wrapper { + isolation: auto; +} + +.flipbook-container.flipbook-legacy-ios .stf__item .page { + isolation: auto; + box-shadow: none !important; +} + +.flipbook-container.flipbook-legacy-ios .stf__item .page::after { + display: none; +} + +.flipbook-container.flipbook-legacy-ios .page .page-paper-shadow { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 2; + background: + linear-gradient(to right, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.06) 16%, transparent 34%), + linear-gradient(to left, rgba(0, 0, 0, 0.12) 0%, rgba(0, 0, 0, 0.04) 14%, transparent 28%); +} + /* مخفی کردن محتوای صفحات غیرفعال - فقط صفحه فعلی قابل تعامل است */ .flipbook-container .stf__wrapper { overflow: hidden; diff --git a/src/pages/catalogue/components/CatalogPreview.tsx b/src/pages/catalogue/components/CatalogPreview.tsx index 8ff443c..116bffb 100644 --- a/src/pages/catalogue/components/CatalogPreview.tsx +++ b/src/pages/catalogue/components/CatalogPreview.tsx @@ -107,6 +107,7 @@ const CatalogPreview: FC = ({ item, page, size, className, selected }) => pageWidth={pageWidth} pageHeight={pageHeight} disableEntranceAnimations + showPaperShadow={false} /> diff --git a/src/pages/viewer/Viewer.tsx b/src/pages/viewer/Viewer.tsx index 0cd0d57..4319ba9 100644 --- a/src/pages/viewer/Viewer.tsx +++ b/src/pages/viewer/Viewer.tsx @@ -1,11 +1,13 @@ import { type FC, useEffect, useState } from 'react'; import BookViewer from './components/BookViewer'; +import ViewerLoading from './components/ViewerLoading'; import { transformViewerDataToPages } from './utils/dataTransformer'; 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'; +import { useInitialPagesPreload } from './hooks/useInitialPagesPreload'; const Viewer: FC = () => { const { id } = useParams<{ id: string }>(); @@ -32,6 +34,10 @@ const Viewer: FC = () => { }, [data?.data?.content, id]); useBrowserFullscreen({ enabled: Boolean(id) }); + const { isReady: areInitialPagesReady, progress: preloadProgress } = useInitialPagesPreload( + pages, + documentSettings, + ); if (!id) { return ( @@ -42,11 +48,7 @@ const Viewer: FC = () => { } if (isLoading) { - return ( -
-
در حال بارگذاری...
-
- ); + return ; } if (queryError) { @@ -65,6 +67,10 @@ const Viewer: FC = () => { ); } + if (!areInitialPagesReady) { + return ; + } + return (
diff --git a/src/pages/viewer/components/BookPage.tsx b/src/pages/viewer/components/BookPage.tsx index cc89b5d..cca2738 100644 --- a/src/pages/viewer/components/BookPage.tsx +++ b/src/pages/viewer/components/BookPage.tsx @@ -30,6 +30,10 @@ type BookPageProps = { entrancePhase?: EntrancePhase; /** در پیش‌نمایش کاتالوگ انیمیشن ورود غیرفعال باشد */ disableEntranceAnimations?: boolean; + /** سایهٔ لبهٔ کاغذ (گرادیان داخل DOM — سازگار با iOS قدیمی) */ + showPaperShadow?: boolean; + /** ورق سخت به‌جای clip-path برای iOS قدیمی */ + useHardPage?: boolean; }; /** @@ -40,7 +44,7 @@ type BookPageProps = { * نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند */ const BookPage = forwardRef( - ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, entrancePhase = 'idle', disableEntranceAnimations = false }, ref) => { + ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, entrancePhase = 'idle', disableEntranceAnimations = false, showPaperShadow = true, useHardPage = false }, ref) => { const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase; // تابع برای تبدیل opacity به رنگ (مثل editor) // در editor، getColorWithOpacity انتظار opacity 0-100 دارد @@ -646,30 +650,36 @@ const BookPage = forwardRef( ref={ref} dir="rtl" className="page" - data-density="soft" + data-density={useHardPage ? 'hard' : 'soft'} style={{ position: 'relative', overflow: 'hidden', boxSizing: 'border-box', width: `${width}px`, height: `${height}px`, - isolation: 'isolate', + isolation: useHardPage ? undefined : 'isolate', zIndex: 1, + ...(useHardPage + ? { backgroundColor: effectiveBackgroundColor, ...bgStyle } + : {}), }} > -
+ {!useHardPage && ( +
+ )}
{page.elements.map((element, index) => renderObject(element, index))}
+ {useHardPage && showPaperShadow &&
}
); }); diff --git a/src/pages/viewer/components/BookViewer.tsx b/src/pages/viewer/components/BookViewer.tsx index 90ccb77..567201e 100644 --- a/src/pages/viewer/components/BookViewer.tsx +++ b/src/pages/viewer/components/BookViewer.tsx @@ -9,6 +9,8 @@ import { useBookEntranceController } from '@/pages/viewer/hooks/useBookEntranceC import { useViewerViewport } from '@/pages/viewer/hooks/useViewerViewport'; import { useIsBrowserFullscreen } from '@/pages/viewer/hooks/useIsBrowserFullscreen'; import { toggleViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen'; +import { isLegacyIOSSafari } from '@/pages/viewer/utils/isLegacyIOSSafari'; +import { clx } from '@/helpers/utils'; /** فاصله پیش‌فرض بین هر ورق در پخش خودکار (میلی‌ثانیه) */ const AUTO_PLAY_INTERVAL_MS = 3000; @@ -36,6 +38,14 @@ function useIsDesktop() { ); } +function useLegacyIOSSafari() { + return useSyncExternalStore( + () => () => {}, + isLegacyIOSSafari, + () => false + ); +} + export type BookViewerProps = { pages: PageData[]; catalogSize?: string; @@ -117,6 +127,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings const [isAutoPlayActive, setIsAutoPlayActive] = useState(false); const [startPage, setStartPage] = useState(0); const isDesktop = useIsDesktop(); + const legacyIOS = useLegacyIOSSafari(); const isBrowserFullscreen = useIsBrowserFullscreen(); const autoPlay = documentSettings?.autoPlay ?? false; @@ -481,12 +492,12 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings minHeight={pagePixelHeight} maxWidth={pagePixelWidth} maxHeight={pagePixelHeight} - drawShadow={true} + drawShadow={!legacyIOS} flippingTime={800} usePortrait={usePortrait} startPage={startPage} autoSize={false} - maxShadowOpacity={isDesktop ? 0.55 : 0.32} + maxShadowOpacity={legacyIOS ? 0 : isDesktop ? 0.55 : 0.32} showCover={hasMultiplePages} mobileScrollSupport={!usePortrait} clickEventForward={true} @@ -495,7 +506,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings startZIndex={1} showPageCorners={hasMultiplePages} disableFlipByClick={true} - className="flipbook-container mx-auto max-w-full" + className={clx('flipbook-container mx-auto max-w-full', legacyIOS && 'flipbook-legacy-ios')} style={{}} onFlip={onFlipForwarded} onInit={handleBookInit} @@ -514,6 +525,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings backgroundColor={page.backgroundColor ?? backgroundColor} backgroundGradient={page.backgroundGradient ?? backgroundGradient} backgroundImageUrl={page.backgroundImageUrl ?? backgroundImageUrl} + useHardPage={legacyIOS} /> ))} diff --git a/src/pages/viewer/components/ViewerLoading.tsx b/src/pages/viewer/components/ViewerLoading.tsx new file mode 100644 index 0000000..a9ac6f4 --- /dev/null +++ b/src/pages/viewer/components/ViewerLoading.tsx @@ -0,0 +1,21 @@ +import { type FC } from 'react'; + +type ViewerLoadingProps = { + progress?: number; +}; + +const ViewerLoading: FC = ({ progress }) => ( +
+
+
در حال بارگذاری صفحات...
+ {typeof progress === 'number' && progress > 0 && progress < 100 && ( +
{progress}٪
+ )} +
+); + +export default ViewerLoading; diff --git a/src/pages/viewer/hooks/useInitialPagesPreload.ts b/src/pages/viewer/hooks/useInitialPagesPreload.ts new file mode 100644 index 0000000..43f89a5 --- /dev/null +++ b/src/pages/viewer/hooks/useInitialPagesPreload.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; +import type { DocumentSettings } from '@/pages/editor/store/editorStore'; +import type { PageData } from '../types'; +import { + INITIAL_PAGES_PRELOAD_COUNT, + preloadPagesMedia, +} from '../utils/pageMediaUrls'; + +export function useInitialPagesPreload( + pages: PageData[], + documentSettings?: Partial, +) { + const [isReady, setIsReady] = useState(false); + const [progress, setProgress] = useState(0); + + useEffect(() => { + if (pages.length === 0) { + setIsReady(false); + setProgress(0); + return; + } + + let cancelled = false; + setIsReady(false); + setProgress(0); + + const pagesToPreload = pages.slice( + 0, + Math.min(INITIAL_PAGES_PRELOAD_COUNT, pages.length), + ); + + void preloadPagesMedia(pagesToPreload, documentSettings, (loaded, total) => { + if (cancelled) return; + setProgress(total === 0 ? 100 : Math.round((loaded / total) * 100)); + }).then(() => { + if (!cancelled) { + setProgress(100); + setIsReady(true); + } + }); + + return () => { + cancelled = true; + }; + }, [pages, documentSettings]); + + return { isReady, progress }; +} diff --git a/src/pages/viewer/utils/isLegacyIOSSafari.ts b/src/pages/viewer/utils/isLegacyIOSSafari.ts new file mode 100644 index 0000000..fed4199 --- /dev/null +++ b/src/pages/viewer/utils/isLegacyIOSSafari.ts @@ -0,0 +1,18 @@ +/** iOS 14 و پایین‌تر: clip-path، inset box-shadow و contain/isolate باگ دارند */ +export function isLegacyIOSSafari(): boolean { + if (typeof navigator === 'undefined') return false; + + const ua = navigator.userAgent; + const isIOS = + /iPad|iPhone|iPod/.test(ua) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + + if (!isIOS) return false; + + const osMatch = ua.match(/OS (\d+)[_.](\d+)/); + if (osMatch) { + return parseInt(osMatch[1], 10) <= 14; + } + + return true; +} diff --git a/src/pages/viewer/utils/pageMediaUrls.ts b/src/pages/viewer/utils/pageMediaUrls.ts new file mode 100644 index 0000000..ce16910 --- /dev/null +++ b/src/pages/viewer/utils/pageMediaUrls.ts @@ -0,0 +1,122 @@ +import type { DocumentSettings } from '@/pages/editor/store/editorStore'; +import type { PageData } from '../types'; + +export const INITIAL_PAGES_PRELOAD_COUNT = 5; + +type MediaAsset = { + url: string; + kind: 'image' | 'video'; +}; + +const MEDIA_LOAD_TIMEOUT_MS = 15_000; + +function addImageUrl(urls: Map, url?: string) { + if (!url) return; + urls.set(url, { url, kind: 'image' }); +} + +function addVideoUrl(urls: Map, url?: string) { + if (!url) return; + urls.set(url, { url, kind: 'video' }); +} + +export function extractPageMediaAssets( + pages: PageData[], + documentSettings?: Partial, +): MediaAsset[] { + const assets = new Map(); + + for (const page of pages) { + if (page.backgroundType === 'image') { + addImageUrl(assets, page.backgroundImageUrl); + } + + for (const element of page.elements) { + if (element.visible === false) continue; + + if (element.type === 'image' || element.type === 'sticker') { + addImageUrl(assets, element.imageUrl); + } else if (element.type === 'video') { + addVideoUrl(assets, element.videoUrl); + } + } + } + + if (documentSettings?.backgroundType === 'image') { + addImageUrl(assets, documentSettings.backgroundImageUrl); + } + + return Array.from(assets.values()); +} + +function preloadImage(url: string): Promise { + return new Promise((resolve) => { + const img = new Image(); + const finish = () => { + img.onload = null; + img.onerror = null; + resolve(); + }; + img.onload = finish; + img.onerror = finish; + img.src = url; + }); +} + +function preloadVideo(url: string): Promise { + return new Promise((resolve) => { + const video = document.createElement('video'); + video.preload = 'auto'; + video.muted = true; + + const finish = () => { + video.onloadeddata = null; + video.onerror = null; + video.removeAttribute('src'); + video.load(); + resolve(); + }; + + video.onloadeddata = finish; + video.onerror = finish; + video.src = url; + }); +} + +function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((resolve) => { + window.setTimeout(resolve, ms); + }), + ]); +} + +export function preloadMediaAsset(asset: MediaAsset): Promise { + const load = + asset.kind === 'video' ? preloadVideo(asset.url) : preloadImage(asset.url); + return withTimeout(load, MEDIA_LOAD_TIMEOUT_MS); +} + +export async function preloadPagesMedia( + pages: PageData[], + documentSettings?: Partial, + onProgress?: (loaded: number, total: number) => void, +): Promise { + const assets = extractPageMediaAssets(pages, documentSettings); + const total = assets.length; + + if (total === 0) { + onProgress?.(0, 0); + return; + } + + let loaded = 0; + await Promise.all( + assets.map(async (asset) => { + await preloadMediaAsset(asset); + loaded += 1; + onProgress?.(loaded, total); + }), + ); +}