This commit is contained in:
+30
-2
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user