Files
dpage-editor/src/pages/viewer/components/Magnifier/useMagnifier.ts
T
hamid zarghami 80a92ea4c4 fix in mobile
2026-07-04 14:43:51 +03:30

253 lines
8.8 KiB
TypeScript

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<HTMLElement | null>;
/** ناحیهٔ تعامل و جستجوی صفحات (wrapper کتاب) */
interactionRef: RefObject<HTMLElement | null>;
overlayRef: RefObject<HTMLElement | null>;
lensRef: RefObject<HTMLElement | null>;
/** لایهٔ داخل لنز که محتوای صفحه (BookPage با مقیاس بزرگ) داخلش رندر می‌شود */
mirrorRef: RefObject<HTMLElement | null>;
/** در حین انیمیشن ورق‌خوردن true است — hit-test و به‌روزرسانی لنز متوقف می‌شود */
isFlippingRef: RefObject<boolean>;
/** در حالت تک‌صفحه (موبایل) همیشه همین صفحه بزرگ‌نمایی می‌شود، نه نتیجهٔ 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<number | null>(null);
const pendingPointRef = useRef<{ x: number; y: number } | null>(null);
const lastPageIdRef = useRef<number | null>(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,
]);
}