This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { forwardRef, memo } from 'react';
|
import { forwardRef, memo, useEffect, useRef } from 'react';
|
||||||
import { type PageData } from '../types';
|
import { type PageData } from '../types';
|
||||||
import type { EditorObject } from '@/pages/editor/store/editorStore';
|
import type { EditorObject } from '@/pages/editor/store/editorStore';
|
||||||
import { toCssLinearGradient, getSvgGradientEndpoints } from '@/pages/editor/utils/gradient';
|
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 { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
|
||||||
import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle';
|
import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle';
|
||||||
import type { EntrancePhase } from '@/pages/viewer/hooks/useBookEntranceController';
|
import type { EntrancePhase } from '@/pages/viewer/hooks/useBookEntranceController';
|
||||||
|
import { usePageMediaReady } from '@/pages/viewer/hooks/usePageMediaReady';
|
||||||
|
|
||||||
const getRasterObjectStyle = (
|
const getRasterObjectStyle = (
|
||||||
obj: EditorObject,
|
obj: EditorObject,
|
||||||
@@ -67,6 +68,10 @@ type BookPageProps = {
|
|||||||
entrancePhase?: EntrancePhase;
|
entrancePhase?: EntrancePhase;
|
||||||
/** در پیشنمایش کاتالوگ انیمی션 ورود غیرفعال باشد */
|
/** در پیشنمایش کاتالوگ انیمی션 ورود غیرفعال باشد */
|
||||||
disableEntranceAnimations?: boolean;
|
disableEntranceAnimations?: boolean;
|
||||||
|
/** وقتی رسانهٔ صفحه دیرتر از زمانبندی انیمیشن لود شد */
|
||||||
|
onDeferredEntranceReady?: (pageId: number) => void;
|
||||||
|
/** لایهٔ اسپینر لود رسانه را نشان نده (مثلاً ذرهبین) */
|
||||||
|
hideMediaLoadingOverlay?: boolean;
|
||||||
/** ویدیو/صوت فقط بهصورت کاور — بدون کنترل و بدون کلیک */
|
/** ویدیو/صوت فقط بهصورت کاور — بدون کنترل و بدون کلیک */
|
||||||
staticMedia?: boolean;
|
staticMedia?: boolean;
|
||||||
/** سایهٔ لبهٔ کاغذ (گرادیان داخل DOM — سازگار با iOS قدیمی) */
|
/** سایهٔ لبهٔ کاغذ (گرادیان داخل DOM — سازگار با iOS قدیمی) */
|
||||||
@@ -89,8 +94,40 @@ type BookPageProps = {
|
|||||||
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
|
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
|
||||||
*/
|
*/
|
||||||
const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
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 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)
|
// تابع برای تبدیل opacity به رنگ (مثل editor)
|
||||||
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
|
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
|
||||||
// در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است
|
// در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است
|
||||||
@@ -134,7 +171,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
rotationDeg?: number;
|
rotationDeg?: number;
|
||||||
},
|
},
|
||||||
) =>
|
) =>
|
||||||
mergeEntranceAnimationStyle(style, obj, scale, index, phase, {
|
mergeEntranceAnimationStyle(style, obj, scale, index, effectivePhase, {
|
||||||
...extra,
|
...extra,
|
||||||
flyLayoutPx,
|
flyLayoutPx,
|
||||||
});
|
});
|
||||||
@@ -982,12 +1019,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
const width = pageWidth ?? 794 * scale;
|
const width = pageWidth ?? 794 * scale;
|
||||||
const height = pageHeight ?? 1123 * 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 =
|
const bgStyle: React.CSSProperties =
|
||||||
effectiveBackgroundType === 'image' && effectiveBackgroundImageUrl
|
effectiveBackgroundType === 'image' && effectiveBackgroundImageUrl
|
||||||
? {
|
? {
|
||||||
@@ -1002,6 +1033,8 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
}
|
}
|
||||||
: { backgroundColor: effectiveBackgroundColor };
|
: { backgroundColor: effectiveBackgroundColor };
|
||||||
|
|
||||||
|
const showLoadingOverlay = !hideMediaLoadingOverlay && !isMediaReady;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -1016,10 +1049,17 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
height: `${height}px`,
|
height: `${height}px`,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
...(useHardPage
|
...(useHardPage && !showLoadingOverlay
|
||||||
? { backgroundColor: effectiveBackgroundColor, ...bgStyle }
|
? { backgroundColor: effectiveBackgroundColor, ...bgStyle }
|
||||||
: {}),
|
: {}),
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
visibility: showLoadingOverlay ? 'hidden' : 'visible',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{!useHardPage && (
|
{!useHardPage && (
|
||||||
<>
|
<>
|
||||||
@@ -1058,7 +1098,24 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
||||||
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
|
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
|
||||||
</div>
|
</div>
|
||||||
{useHardPage && showPaperShadow && <div aria-hidden className="page-paper-shadow" />}
|
</div>
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
const {
|
const {
|
||||||
scheduleEntranceForIndices,
|
scheduleEntranceForIndices,
|
||||||
getEntrancePhase,
|
getEntrancePhase,
|
||||||
|
replayEntranceForPage,
|
||||||
reset: resetEntrance,
|
reset: resetEntrance,
|
||||||
} = useBookEntranceController({ pages });
|
} = useBookEntranceController({ pages });
|
||||||
|
|
||||||
@@ -627,6 +628,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
key={page.id}
|
key={page.id}
|
||||||
page={page}
|
page={page}
|
||||||
entrancePhase={getEntrancePhase(page.id)}
|
entrancePhase={getEntrancePhase(page.id)}
|
||||||
|
onDeferredEntranceReady={replayEntranceForPage}
|
||||||
scale={contentScale}
|
scale={contentScale}
|
||||||
pageWidth={pagePixelWidth}
|
pageWidth={pagePixelWidth}
|
||||||
pageHeight={pagePixelHeight}
|
pageHeight={pagePixelHeight}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ const Magnifier = memo(
|
|||||||
backgroundVideoUrl={background.backgroundVideoUrl}
|
backgroundVideoUrl={background.backgroundVideoUrl}
|
||||||
useHardPage={useHardPage}
|
useHardPage={useHardPage}
|
||||||
disableEntranceAnimations
|
disableEntranceAnimations
|
||||||
|
hideMediaLoadingOverlay
|
||||||
showPaperShadow={false}
|
showPaperShadow={false}
|
||||||
idSuffix="-magnifier"
|
idSuffix="-magnifier"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -115,9 +115,31 @@ export function useBookEntranceController({ pages }: Options) {
|
|||||||
setPlayingPageIds(new Set());
|
setPlayingPageIds(new Set());
|
||||||
}, [clearPlayTimer]);
|
}, [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 {
|
return {
|
||||||
scheduleEntranceForIndices,
|
scheduleEntranceForIndices,
|
||||||
getEntrancePhase,
|
getEntrancePhase,
|
||||||
|
replayEntranceForPage,
|
||||||
reset,
|
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;
|
||||||
|
}
|
||||||
@@ -41,7 +41,11 @@ export function extractPageMediaAssets(
|
|||||||
for (const element of page.elements) {
|
for (const element of page.elements) {
|
||||||
if (element.visible === false) continue;
|
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);
|
addImageUrl(assets, element.imageUrl);
|
||||||
} else if (element.type === 'video') {
|
} else if (element.type === 'video') {
|
||||||
addVideoUrl(assets, element.videoUrl);
|
addVideoUrl(assets, element.videoUrl);
|
||||||
|
|||||||
Reference in New Issue
Block a user