diff --git a/src/index.css b/src/index.css index a1cc7e1..1c69216 100644 --- a/src/index.css +++ b/src/index.css @@ -276,3 +276,13 @@ html { .flipbook-container.flipbook-magnifier-active .stf__item { pointer-events: auto; } + +/* جلوگیری از اسکرول/ورق‌خوردن پیش‌فرض مرورگر هنگام لمس با ذره‌بین فعال */ +.flipbook-container.flipbook-magnifier-active { + touch-action: none; +} + +/* در حین انیمیشن ورق، hit-test ذره‌بین روی صفحات در حال حرکت انجام نشود */ +.flipbook-flipping .flipbook-container.flipbook-magnifier-active .stf__item { + pointer-events: none; +} diff --git a/src/pages/viewer/components/BookViewer.tsx b/src/pages/viewer/components/BookViewer.tsx index b28356e..ed82d4a 100644 --- a/src/pages/viewer/components/BookViewer.tsx +++ b/src/pages/viewer/components/BookViewer.tsx @@ -139,6 +139,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings isBookFlippingRef.current = flipping; flipbookWrapperRef.current?.classList.toggle("relative", flipping); flipbookWrapperRef.current?.classList.toggle("z-10", flipping); + flipbookWrapperRef.current?.classList.toggle("flipbook-flipping", flipping); spineOverlayRef.current?.classList.toggle("z-50", !flipping); spineOverlayRef.current?.classList.toggle("z-0", flipping); }, []); @@ -231,6 +232,8 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings const pagePixelWidth = viewportFit.w; const pagePixelHeight = viewportFit.h; const contentScale = viewportFit.contentScale; + const flipDisabledOnMobile = !isDesktop && magnifierEnabled; + const magnifierLockedPageId = usePortrait ? (pages[currentPage]?.id ?? null) : null; const flipKey = `${usePortrait ? "portrait" : "spread"}-${pagePixelWidth}-${pagePixelHeight}-${flipIndexCount}`; @@ -399,6 +402,12 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings [flipIndexCount, scheduleEntranceForIndices, getVisibleLogicalIndices, toLogicalIndex], ); + const beginPageNavigation = useCallback(() => { + if (flipDisabledOnMobile) return false; + setBookFlippingVisual(true); + return true; + }, [flipDisabledOnMobile, setBookFlippingVisual]); + /** بعد از اتمام انیمیشن ورق، ایندکس واقعی کتاب را می‌گیریم و انیمیشن ورود را یک‌بار شروع می‌کنیم */ const handleChangeState = useCallback( (e: PageFlipStateEvent) => { @@ -415,23 +424,25 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings const goToNextPage = useCallback(() => { if (currentPage >= pages.length - 1) return; + if (!beginPageNavigation()) return; const api = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined; if (isRtlBook) { triggerFlipPrev(api); } else { api?.flipNext(); } - }, [currentPage, pages.length, isRtlBook]); + }, [currentPage, pages.length, isRtlBook, beginPageNavigation]); const goToPrevPage = useCallback(() => { if (currentPage <= 0) return; + if (!beginPageNavigation()) return; const api = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined; if (isRtlBook) { api?.flipNext(); } else { triggerFlipPrev(api); } - }, [currentPage, isRtlBook]); + }, [currentPage, isRtlBook, beginPageNavigation]); const stopAutoPlayByUser = useCallback(() => { if (isAutoPlayActive) { @@ -459,10 +470,17 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings return () => window.removeEventListener("keydown", handleKeyDown); }, [magnifierEnabled]); + useEffect(() => { + if (flipDisabledOnMobile && isAutoPlayActive) { + setIsAutoPlayActive(false); + } + }, [flipDisabledOnMobile, isAutoPlayActive]); + const navigateToPageIndex = useCallback( (targetIndex: number) => { const pageFlipAPI = bookRef.current?.pageFlip(); if (!pageFlipAPI) return; + if (!beginPageNavigation()) return; const logicalIdx = Math.max(0, Math.min(targetIndex, pages.length - 1)); const flipIdx = toFlipIndex(logicalIdx); @@ -470,7 +488,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings lastFlipPageRef.current = logicalIdx; scheduleEntranceForIndices(getVisibleLogicalIndices(flipIdx)); }, - [pages.length, scheduleEntranceForIndices, getVisibleLogicalIndices, toFlipIndex], + [pages.length, scheduleEntranceForIndices, getVisibleLogicalIndices, toFlipIndex, beginPageNavigation], ); const handleLinkClick = useCallback( @@ -479,6 +497,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings stopAutoPlayByUser(); if (!linkUrl.startsWith("page://")) return; + if (flipDisabledOnMobile) return; const action = linkUrl.replace("page://", ""); @@ -497,7 +516,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings } } }, - [pages.length, goToNextPage, goToPrevPage, navigateToPageIndex, stopAutoPlayByUser], + [pages.length, goToNextPage, goToPrevPage, navigateToPageIndex, stopAutoPlayByUser, flipDisabledOnMobile], ); const prevFlipKeyRef = useRef(flipKey); @@ -518,7 +537,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings // پخش خودکار useEffect(() => { - if (!isAutoPlayActive || pages.length <= 1) return; + if (!isAutoPlayActive || pages.length <= 1 || flipDisabledOnMobile) return; const timer = setInterval(() => { if (currentPage >= pages.length - 1) { bookRef.current?.pageFlip()?.turnToPage?.(toFlipIndex(0)); @@ -534,7 +553,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings } }, AUTO_PLAY_INTERVAL_MS); return () => clearInterval(timer); - }, [isAutoPlayActive, currentPage, pages.length, isRtlBook, toFlipIndex]); + }, [isAutoPlayActive, currentPage, pages.length, isRtlBook, toFlipIndex, flipDisabledOnMobile]); return (
@@ -560,6 +579,7 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings className="max-w-full flex justify-center shrink-0 rounded-sm" style={{ cursor: magnifierEnabled ? "zoom-in" : undefined, + touchAction: magnifierEnabled ? "none" : undefined, overflow: "hidden", width: usePortrait ? pagePixelWidth : pagePixelWidth * 2, height: pagePixelHeight, @@ -583,12 +603,12 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings autoSize={false} maxShadowOpacity={legacyIOS ? 0 : isDesktop ? 0.55 : 0.32} showCover={hasMultiplePages} - mobileScrollSupport={!usePortrait} + mobileScrollSupport={!usePortrait && !magnifierEnabled} clickEventForward={true} useMouseEvents={hasMultiplePages && !magnifierEnabled} - swipeDistance={hasMultiplePages ? (usePortrait ? 48 : 30) : 9999} + swipeDistance={flipDisabledOnMobile || !hasMultiplePages ? 9999 : usePortrait ? 48 : 30} startZIndex={1} - showPageCorners={hasMultiplePages} + showPageCorners={hasMultiplePages && !flipDisabledOnMobile} disableFlipByClick={true} className={clx( "flipbook-container mx-auto max-w-full", @@ -626,6 +646,9 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings enabled={magnifierEnabled} containerRef={bookAreaRef} interactionRef={flipbookWrapperRef} + isFlippingRef={isBookFlippingRef} + lockedPageId={magnifierLockedPageId} + blockFlipTouch={flipDisabledOnMobile} pages={displayPages} pageWidth={pagePixelWidth} pageHeight={pagePixelHeight} @@ -651,9 +674,13 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings goToPrevPage(); } }} - disabled={leftToRightFlip ? currentPage >= pages.length - 1 : currentPage <= 0} + disabled={ + flipDisabledOnMobile || + (leftToRightFlip ? currentPage >= pages.length - 1 : currentPage <= 0) + } className="p-2 md:p-2 min-h-11 min-w-11 md:min-h-0 md:min-w-0 inline-flex items-center justify-center rounded-full hover:bg-gray-100 active:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition-all" aria-label={leftToRightFlip ? "صفحه بعد" : "صفحه قبل"} + title={flipDisabledOnMobile ? "برای ورق زدن ابتدا ذره‌بین را خاموش کنید" : undefined} > @@ -680,9 +707,13 @@ const BookViewer: FC = ({ pages, catalogSize, documentSettings goToNextPage(); } }} - disabled={leftToRightFlip ? currentPage <= 0 : currentPage >= pages.length - 1} + disabled={ + flipDisabledOnMobile || + (leftToRightFlip ? currentPage <= 0 : currentPage >= pages.length - 1) + } className="p-2 md:p-2 min-h-11 min-w-11 md:min-h-0 md:min-w-0 inline-flex items-center justify-center rounded-full hover:bg-gray-100 active:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition-all" aria-label={leftToRightFlip ? "صفحه قبل" : "صفحه بعد"} + title={flipDisabledOnMobile ? "برای ورق زدن ابتدا ذره‌بین را خاموش کنید" : undefined} > diff --git a/src/pages/viewer/components/Magnifier/Magnifier.tsx b/src/pages/viewer/components/Magnifier/Magnifier.tsx index 47bae99..08f6c0c 100644 --- a/src/pages/viewer/components/Magnifier/Magnifier.tsx +++ b/src/pages/viewer/components/Magnifier/Magnifier.tsx @@ -21,6 +21,11 @@ export type MagnifierProps = { contentScale: number; pageDefaults: PageBackgroundDefaults; useHardPage?: boolean; + /** در حین انیمیشن ورق‌خوردن true است */ + isFlippingRef: RefObject; + /** در حالت تک‌صفحه (موبایل) همیشه صفحهٔ فعلی بزرگ‌نمایی می‌شود */ + lockedPageId?: number | null; + blockFlipTouch?: boolean; /** با تغییر آن (ورق خوردن) ذره‌بین ریست می‌شود */ pageIndex?: number; }; @@ -36,6 +41,9 @@ const Magnifier = memo( contentScale, pageDefaults, useHardPage = false, + isFlippingRef, + lockedPageId = null, + blockFlipTouch = false, pageIndex, }: MagnifierProps) => { const overlayRef = useRef(null); @@ -54,15 +62,23 @@ const Magnifier = memo( overlayRef, lensRef, mirrorRef, + isFlippingRef, + lockedPageId, + blockFlipTouch, pageIndex, onActivePageChange: handleActivePageChange, }); - const activePage = useMemo( - () => (activePageId != null ? pages.find((p) => p.id === activePageId) : undefined), - [pages, activePageId], + const lockedPage = useMemo( + () => (lockedPageId != null ? pages.find((p) => p.id === lockedPageId) : undefined), + [lockedPageId, pages], ); + const activePage = useMemo(() => { + if (lockedPage) return lockedPage; + return activePageId != null ? pages.find((p) => p.id === activePageId) : undefined; + }, [lockedPage, pages, activePageId]); + const background = useMemo( () => (activePage ? resolvePageBackground(activePage, pageDefaults) : null), [activePage, pageDefaults], diff --git a/src/pages/viewer/components/Magnifier/magnifierUtils.ts b/src/pages/viewer/components/Magnifier/magnifierUtils.ts index 3295949..21eac83 100644 --- a/src/pages/viewer/components/Magnifier/magnifierUtils.ts +++ b/src/pages/viewer/components/Magnifier/magnifierUtils.ts @@ -1,3 +1,12 @@ +/** پیدا کردن المان `.page` با شناسهٔ مشخص (حالت تک‌صفحه‌ای موبایل) */ +export function findPageById(root: HTMLElement, pageId: number): HTMLElement | null { + const page = root.querySelector(`.page[data-page-id="${pageId}"]`); + if (!(page instanceof HTMLElement) || !root.contains(page) || !isPageVisible(page)) { + return null; + } + return page; +} + /** پیدا کردن المان `.page` زیر مختصات مشخص، با اولویت بالاترین z-index در حالت اسپرد */ export function findPageAtPoint( root: HTMLElement, diff --git a/src/pages/viewer/components/Magnifier/useMagnifier.ts b/src/pages/viewer/components/Magnifier/useMagnifier.ts index c867561..09165db 100644 --- a/src/pages/viewer/components/Magnifier/useMagnifier.ts +++ b/src/pages/viewer/components/Magnifier/useMagnifier.ts @@ -1,10 +1,10 @@ import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { MAGNIFIER_SIZE, ZOOM_SCALE } from "./constants"; -import { computeMagnifierGeometry, findPageAtPoint } from "./magnifierUtils"; +import { computeMagnifierGeometry, findPageAtPoint, findPageById } from "./magnifierUtils"; type UseMagnifierOptions = { enabled: boolean; - /** ناحیهٔ موقعیت‌دهی لنز (مرجع مختصات نسبی) */ + /** مرجع مختصات برای موقعیت‌دهی لنز (معمولاً ناحیهٔ اطراف کتاب) */ containerRef: RefObject; /** ناحیهٔ تعامل و جستجوی صفحات (wrapper کتاب) */ interactionRef: RefObject; @@ -12,6 +12,12 @@ type UseMagnifierOptions = { lensRef: RefObject; /** لایهٔ داخل لنز که محتوای صفحه (BookPage با مقیاس بزرگ) داخلش رندر می‌شود */ mirrorRef: RefObject; + /** در حین انیمیشن ورق‌خوردن true است — hit-test و به‌روزرسانی لنز متوقف می‌شود */ + isFlippingRef: RefObject; + /** در حالت تک‌صفحه (موبایل) همیشه همین صفحه بزرگ‌نمایی می‌شود، نه نتیجهٔ hit-test */ + lockedPageId?: number | null; + /** جلوگیری از رسیدن touch به page-flip (کتابخانه تنظیمات را بعد از mount به‌روز نمی‌کند) */ + blockFlipTouch?: boolean; /** با تغییر آن (ورق خوردن) وضعیت فعلی ریست می‌شود */ pageIndex?: number; /** وقتی صفحهٔ زیر ماوس عوض شود صدا زده می‌شود تا محتوای مناسب رندر شود */ @@ -25,12 +31,19 @@ export function useMagnifier({ overlayRef, lensRef, mirrorRef, + isFlippingRef, + lockedPageId = null, + blockFlipTouch = false, pageIndex, onActivePageChange, }: UseMagnifierOptions) { const rafIdRef = useRef(null); const pendingPointRef = useRef<{ x: number; y: number } | null>(null); const lastPageIdRef = useRef(null); + const touchActiveRef = useRef(false); + const lockedPageIdRef = useRef(lockedPageId); + + lockedPageIdRef.current = lockedPageId; const hideLens = useCallback(() => { const lens = lensRef.current; @@ -46,37 +59,27 @@ export function useMagnifier({ } }, [onActivePageChange]); - // با تغییر صفحه (ورق خوردن) باید دوباره از صفر hit-test انجام شود - useEffect(() => { - if (!enabled) return; - pendingPointRef.current = null; - hideLens(); - resetActivePage(); - }, [pageIndex, enabled, hideLens, resetActivePage]); + const applyGeometryRef = useRef<(clientX: number, clientY: number) => void>(() => {}); useLayoutEffect(() => { - if (!enabled) { - pendingPointRef.current = null; - if (rafIdRef.current != null) { - cancelAnimationFrame(rafIdRef.current); - rafIdRef.current = null; + applyGeometryRef.current = (clientX: number, clientY: number) => { + if (isFlippingRef.current) { + hideLens(); + resetActivePage(); + return; } - hideLens(); - resetActivePage(); - return; - } - const container = containerRef.current; - const interaction = interactionRef.current; - if (!container || !interaction) return; - - const half = MAGNIFIER_SIZE / 2; - - const applyGeometry = (clientX: number, clientY: number) => { + const container = containerRef.current; + const interaction = interactionRef.current; const lens = lensRef.current; - if (!lens) return; + if (!container || !interaction || !lens) return; + + const lockedId = lockedPageIdRef.current; + const page = + lockedId != null + ? findPageById(interaction, lockedId) + : findPageAtPoint(interaction, clientX, clientY, overlayRef.current); - const page = findPageAtPoint(interaction, clientX, clientY, overlayRef.current); if (!page) { hideLens(); resetActivePage(); @@ -96,6 +99,7 @@ export function useMagnifier({ onActivePageChange(pageId); } + const half = MAGNIFIER_SIZE / 2; const { pageOffsetX, pageOffsetY, localX, localY, clampedX, clampedY } = geometry; lens.style.visibility = "visible"; @@ -103,66 +107,146 @@ export function useMagnifier({ const mirror = mirrorRef.current; if (mirror) { - const tx = half - localX * ZOOM_SCALE; - const ty = half - localY * ZOOM_SCALE; - mirror.style.transform = `translate3d(${tx}px, ${ty}px, 0)`; + mirror.style.transform = `translate3d(${half - localX * ZOOM_SCALE}px, ${half - localY * ZOOM_SCALE}px, 0)`; } }; + }); - const flushPending = () => { - rafIdRef.current = null; - const point = pendingPointRef.current; - if (!point) return; - applyGeometry(point.x, point.y); - }; + // با ورق خوردن، وضعیت ذره‌بین کاملاً ریست می‌شود + useEffect(() => { + if (!enabled) return; + pendingPointRef.current = null; + touchActiveRef.current = false; + hideLens(); + resetActivePage(); + lastPageIdRef.current = null; + }, [pageIndex, enabled, hideLens, resetActivePage]); - const scheduleUpdate = (clientX: number, clientY: number) => { - pendingPointRef.current = { x: clientX, y: clientY }; - if (rafIdRef.current == null) { - rafIdRef.current = requestAnimationFrame(flushPending); - } - }; - - const handleMouseMove = (event: MouseEvent) => { - scheduleUpdate(event.clientX, event.clientY); - }; - - const handleMouseLeave = () => { + useLayoutEffect(() => { + if (!enabled) { pendingPointRef.current = null; - hideLens(); - resetActivePage(); - }; - - interaction.addEventListener("mousemove", handleMouseMove); - interaction.addEventListener("mouseleave", handleMouseLeave); - - return () => { - interaction.removeEventListener("mousemove", handleMouseMove); - interaction.removeEventListener("mouseleave", handleMouseLeave); + touchActiveRef.current = false; if (rafIdRef.current != null) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; } hideLens(); resetActivePage(); - }; - }, [enabled, containerRef, interactionRef, overlayRef, lensRef, mirrorRef, hideLens, resetActivePage, onActivePageChange]); + return; + } - // بلافاصله بعد از سوییچ صفحه (رندر مجدد mirror) آخرین موقعیت شناخته‌شده را دوباره اعمال می‌کند - // تا محتوای تازه‌رندرشده یک فریم با موقعیت اشتباه دیده نشود. - useLayoutEffect(() => { - if (!enabled) return; - const mirror = mirrorRef.current; - const point = pendingPointRef.current; - if (!mirror || !point) return; - const half = MAGNIFIER_SIZE / 2; const container = containerRef.current; const interaction = interactionRef.current; if (!container || !interaction) return; - const page = findPageAtPoint(interaction, point.x, point.y, overlayRef.current); - if (!page) return; - const geometry = computeMagnifierGeometry(point.x, point.y, page, container, MAGNIFIER_SIZE); - if (!geometry) return; - mirror.style.transform = `translate3d(${half - geometry.localX * ZOOM_SCALE}px, ${half - geometry.localY * ZOOM_SCALE}px, 0)`; - }); + + const flushPending = () => { + rafIdRef.current = null; + const point = pendingPointRef.current; + if (!point) return; + applyGeometryRef.current(point.x, point.y); + }; + + const scheduleUpdate = (clientX: number, clientY: number) => { + if (isFlippingRef.current) { + hideLens(); + resetActivePage(); + return; + } + + pendingPointRef.current = { x: clientX, y: clientY }; + if (rafIdRef.current == null) { + rafIdRef.current = requestAnimationFrame(flushPending); + } + }; + + const isTouchLike = (event: PointerEvent) => + event.pointerType === "touch" || event.pointerType === "pen"; + + const handlePointerMove = (event: PointerEvent) => { + if (isTouchLike(event)) { + if (!touchActiveRef.current) return; + event.preventDefault(); + event.stopPropagation(); + } + scheduleUpdate(event.clientX, event.clientY); + }; + + const handlePointerDown = (event: PointerEvent) => { + if (!isTouchLike(event)) return; + event.preventDefault(); + event.stopPropagation(); + touchActiveRef.current = true; + scheduleUpdate(event.clientX, event.clientY); + }; + + const handlePointerUpOrCancel = (event: PointerEvent) => { + if (!isTouchLike(event)) return; + event.stopPropagation(); + touchActiveRef.current = false; + pendingPointRef.current = null; + hideLens(); + resetActivePage(); + }; + + const handlePointerLeave = (event: PointerEvent) => { + if (isTouchLike(event)) return; + pendingPointRef.current = null; + hideLens(); + resetActivePage(); + }; + + const listenerOptions = { capture: true, passive: false } as const; + + interaction.addEventListener("pointermove", handlePointerMove, listenerOptions); + interaction.addEventListener("pointerdown", handlePointerDown, listenerOptions); + interaction.addEventListener("pointerup", handlePointerUpOrCancel, listenerOptions); + interaction.addEventListener("pointercancel", handlePointerUpOrCancel, listenerOptions); + interaction.addEventListener("pointerleave", handlePointerLeave); + + const blockTouchEvent = (event: TouchEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + const touchBlockOptions = { capture: true, passive: false } as const; + + if (blockFlipTouch) { + interaction.addEventListener("touchstart", blockTouchEvent, touchBlockOptions); + interaction.addEventListener("touchmove", blockTouchEvent, touchBlockOptions); + interaction.addEventListener("touchend", blockTouchEvent, touchBlockOptions); + interaction.addEventListener("touchcancel", blockTouchEvent, touchBlockOptions); + } + + return () => { + if (blockFlipTouch) { + interaction.removeEventListener("touchstart", blockTouchEvent, touchBlockOptions); + interaction.removeEventListener("touchmove", blockTouchEvent, touchBlockOptions); + interaction.removeEventListener("touchend", blockTouchEvent, touchBlockOptions); + interaction.removeEventListener("touchcancel", blockTouchEvent, touchBlockOptions); + } + interaction.removeEventListener("pointermove", handlePointerMove, listenerOptions); + interaction.removeEventListener("pointerdown", handlePointerDown, listenerOptions); + interaction.removeEventListener("pointerup", handlePointerUpOrCancel, listenerOptions); + interaction.removeEventListener("pointercancel", handlePointerUpOrCancel, listenerOptions); + interaction.removeEventListener("pointerleave", handlePointerLeave); + if (rafIdRef.current != null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + touchActiveRef.current = false; + hideLens(); + resetActivePage(); + }; + }, [ + enabled, + containerRef, + interactionRef, + overlayRef, + lensRef, + mirrorRef, + isFlippingRef, + hideLens, + resetActivePage, + onActivePageChange, + blockFlipTouch, + ]); }