magnifier
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
hamid zarghami
2026-07-01 11:17:08 +03:30
parent 39e18c3331
commit 85ba8e4261
10 changed files with 585 additions and 22 deletions
@@ -0,0 +1,168 @@
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { MAGNIFIER_SIZE, ZOOM_SCALE } from "./constants";
import { computeMagnifierGeometry, findPageAtPoint } 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>;
/** با تغییر آن (ورق خوردن) وضعیت فعلی ریست می‌شود */
pageIndex?: number;
/** وقتی صفحهٔ زیر ماوس عوض شود صدا زده می‌شود تا محتوای مناسب رندر شود */
onActivePageChange: (pageId: number | null) => void;
};
export function useMagnifier({
enabled,
containerRef,
interactionRef,
overlayRef,
lensRef,
mirrorRef,
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 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]);
// با تغییر صفحه (ورق خوردن) باید دوباره از صفر hit-test انجام شود
useEffect(() => {
if (!enabled) return;
pendingPointRef.current = null;
hideLens();
resetActivePage();
}, [pageIndex, enabled, hideLens, resetActivePage]);
useLayoutEffect(() => {
if (!enabled) {
pendingPointRef.current = null;
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 half = MAGNIFIER_SIZE / 2;
const applyGeometry = (clientX: number, clientY: number) => {
const lens = lensRef.current;
if (!lens) return;
const page = 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 { 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) {
const tx = half - localX * ZOOM_SCALE;
const ty = half - localY * ZOOM_SCALE;
mirror.style.transform = `translate3d(${tx}px, ${ty}px, 0)`;
}
};
const flushPending = () => {
rafIdRef.current = null;
const point = pendingPointRef.current;
if (!point) return;
applyGeometry(point.x, point.y);
};
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 = () => {
pendingPointRef.current = null;
hideLens();
resetActivePage();
};
interaction.addEventListener("mousemove", handleMouseMove);
interaction.addEventListener("mouseleave", handleMouseLeave);
return () => {
interaction.removeEventListener("mousemove", handleMouseMove);
interaction.removeEventListener("mouseleave", handleMouseLeave);
if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
hideLens();
resetActivePage();
};
}, [enabled, containerRef, interactionRef, overlayRef, lensRef, mirrorRef, hideLens, resetActivePage, onActivePageChange]);
// بلافاصله بعد از سوییچ صفحه (رندر مجدد 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)`;
});
}