From fef8099ab1e56c7e3a4e0b9b56c5e7d1e2db5502 Mon Sep 17 00:00:00 2001 From: Hamid Date: Sat, 16 May 2026 23:22:01 -0700 Subject: [PATCH] fix animation --- src/pages/viewer/components/BookPage.tsx | 26 ++-- src/pages/viewer/components/BookViewer.tsx | 36 ++++- .../viewer/hooks/useBookEntranceController.ts | 125 ++++++++++++++++++ .../hooks/useBookPageEntranceSequence.ts | 56 -------- .../viewer/utils/entranceAnimationStyle.ts | 8 +- src/pages/viewer/utils/visiblePageIndices.ts | 28 ++++ 6 files changed, 195 insertions(+), 84 deletions(-) create mode 100644 src/pages/viewer/hooks/useBookEntranceController.ts delete mode 100644 src/pages/viewer/hooks/useBookPageEntranceSequence.ts create mode 100644 src/pages/viewer/utils/visiblePageIndices.ts diff --git a/src/pages/viewer/components/BookPage.tsx b/src/pages/viewer/components/BookPage.tsx index bd90429..5490a33 100644 --- a/src/pages/viewer/components/BookPage.tsx +++ b/src/pages/viewer/components/BookPage.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useCallback } from 'react'; +import { forwardRef } from 'react'; import { type PageData } from '../types'; import type { EditorObject } from '@/pages/editor/store/editorStore'; import { toCssLinearGradient } from '@/pages/editor/utils/gradient'; @@ -12,7 +12,7 @@ import { } from '@/pages/editor/utils/textStyle'; import '@/pages/viewer/styles/entranceAnimations.css'; import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle'; -import { useBookPageEntranceSequence } from '@/pages/viewer/hooks/useBookPageEntranceSequence'; +import type { EntrancePhase } from '@/pages/viewer/hooks/useBookEntranceController'; type BookPageProps = { page: PageData; @@ -28,6 +28,8 @@ type BookPageProps = { angle: number; }; backgroundImageUrl?: string; + /** فاز انیمیشن ورود — از BookViewer کنترل می‌شود */ + entrancePhase?: EntrancePhase; /** در پیش‌نمایش کاتالوگ انیمیشن ورود غیرفعال باشد */ disableEntranceAnimations?: boolean; }; @@ -40,20 +42,8 @@ type BookPageProps = { * نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند */ const BookPage = forwardRef( - ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, disableEntranceAnimations = false }, ref) => { - const { attachRootRef, entranceSeq } = useBookPageEntranceSequence(disableEntranceAnimations); - - const setHostRef = useCallback( - (node: HTMLDivElement | null) => { - attachRootRef(node); - if (typeof ref === 'function') { - ref(node); - } else if (ref) { - (ref as React.MutableRefObject).current = node; - } - }, - [ref, attachRootRef], - ); + ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, entrancePhase = 'idle', disableEntranceAnimations = false }, ref) => { + const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase; // تابع برای تبدیل opacity به رنگ (مثل editor) // در editor، getColorWithOpacity انتظار opacity 0-100 دارد // در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است @@ -97,7 +87,7 @@ const BookPage = forwardRef( rotationDeg?: number; }, ) => - mergeEntranceAnimationStyle(style, obj, scale, index, entranceSeq, { + mergeEntranceAnimationStyle(style, obj, scale, index, phase, { ...extra, flyLayoutPx, }); @@ -664,7 +654,7 @@ const BookPage = forwardRef( return (
= ({ pages, catalogSize, documentSettings ? 'desktop' : `mobile-${pagePixelWidth}-${pagePixelHeight}`; + const usePortrait = !isDesktop || displayStyle === 'single'; + + const { + scheduleEntranceForSpread, + getEntrancePhase, + reset: resetEntrance, + } = useBookEntranceController({ + pages, + portrait: usePortrait, + showCover: hasMultiplePages, + }); + + const commitEntranceAtCurrentSpread = useCallback(() => { + const api = bookRef.current?.pageFlip(); + const idx = api?.getCurrentPageIndex?.(); + if (typeof idx !== 'number' || pages.length <= 0) return; + const clamped = Math.max(0, Math.min(idx, pages.length - 1)); + scheduleEntranceForSpread(clamped); + }, [pages.length, scheduleEntranceForSpread]); + + useEffect(() => { + resetEntrance(); + }, [flipKey, pages.length, pages[0]?.id, resetEntrance]); + // اطمینان از اینکه currentPage همیشه در محدوده معتبر است useEffect(() => { if (currentPage < 0) { @@ -282,16 +307,17 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings setCurrentPage(clamped); lastFlipPageRef.current = clamped; }, - [pages.length] + [pages.length], ); - /** بعد از اتمام انیمیشن، ایندکس واقعی کتاب را می‌گیریم (RTL روی روت کتاب مختصات page-flip را به‌هم می‌زند) */ + /** بعد از اتمام انیمیشن ورق، ایندکس واقعی کتاب را می‌گیریم و انیمیشن ورود را یک‌بار شروع می‌کنیم */ const handleChangeState = useCallback( (e: PageFlipStateEvent) => { if (e.data !== 'read') return; syncPageIndexFromBook(); + commitEntranceAtCurrentSpread(); }, - [syncPageIndexFromBook] + [syncPageIndexFromBook, commitEntranceAtCurrentSpread], ); const goToNextPage = useCallback(() => { @@ -393,9 +419,6 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings } }, [currentPage, pages.length, goToNextPage, goToPrevPage]); - // تک‌صفحه‌ای: موبایل یا تنظیم single، دوصفحه‌ای: دسکتاپ + تنظیم double - const usePortrait = !isDesktop || displayStyle === 'single'; - const prevFlipKeyRef = useRef(flipKey); useEffect(() => { if (prevFlipKeyRef.current !== flipKey) { @@ -481,6 +504,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings ()); + const [playingPageIds, setPlayingPageIds] = useState>(() => new Set()); + const playTimersRef = useRef>>(new Map()); + + const clearPlayTimer = useCallback((pageId: number) => { + const t = playTimersRef.current.get(pageId); + if (t) { + clearTimeout(t); + playTimersRef.current.delete(pageId); + } + }, []); + + const finishPlaying = useCallback((pageId: number) => { + clearPlayTimer(pageId); + setPlayingPageIds((prev) => { + if (!prev.has(pageId)) return prev; + const next = new Set(prev); + next.delete(pageId); + return next; + }); + }, [clearPlayTimer]); + + const scheduleEntranceForSpread = useCallback( + (currentIndex: number) => { + const indices = getVisiblePageIndices(currentIndex, pages.length, { + portrait, + showCover, + }); + + const visibleIds = new Set( + indices.map((i) => pages[i]?.id).filter((id): id is number => id != null), + ); + + const newlyPlaying: number[] = []; + + for (const i of indices) { + const page = pages[i]; + if (!page) continue; + if (playedPageIdsRef.current.has(page.id)) continue; + + playedPageIdsRef.current.add(page.id); + newlyPlaying.push(page.id); + } + + setPlayingPageIds((prev) => { + const next = new Set(); + for (const id of prev) { + if (visibleIds.has(id)) next.add(id); + } + for (const id of newlyPlaying) next.add(id); + if ( + next.size === prev.size && + newlyPlaying.every((id) => prev.has(id)) + ) { + return prev; + } + return next; + }); + + if (newlyPlaying.length === 0) return; + + for (const pageId of newlyPlaying) { + clearPlayTimer(pageId); + playTimersRef.current.set( + pageId, + setTimeout(() => finishPlaying(pageId), MAX_ENTRANCE_PLAY_MS), + ); + } + }, + [pages, portrait, showCover, clearPlayTimer, finishPlaying], + ); + + const getEntrancePhase = useCallback( + (pageId: number): EntrancePhase => { + if (playingPageIds.has(pageId)) return "play"; + if (playedPageIdsRef.current.has(pageId)) return "settled"; + return "idle"; + }, + [playingPageIds], + ); + + const reset = useCallback(() => { + for (const id of playTimersRef.current.keys()) { + clearPlayTimer(id); + } + playedPageIdsRef.current.clear(); + setPlayingPageIds(new Set()); + }, [clearPlayTimer]); + + return { + scheduleEntranceForSpread, + getEntrancePhase, + reset, + }; +} diff --git a/src/pages/viewer/hooks/useBookPageEntranceSequence.ts b/src/pages/viewer/hooks/useBookPageEntranceSequence.ts deleted file mode 100644 index b1f134f..0000000 --- a/src/pages/viewer/hooks/useBookPageEntranceSequence.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; - -/** - * وقتی صفحهٔ کتاب به viewport می‌رسد، یک شمارنده زیاد می‌شود تا انیمیشن ورود دوباره اجرا شود - * (مشابه ورود مجدد به اسلاید در PowerPoint). - */ -export function useBookPageEntranceSequence(disabled: boolean) { - const wasVisibleRef = useRef(false); - const [entranceSeq, setEntranceSeq] = useState(0); - const observerRef = useRef(null); - - const attachRootRef = useCallback( - (node: HTMLDivElement | null) => { - observerRef.current?.disconnect(); - observerRef.current = null; - if (disabled || !node) { - wasVisibleRef.current = false; - return; - } - - const io = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (!entry) return; - const visible = entry.isIntersecting && entry.intersectionRatio >= 0.06; - const mostlyHidden = !entry.isIntersecting || entry.intersectionRatio < 0.02; - - if (visible && !wasVisibleRef.current) { - wasVisibleRef.current = true; - setEntranceSeq((s) => s + 1); - } else if (mostlyHidden) { - wasVisibleRef.current = false; - } - }, - { threshold: [0, 0.02, 0.06, 0.12, 0.25] }, - ); - - io.observe(node); - observerRef.current = io; - }, - [disabled], - ); - - useEffect( - () => () => { - observerRef.current?.disconnect(); - observerRef.current = null; - }, - [], - ); - - return { - attachRootRef, - entranceSeq: disabled ? 0 : entranceSeq, - }; -} diff --git a/src/pages/viewer/utils/entranceAnimationStyle.ts b/src/pages/viewer/utils/entranceAnimationStyle.ts index 265b447..f16ed79 100644 --- a/src/pages/viewer/utils/entranceAnimationStyle.ts +++ b/src/pages/viewer/utils/entranceAnimationStyle.ts @@ -1,5 +1,6 @@ import type { CSSProperties } from "react"; import type { EditorObject } from "@/pages/editor/store/editorStore"; +import type { EntrancePhase } from "@/pages/viewer/hooks/useBookEntranceController"; import { DEFAULT_ENTRANCE_DURATION_MS, DEFAULT_ENTRANCE_DELAY_MS, @@ -48,7 +49,7 @@ export function mergeEntranceAnimationStyle( obj: EditorObject, scale: number, elementIndex: number, - entranceSeq: number, + phase: EntrancePhase, options?: { transformOrigin?: CSSProperties["transformOrigin"]; rotationDeg?: number; @@ -57,12 +58,11 @@ export function mergeEntranceAnimationStyle( }, ): CSSProperties { const type = obj.entranceAnimation; - if (!type || type === "none") { + if (!type || type === "none" || phase === "settled") { return base; } - /* تا observer هنوز seq را زیاد نکرده، یک فریم با «ظاهر نهایی» رندر نشود — باعث چشمک می‌شد */ - if (entranceSeq === 0) { + if (phase === "idle") { const { opacity: _o, ...rest } = base; return { ...rest, diff --git a/src/pages/viewer/utils/visiblePageIndices.ts b/src/pages/viewer/utils/visiblePageIndices.ts new file mode 100644 index 0000000..9a3322a --- /dev/null +++ b/src/pages/viewer/utils/visiblePageIndices.ts @@ -0,0 +1,28 @@ +/** ایندکس صفحاتی که در اسپرد فعلی کتاب دیده می‌شوند */ +export function getVisiblePageIndices( + currentIndex: number, + pageCount: number, + mode: { portrait: boolean; showCover: boolean }, +): number[] { + if (pageCount <= 0) return []; + + const idx = Math.max(0, Math.min(currentIndex, pageCount - 1)); + + if (mode.portrait) { + return [idx]; + } + + if (mode.showCover) { + if (idx === 0) return [0]; + if (idx === pageCount - 1) return [pageCount - 1]; + const right = idx + 1; + return right < pageCount ? [idx, right] : [idx]; + } + + if (idx % 2 === 0) { + const right = idx + 1; + return right < pageCount ? [idx, right] : [idx]; + } + + return [idx - 1, idx]; +}