spinner to loading page compeletely
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
hamid zarghami
2026-07-05 16:26:37 +03:30
parent 954ad48486
commit 3c764a1652
6 changed files with 195 additions and 40 deletions
+96 -39
View File
@@ -1,4 +1,4 @@
import { forwardRef, memo } from 'react';
import { forwardRef, memo, useEffect, useRef } from 'react';
import { type PageData } from '../types';
import type { EditorObject } from '@/pages/editor/store/editorStore';
import { toCssLinearGradient, getSvgGradientEndpoints } from '@/pages/editor/utils/gradient';
@@ -17,6 +17,7 @@ import '@/pages/viewer/styles/entranceAnimations.css';
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle';
import type { EntrancePhase } from '@/pages/viewer/hooks/useBookEntranceController';
import { usePageMediaReady } from '@/pages/viewer/hooks/usePageMediaReady';
const getRasterObjectStyle = (
obj: EditorObject,
@@ -67,6 +68,10 @@ type BookPageProps = {
entrancePhase?: EntrancePhase;
/** در پیش‌نمایش کاتالوگ انیمی션 ورود غیرفعال باشد */
disableEntranceAnimations?: boolean;
/** وقتی رسانهٔ صفحه دیرتر از زمان‌بندی انیمیشن لود شد */
onDeferredEntranceReady?: (pageId: number) => void;
/** لایهٔ اسپینر لود رسانه را نشان نده (مثلاً ذره‌بین) */
hideMediaLoadingOverlay?: boolean;
/** ویدیو/صوت فقط به‌صورت کاور — بدون کنترل و بدون کلیک */
staticMedia?: boolean;
/** سایهٔ لبهٔ کاغذ (گرادیان داخل DOM — سازگار با iOS قدیمی) */
@@ -89,8 +94,40 @@ type BookPageProps = {
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
*/
const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => {
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = false, onDeferredEntranceReady, hideMediaLoadingOverlay = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => {
const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color';
const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff';
const effectiveBackgroundGradient = backgroundGradient ?? page.backgroundGradient;
const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? '';
const effectiveBackgroundVideoUrl = backgroundVideoUrl ?? page.backgroundVideoUrl ?? '';
const isMediaReady = usePageMediaReady(
page,
{
backgroundType: effectiveBackgroundType,
backgroundImageUrl: effectiveBackgroundImageUrl,
backgroundVideoUrl: effectiveBackgroundVideoUrl,
},
!hideMediaLoadingOverlay,
);
const deferredEntranceRef = useRef(false);
const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase;
const effectivePhase: EntrancePhase =
hideMediaLoadingOverlay || isMediaReady ? phase : 'idle';
useEffect(() => {
if (disableEntranceAnimations || hideMediaLoadingOverlay) return;
if (phase === 'play' && !isMediaReady) {
deferredEntranceRef.current = true;
}
}, [phase, isMediaReady, disableEntranceAnimations, hideMediaLoadingOverlay]);
useEffect(() => {
if (!isMediaReady || !deferredEntranceRef.current) return;
deferredEntranceRef.current = false;
onDeferredEntranceReady?.(page.id);
}, [isMediaReady, onDeferredEntranceReady, page.id]);
// تابع برای تبدیل opacity به رنگ (مثل editor)
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
// در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است
@@ -134,7 +171,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
rotationDeg?: number;
},
) =>
mergeEntranceAnimationStyle(style, obj, scale, index, phase, {
mergeEntranceAnimationStyle(style, obj, scale, index, effectivePhase, {
...extra,
flyLayoutPx,
});
@@ -982,12 +1019,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
const width = pageWidth ?? 794 * scale;
const height = pageHeight ?? 1123 * scale;
const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color';
const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff';
const effectiveBackgroundGradient = backgroundGradient ?? page.backgroundGradient;
const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? '';
const effectiveBackgroundVideoUrl = backgroundVideoUrl ?? page.backgroundVideoUrl ?? '';
const bgStyle: React.CSSProperties =
effectiveBackgroundType === 'image' && effectiveBackgroundImageUrl
? {
@@ -1002,6 +1033,8 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
}
: { backgroundColor: effectiveBackgroundColor };
const showLoadingOverlay = !hideMediaLoadingOverlay && !isMediaReady;
return (
<div
ref={ref}
@@ -1016,49 +1049,73 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
width: `${width}px`,
height: `${height}px`,
zIndex: 1,
...(useHardPage
...(useHardPage && !showLoadingOverlay
? { backgroundColor: effectiveBackgroundColor, ...bgStyle }
: {}),
}}
>
{!useHardPage && (
<>
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
...bgStyle,
pointerEvents: 'none',
zIndex: 0,
}}
/>
{effectiveBackgroundType === 'video' && effectiveBackgroundVideoUrl && (
<video
<div
style={{
position: 'absolute',
inset: 0,
visibility: showLoadingOverlay ? 'hidden' : 'visible',
}}
>
{!useHardPage && (
<>
<div
aria-hidden
src={effectiveBackgroundVideoUrl}
autoPlay
loop
muted
playsInline
preload="metadata"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
...bgStyle,
pointerEvents: 'none',
zIndex: 0,
}}
/>
)}
</>
)}
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
{effectiveBackgroundType === 'video' && effectiveBackgroundVideoUrl && (
<video
aria-hidden
src={effectiveBackgroundVideoUrl}
autoPlay
loop
muted
playsInline
preload="metadata"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
pointerEvents: 'none',
zIndex: 0,
}}
/>
)}
</>
)}
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
</div>
</div>
{useHardPage && showPaperShadow && <div aria-hidden className="page-paper-shadow" />}
{showLoadingOverlay && (
<div
className="flex items-center justify-center bg-gray-100"
style={{
position: 'absolute',
inset: 0,
zIndex: 2,
}}
role="status"
aria-label="در حال بارگذاری صفحه"
>
<div className="size-8 rounded-full border-2 border-gray-200 border-t-gray-600 animate-spin" />
</div>
)}
{useHardPage && showPaperShadow && !showLoadingOverlay && (
<div aria-hidden className="page-paper-shadow" />
)}
</div>
);
}),
@@ -240,6 +240,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
const {
scheduleEntranceForIndices,
getEntrancePhase,
replayEntranceForPage,
reset: resetEntrance,
} = useBookEntranceController({ pages });
@@ -627,6 +628,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
key={page.id}
page={page}
entrancePhase={getEntrancePhase(page.id)}
onDeferredEntranceReady={replayEntranceForPage}
scale={contentScale}
pageWidth={pagePixelWidth}
pageHeight={pagePixelHeight}
@@ -113,6 +113,7 @@ const Magnifier = memo(
backgroundVideoUrl={background.backgroundVideoUrl}
useHardPage={useHardPage}
disableEntranceAnimations
hideMediaLoadingOverlay
showPaperShadow={false}
idSuffix="-magnifier"
/>
@@ -115,9 +115,31 @@ export function useBookEntranceController({ pages }: Options) {
setPlayingPageIds(new Set());
}, [clearPlayTimer]);
/** وقتی رسانهٔ صفحه دیرتر از زمان‌بندی انیمیشن لود شد، پخش را دوباره شروع می‌کند */
const replayEntranceForPage = useCallback(
(pageId: number) => {
if (!playedPageIdsRef.current.has(pageId)) return;
setPlayingPageIds((prev) => {
if (prev.has(pageId)) return prev;
const next = new Set(prev);
next.add(pageId);
return next;
});
clearPlayTimer(pageId);
playTimersRef.current.set(
pageId,
setTimeout(() => finishPlaying(pageId), MAX_ENTRANCE_PLAY_MS),
);
},
[clearPlayTimer, finishPlaying],
);
return {
scheduleEntranceForIndices,
getEntrancePhase,
replayEntranceForPage,
reset,
};
}
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
import type { PageData } from '../types';
import {
extractPageMediaAssets,
preloadPagesMedia,
} from '../utils/pageMediaUrls';
type PageBackground = Pick<
PageData,
'backgroundType' | 'backgroundImageUrl' | 'backgroundVideoUrl'
>;
function mergePageBackground(
page: PageData,
background?: Partial<PageBackground>,
): PageData {
if (!background) return page;
return {
...page,
backgroundType: background.backgroundType ?? page.backgroundType,
backgroundImageUrl:
background.backgroundImageUrl ?? page.backgroundImageUrl,
backgroundVideoUrl:
background.backgroundVideoUrl ?? page.backgroundVideoUrl,
};
}
export function usePageMediaReady(
page: PageData,
background?: Partial<PageBackground>,
enabled = true,
) {
const [isReady, setIsReady] = useState(false);
const mediaKey = enabled
? extractPageMediaAssets([mergePageBackground(page, background)])
.map((asset) => `${asset.kind}:${asset.url}`)
.sort()
.join('|')
: '';
useEffect(() => {
if (!enabled) {
setIsReady(true);
return;
}
const mergedPage = mergePageBackground(page, background);
const assets = extractPageMediaAssets([mergedPage]);
if (assets.length === 0) {
setIsReady(true);
return;
}
let cancelled = false;
setIsReady(false);
void preloadPagesMedia([mergedPage]).then(() => {
if (!cancelled) setIsReady(true);
});
return () => {
cancelled = true;
};
}, [enabled, page.id, mediaKey, background?.backgroundType, background?.backgroundImageUrl, background?.backgroundVideoUrl]);
return isReady;
}
+5 -1
View File
@@ -41,7 +41,11 @@ export function extractPageMediaAssets(
for (const element of page.elements) {
if (element.visible === false) continue;
if (element.type === 'image' || element.type === 'sticker') {
if (
element.type === 'image' ||
element.type === 'sticker' ||
element.type === 'document'
) {
addImageUrl(assets, element.imageUrl);
} else if (element.type === 'video') {
addVideoUrl(assets, element.videoUrl);