import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { MAGNIFIER_SIZE, ZOOM_SCALE } from "./constants"; import { computeMagnifierGeometry, findPageAtPoint, findPageById } from "./magnifierUtils"; type UseMagnifierOptions = { enabled: boolean; /** مرجع مختصات برای موقعیت‌دهی لنز (معمولاً ناحیهٔ اطراف کتاب) */ containerRef: RefObject; /** ناحیهٔ تعامل و جستجوی صفحات (wrapper کتاب) */ interactionRef: RefObject; overlayRef: RefObject; lensRef: RefObject; /** لایهٔ داخل لنز که محتوای صفحه (BookPage با مقیاس بزرگ) داخلش رندر می‌شود */ mirrorRef: RefObject; /** در حین انیمیشن ورق‌خوردن true است — hit-test و به‌روزرسانی لنز متوقف می‌شود */ isFlippingRef: RefObject; /** در حالت تک‌صفحه (موبایل) همیشه همین صفحه بزرگ‌نمایی می‌شود، نه نتیجهٔ hit-test */ lockedPageId?: number | null; /** جلوگیری از رسیدن touch به page-flip (کتابخانه تنظیمات را بعد از mount به‌روز نمی‌کند) */ blockFlipTouch?: boolean; /** با تغییر آن (ورق خوردن) وضعیت فعلی ریست می‌شود */ pageIndex?: number; /** وقتی صفحهٔ زیر ماوس عوض شود صدا زده می‌شود تا محتوای مناسب رندر شود */ onActivePageChange: (pageId: number | null) => void; }; export function useMagnifier({ enabled, containerRef, interactionRef, 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; if (lens) { lens.style.visibility = "hidden"; } }, [lensRef]); const resetActivePage = useCallback(() => { if (lastPageIdRef.current !== null) { lastPageIdRef.current = null; onActivePageChange(null); } }, [onActivePageChange]); const applyGeometryRef = useRef<(clientX: number, clientY: number) => void>(() => {}); useLayoutEffect(() => { applyGeometryRef.current = (clientX: number, clientY: number) => { if (isFlippingRef.current) { hideLens(); resetActivePage(); return; } const container = containerRef.current; const interaction = interactionRef.current; const lens = lensRef.current; if (!container || !interaction || !lens) return; const lockedId = lockedPageIdRef.current; const page = lockedId != null ? findPageById(interaction, lockedId) : findPageAtPoint(interaction, clientX, clientY, overlayRef.current); if (!page) { hideLens(); resetActivePage(); return; } const geometry = computeMagnifierGeometry(clientX, clientY, page, container, MAGNIFIER_SIZE); if (!geometry) { hideLens(); return; } const pageIdAttr = page.dataset.pageId; const pageId = pageIdAttr != null ? Number(pageIdAttr) : null; if (pageId !== lastPageIdRef.current) { lastPageIdRef.current = pageId; onActivePageChange(pageId); } const half = MAGNIFIER_SIZE / 2; const { pageOffsetX, pageOffsetY, localX, localY, clampedX, clampedY } = geometry; lens.style.visibility = "visible"; lens.style.transform = `translate3d(${pageOffsetX + clampedX - half}px, ${pageOffsetY + clampedY - half}px, 0)`; const mirror = mirrorRef.current; if (mirror) { mirror.style.transform = `translate3d(${half - localX * ZOOM_SCALE}px, ${half - localY * ZOOM_SCALE}px, 0)`; } }; }); // با ورق خوردن، وضعیت ذره‌بین کاملاً ریست می‌شود useEffect(() => { if (!enabled) return; pendingPointRef.current = null; touchActiveRef.current = false; hideLens(); resetActivePage(); lastPageIdRef.current = null; }, [pageIndex, enabled, hideLens, resetActivePage]); useLayoutEffect(() => { if (!enabled) { pendingPointRef.current = null; touchActiveRef.current = false; if (rafIdRef.current != null) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; } hideLens(); resetActivePage(); return; } const container = containerRef.current; const interaction = interactionRef.current; if (!container || !interaction) return; 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, ]); }