load 5 pages first
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
hamid zarghami
2026-05-30 15:44:56 +03:30
parent f95aeb1810
commit f6147fafa1
9 changed files with 289 additions and 23 deletions
+30 -2
View File
@@ -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;
@@ -107,6 +107,7 @@ const CatalogPreview: FC<Props> = ({ item, page, size, className, selected }) =>
pageWidth={pageWidth}
pageHeight={pageHeight}
disableEntranceAnimations
showPaperShadow={false}
/>
</div>
</div>
+11 -5
View File
@@ -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 (
<div className="w-full h-full flex items-center justify-center">
<div className="text-gray-600">در حال بارگذاری...</div>
</div>
);
return <ViewerLoading />;
}
if (queryError) {
@@ -65,6 +67,10 @@ const Viewer: FC = () => {
);
}
if (!areInitialPagesReady) {
return <ViewerLoading progress={preloadProgress} />;
}
return (
<div className="w-full h-full min-h-0 flex flex-col">
<BookViewer pages={pages} catalogSize={data?.data?.size} documentSettings={documentSettings} />
+13 -3
View File
@@ -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<HTMLDivElement, BookPageProps>(
({ 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,17 +650,21 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
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 && (
<div
aria-hidden
style={{
@@ -667,9 +675,11 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
zIndex: 0,
}}
/>
)}
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
{page.elements.map((element, index) => renderObject(element, index))}
</div>
{useHardPage && showPaperShadow && <div aria-hidden className="page-paper-shadow" />}
</div>
);
});
+15 -3
View File
@@ -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<BookViewerProps> = ({ 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<BookViewerProps> = ({ 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<BookViewerProps> = ({ 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<BookViewerProps> = ({ pages, catalogSize, documentSettings
backgroundColor={page.backgroundColor ?? backgroundColor}
backgroundGradient={page.backgroundGradient ?? backgroundGradient}
backgroundImageUrl={page.backgroundImageUrl ?? backgroundImageUrl}
useHardPage={legacyIOS}
/>
))}
</HTMLFlipBook>
@@ -0,0 +1,21 @@
import { type FC } from 'react';
type ViewerLoadingProps = {
progress?: number;
};
const ViewerLoading: FC<ViewerLoadingProps> = ({ progress }) => (
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<div
className="size-10 rounded-full border-[3px] border-gray-200 border-t-gray-600 animate-spin"
role="status"
aria-label="در حال بارگذاری"
/>
<div className="text-gray-600 text-sm">در حال بارگذاری صفحات...</div>
{typeof progress === 'number' && progress > 0 && progress < 100 && (
<div className="text-xs text-gray-400">{progress}٪</div>
)}
</div>
);
export default ViewerLoading;
@@ -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<DocumentSettings>,
) {
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 };
}
@@ -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;
}
+122
View File
@@ -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<string, MediaAsset>, url?: string) {
if (!url) return;
urls.set(url, { url, kind: 'image' });
}
function addVideoUrl(urls: Map<string, MediaAsset>, url?: string) {
if (!url) return;
urls.set(url, { url, kind: 'video' });
}
export function extractPageMediaAssets(
pages: PageData[],
documentSettings?: Partial<DocumentSettings>,
): MediaAsset[] {
const assets = new Map<string, MediaAsset>();
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<void> {
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<void> {
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<void>, ms: number): Promise<void> {
return Promise.race([
promise,
new Promise<void>((resolve) => {
window.setTimeout(resolve, ms);
}),
]);
}
export function preloadMediaAsset(asset: MediaAsset): Promise<void> {
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<DocumentSettings>,
onProgress?: (loaded: number, total: number) => void,
): Promise<void> {
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);
}),
);
}