253 lines
8.8 KiB
TypeScript
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,
|
|
]);
|
|
}
|