@@ -271,3 +271,8 @@ html {
|
||||
pointer-events: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* حالت ذرهبین: همهٔ صفحات visible در اسپرد قابل hit-test باشند */
|
||||
.flipbook-container.flipbook-magnifier-active .stf__item {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ type BookPageProps = {
|
||||
showPaperShadow?: boolean;
|
||||
/** ورق سخت بهجای clip-path برای iOS قدیمی */
|
||||
useHardPage?: boolean;
|
||||
/**
|
||||
* پسوند یکتا برای idهای SVG (گرادیان و غیره).
|
||||
* وقتی همان صفحه بیش از یکبار همزمان رندر میشود (مثلاً در Magnifier)
|
||||
* باید idها متفاوت باشند تا با نسخهٔ اصلی تداخل نکنند.
|
||||
*/
|
||||
idSuffix?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -72,7 +78,7 @@ type BookPageProps = {
|
||||
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
|
||||
*/
|
||||
const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, entrancePhase = 'idle', disableEntranceAnimations = false, showPaperShadow = true, useHardPage = false }, ref) => {
|
||||
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, entrancePhase = 'idle', disableEntranceAnimations = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => {
|
||||
const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase;
|
||||
// تابع برای تبدیل opacity به رنگ (مثل editor)
|
||||
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
|
||||
@@ -448,7 +454,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0;
|
||||
const fillColor = obj.fill || '#000000';
|
||||
const strokeColor = obj.stroke || 'transparent';
|
||||
const gradientId = `triangle-grad-${obj.id}-${index}`;
|
||||
const gradientId = `triangle-grad-${obj.id}-${index}${idSuffix}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -659,7 +665,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0;
|
||||
const fillColor = obj.fill || '#3b82f6';
|
||||
const strokeColor = obj.stroke || 'transparent';
|
||||
const gradientId = `custom-shape-grad-${obj.id}-${index}`;
|
||||
const gradientId = `custom-shape-grad-${obj.id}-${index}${idSuffix}`;
|
||||
const gradientEndpoints =
|
||||
obj.fillType === 'gradient' && obj.gradient
|
||||
? getSvgGradientEndpoints(obj.gradient, width, height, 'rect')
|
||||
@@ -894,6 +900,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
ref={ref}
|
||||
dir="rtl"
|
||||
className="page"
|
||||
data-page-id={page.id}
|
||||
data-density={useHardPage ? 'hard' : 'soft'}
|
||||
style={{
|
||||
position: 'relative',
|
||||
|
||||
@@ -5,12 +5,15 @@ import { useBookEntranceController } from "@/pages/viewer/hooks/useBookEntranceC
|
||||
import { useIsBrowserFullscreen } from "@/pages/viewer/hooks/useIsBrowserFullscreen";
|
||||
import { useViewerViewport } from "@/pages/viewer/hooks/useViewerViewport";
|
||||
import { isLegacyIOSSafari } from "@/pages/viewer/utils/isLegacyIOSSafari";
|
||||
import { resolvePageBackground } from "@/pages/viewer/utils/pageBackground";
|
||||
import { toggleViewerFullscreen } from "@/pages/viewer/utils/viewerFullscreen";
|
||||
import { ArrowLeft2, ArrowRight2, Maximize4, Pause, Play, RowVertical } from "iconsax-react";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import HTMLFlipBook from "react-pageflip";
|
||||
import { type PageData } from "../types";
|
||||
import BookPage from "./BookPage";
|
||||
import Magnifier from "./Magnifier/Magnifier";
|
||||
import ZoomButton from "./Toolbar/ZoomButton";
|
||||
|
||||
/** فاصله پیشفرض بین هر ورق در پخش خودکار (میلیثانیه) */
|
||||
const AUTO_PLAY_INTERVAL_MS = 3000;
|
||||
@@ -118,10 +121,13 @@ function triggerFlipPrev(api: PageFlipWithFlipController | undefined) {
|
||||
*/
|
||||
const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings }) => {
|
||||
const bookRef = useRef<FlipBookInstance | null>(null);
|
||||
const bookAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const flipbookWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const pageFlipHandlerRef = useRef<(e: FlipEvent) => void>(() => {});
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [isAutoPlayActive, setIsAutoPlayActive] = useState(false);
|
||||
const [startPage, setStartPage] = useState(0);
|
||||
const [magnifierEnabled, setMagnifierEnabled] = useState(false);
|
||||
const isDesktop = useIsDesktop();
|
||||
const legacyIOS = useLegacyIOSSafari();
|
||||
const isBrowserFullscreen = useIsBrowserFullscreen();
|
||||
@@ -139,6 +145,10 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
const backgroundColor = documentSettings?.backgroundColor ?? "#ffffff";
|
||||
const backgroundGradient = documentSettings?.backgroundGradient;
|
||||
const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? "";
|
||||
const pageBackgroundDefaults = useMemo(
|
||||
() => ({ backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl }),
|
||||
[backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAutoPlayActive(autoPlay);
|
||||
@@ -359,6 +369,21 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
setIsAutoPlayActive((prev) => !prev);
|
||||
}, [pages.length]);
|
||||
|
||||
const toggleMagnifier = useCallback(() => {
|
||||
setMagnifierEnabled((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!magnifierEnabled) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setMagnifierEnabled(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [magnifierEnabled]);
|
||||
|
||||
const navigateToPageIndex = useCallback(
|
||||
(targetIndex: number) => {
|
||||
const pageFlipAPI = bookRef.current?.pageFlip();
|
||||
@@ -441,7 +466,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 md:gap-6 w-full h-full max-md:min-h-0 max-md:max-w-full max-md:overflow-x-hidden max-md:pb-[env(safe-area-inset-bottom,0px)]" dir="rtl">
|
||||
<div className="relative w-full flex flex-col flex-1 max-md:min-h-0 pt-8 md:pt-12 min-h-0 overflow-hidden">
|
||||
<div className="relative flex flex-1 min-h-0 w-full items-center justify-center">
|
||||
<div ref={bookAreaRef} className="relative flex flex-1 min-h-0 w-full items-center justify-center">
|
||||
{/* Border و Shadow عمودی در وسط (فقط حالت دوصفحهای دسکتاپ) */}
|
||||
{!usePortrait && (
|
||||
<div
|
||||
@@ -456,9 +481,11 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
ایندکس/onFlip با دکمهها میشود. محتوای فارسی داخل BookPage با dir=rtl است.
|
||||
*/}
|
||||
<div
|
||||
ref={flipbookWrapperRef}
|
||||
dir="ltr"
|
||||
className="max-w-full flex justify-center shrink-0 rounded-sm"
|
||||
style={{
|
||||
cursor: magnifierEnabled ? "zoom-in" : undefined,
|
||||
overflow: "hidden",
|
||||
width: usePortrait ? pagePixelWidth : pagePixelWidth * 2,
|
||||
height: pagePixelHeight,
|
||||
@@ -484,35 +511,54 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
showCover={hasMultiplePages}
|
||||
mobileScrollSupport={!usePortrait}
|
||||
clickEventForward={true}
|
||||
useMouseEvents={hasMultiplePages}
|
||||
useMouseEvents={hasMultiplePages && !magnifierEnabled}
|
||||
swipeDistance={hasMultiplePages ? (usePortrait ? 48 : 30) : 9999}
|
||||
startZIndex={1}
|
||||
showPageCorners={hasMultiplePages}
|
||||
disableFlipByClick={true}
|
||||
className={clx("flipbook-container mx-auto max-w-full", legacyIOS && "flipbook-legacy-ios")}
|
||||
className={clx(
|
||||
"flipbook-container mx-auto max-w-full",
|
||||
legacyIOS && "flipbook-legacy-ios",
|
||||
magnifierEnabled && "flipbook-magnifier-active",
|
||||
)}
|
||||
style={{}}
|
||||
onFlip={onFlipForwarded}
|
||||
onInit={handleBookInit}
|
||||
onChangeState={handleChangeState}
|
||||
>
|
||||
{displayPages.map((page) => (
|
||||
<BookPage
|
||||
key={page.id}
|
||||
page={page}
|
||||
entrancePhase={getEntrancePhase(page.id)}
|
||||
scale={contentScale}
|
||||
pageWidth={pagePixelWidth}
|
||||
pageHeight={pagePixelHeight}
|
||||
onLinkClick={handleLinkClick}
|
||||
backgroundType={page.backgroundType ?? backgroundType}
|
||||
backgroundColor={page.backgroundColor ?? backgroundColor}
|
||||
backgroundGradient={page.backgroundGradient ?? backgroundGradient}
|
||||
backgroundImageUrl={page.backgroundImageUrl ?? backgroundImageUrl}
|
||||
useHardPage={legacyIOS}
|
||||
/>
|
||||
))}
|
||||
{displayPages.map((page) => {
|
||||
const background = resolvePageBackground(page, pageBackgroundDefaults);
|
||||
return (
|
||||
<BookPage
|
||||
key={page.id}
|
||||
page={page}
|
||||
entrancePhase={getEntrancePhase(page.id)}
|
||||
scale={contentScale}
|
||||
pageWidth={pagePixelWidth}
|
||||
pageHeight={pagePixelHeight}
|
||||
onLinkClick={handleLinkClick}
|
||||
backgroundType={background.backgroundType}
|
||||
backgroundColor={background.backgroundColor}
|
||||
backgroundGradient={background.backgroundGradient}
|
||||
backgroundImageUrl={background.backgroundImageUrl}
|
||||
useHardPage={legacyIOS}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</HTMLFlipBook>
|
||||
</div>
|
||||
<Magnifier
|
||||
enabled={magnifierEnabled}
|
||||
containerRef={bookAreaRef}
|
||||
interactionRef={flipbookWrapperRef}
|
||||
pages={displayPages}
|
||||
pageWidth={pagePixelWidth}
|
||||
pageHeight={pagePixelHeight}
|
||||
contentScale={contentScale}
|
||||
pageDefaults={pageBackgroundDefaults}
|
||||
useHardPage={legacyIOS}
|
||||
pageIndex={currentPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -570,6 +616,8 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
|
||||
{hasMultiplePages && autoPlay && <AutoPlayToggleButton isActive={isAutoPlayActive} onToggle={toggleAutoPlay} />}
|
||||
|
||||
<ZoomButton isActive={magnifierEnabled} onToggle={toggleMagnifier} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleViewerFullscreen}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.root,
|
||||
.root * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lens {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--magnifier-size);
|
||||
height: var(--magnifier-size);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.28),
|
||||
0 2px 8px rgba(0, 0, 0, 0.16),
|
||||
inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
background-color: #fff;
|
||||
will-change: transform;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* پنجرهٔ دید ثابت (بهاندازهٔ لنز)؛ محتوای بزرگنماییشده داخلش clip میشود */
|
||||
.content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* لایهای که BookPage با مقیاس بزرگ داخلش رندر میشود و فقط translate میگیرد */
|
||||
.mirror {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { memo, useCallback, useMemo, useRef, useState, type CSSProperties, type RefObject } from "react";
|
||||
import BookPage from "@/pages/viewer/components/BookPage";
|
||||
import type { PageData } from "@/pages/viewer/types";
|
||||
import { resolvePageBackground, type PageBackgroundDefaults } from "@/pages/viewer/utils/pageBackground";
|
||||
import { MAGNIFIER_SIZE, ZOOM_SCALE } from "./constants";
|
||||
import styles from "./Magnifier.module.css";
|
||||
import { useMagnifier } from "./useMagnifier";
|
||||
|
||||
const noopLinkClick = () => {};
|
||||
|
||||
export type MagnifierProps = {
|
||||
enabled: boolean;
|
||||
/** مرجع مختصات برای موقعیتدهی لنز (معمولاً ناحیهٔ اطراف کتاب) */
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
/** ناحیهای که رویدادهای موس روی آن گوش داده میشود (wrapper کتاب) */
|
||||
interactionRef: RefObject<HTMLElement | null>;
|
||||
/** صفحاتی که هماکنون در DOM رندر شدهاند (برای نگاشت pageId به داده) */
|
||||
pages: PageData[];
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
contentScale: number;
|
||||
pageDefaults: PageBackgroundDefaults;
|
||||
useHardPage?: boolean;
|
||||
/** با تغییر آن (ورق خوردن) ذرهبین ریست میشود */
|
||||
pageIndex?: number;
|
||||
};
|
||||
|
||||
const Magnifier = memo(
|
||||
({
|
||||
enabled,
|
||||
containerRef,
|
||||
interactionRef,
|
||||
pages,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
contentScale,
|
||||
pageDefaults,
|
||||
useHardPage = false,
|
||||
pageIndex,
|
||||
}: MagnifierProps) => {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const lensRef = useRef<HTMLDivElement>(null);
|
||||
const mirrorRef = useRef<HTMLDivElement>(null);
|
||||
const [activePageId, setActivePageId] = useState<number | null>(null);
|
||||
|
||||
const handleActivePageChange = useCallback((pageId: number | null) => {
|
||||
setActivePageId(pageId);
|
||||
}, []);
|
||||
|
||||
useMagnifier({
|
||||
enabled,
|
||||
containerRef,
|
||||
interactionRef,
|
||||
overlayRef,
|
||||
lensRef,
|
||||
mirrorRef,
|
||||
pageIndex,
|
||||
onActivePageChange: handleActivePageChange,
|
||||
});
|
||||
|
||||
const activePage = useMemo(
|
||||
() => (activePageId != null ? pages.find((p) => p.id === activePageId) : undefined),
|
||||
[pages, activePageId],
|
||||
);
|
||||
|
||||
const background = useMemo(
|
||||
() => (activePage ? resolvePageBackground(activePage, pageDefaults) : null),
|
||||
[activePage, pageDefaults],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className={styles.root}
|
||||
aria-hidden
|
||||
data-magnifier-overlay
|
||||
style={{ display: enabled ? "block" : "none" }}
|
||||
>
|
||||
<div
|
||||
ref={lensRef}
|
||||
className={styles.lens}
|
||||
style={{ "--magnifier-size": `${MAGNIFIER_SIZE}px` } as CSSProperties}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
{activePage && background && (
|
||||
<div ref={mirrorRef} className={styles.mirror}>
|
||||
<BookPage
|
||||
page={activePage}
|
||||
scale={contentScale * ZOOM_SCALE}
|
||||
pageWidth={pageWidth * ZOOM_SCALE}
|
||||
pageHeight={pageHeight * ZOOM_SCALE}
|
||||
onLinkClick={noopLinkClick}
|
||||
backgroundType={background.backgroundType}
|
||||
backgroundColor={background.backgroundColor}
|
||||
backgroundGradient={background.backgroundGradient}
|
||||
backgroundImageUrl={background.backgroundImageUrl}
|
||||
useHardPage={useHardPage}
|
||||
disableEntranceAnimations
|
||||
showPaperShadow={false}
|
||||
idSuffix="-magnifier"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Magnifier.displayName = "Magnifier";
|
||||
|
||||
export default Magnifier;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const MAGNIFIER_SIZE = 220;
|
||||
export const ZOOM_SCALE = 2.5;
|
||||
@@ -0,0 +1,121 @@
|
||||
/** پیدا کردن المان `.page` زیر مختصات مشخص، با اولویت بالاترین z-index در حالت اسپرد */
|
||||
export function findPageAtPoint(
|
||||
root: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
ignoreRoot?: HTMLElement | null,
|
||||
): HTMLElement | null {
|
||||
const hitElements = document.elementsFromPoint(clientX, clientY);
|
||||
for (const el of hitElements) {
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
if (ignoreRoot?.contains(el)) continue;
|
||||
if (!root.contains(el)) continue;
|
||||
|
||||
const page = el.closest(".page");
|
||||
if (page instanceof HTMLElement && root.contains(page) && isPageVisible(page)) {
|
||||
return page;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback برای زمانی که pointer-events روی صفحات محدود شده (مثلاً هنگام ورقخوردن)
|
||||
let match: HTMLElement | null = null;
|
||||
let topZ = -Infinity;
|
||||
|
||||
root.querySelectorAll<HTMLElement>(".page").forEach((page) => {
|
||||
if (!isPageVisible(page)) return;
|
||||
|
||||
const rect = page.getBoundingClientRect();
|
||||
if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const z = getPageStackZIndex(page);
|
||||
if (z >= topZ) {
|
||||
topZ = z;
|
||||
match = page;
|
||||
}
|
||||
});
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
function isPageVisible(page: HTMLElement): boolean {
|
||||
const rect = page.getBoundingClientRect();
|
||||
if (rect.width < 1 || rect.height < 1) return false;
|
||||
|
||||
const style = window.getComputedStyle(page);
|
||||
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||
|
||||
const opacity = Number.parseFloat(style.opacity);
|
||||
if (Number.isFinite(opacity) && opacity < 0.05) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getPageStackZIndex(page: HTMLElement): number {
|
||||
let el: HTMLElement | null = page;
|
||||
let maxZ = 0;
|
||||
|
||||
while (el) {
|
||||
const z = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
|
||||
if (Number.isFinite(z)) {
|
||||
maxZ = Math.max(maxZ, z);
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
|
||||
return maxZ;
|
||||
}
|
||||
|
||||
export type MagnifierGeometry = {
|
||||
/** فاصلهٔ گوشهٔ بالا-چپ صفحه از containerRef (برای موقعیتدهی خود لنز) */
|
||||
pageOffsetX: number;
|
||||
pageOffsetY: number;
|
||||
/** مختصات موس نسبت به گوشهٔ بالا-چپ صفحه (بدون کلمپ — برای محتوای داخل لنز) */
|
||||
localX: number;
|
||||
localY: number;
|
||||
/** مختصات کلمپشده در محدودهٔ صفحه (برای موقعیت خود لنز، تا از صفحه بیرون نزند) */
|
||||
clampedX: number;
|
||||
clampedY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
};
|
||||
|
||||
export function computeMagnifierGeometry(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
page: HTMLElement,
|
||||
container: HTMLElement,
|
||||
lensSize: number,
|
||||
): MagnifierGeometry | null {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const pageRect = page.getBoundingClientRect();
|
||||
|
||||
if (pageRect.width <= 0 || pageRect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const localX = clientX - pageRect.left;
|
||||
const localY = clientY - pageRect.top;
|
||||
|
||||
if (localX < 0 || localY < 0 || localX > pageRect.width || localY > pageRect.height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const half = lensSize / 2;
|
||||
const clampedX =
|
||||
pageRect.width <= lensSize ? pageRect.width / 2 : Math.max(half, Math.min(pageRect.width - half, localX));
|
||||
const clampedY =
|
||||
pageRect.height <= lensSize ? pageRect.height / 2 : Math.max(half, Math.min(pageRect.height - half, localY));
|
||||
|
||||
return {
|
||||
pageOffsetX: pageRect.left - containerRect.left,
|
||||
pageOffsetY: pageRect.top - containerRect.top,
|
||||
localX,
|
||||
localY,
|
||||
clampedX,
|
||||
clampedY,
|
||||
pageWidth: pageRect.width,
|
||||
pageHeight: pageRect.height,
|
||||
};
|
||||
}
|
||||
@@ -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)`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { clx } from "@/helpers/utils";
|
||||
import { SearchNormal1 } from "iconsax-react";
|
||||
import { type FC } from "react";
|
||||
|
||||
export type ZoomButtonProps = {
|
||||
isActive: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
const ZoomButton: FC<ZoomButtonProps> = ({ isActive, onToggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={isActive ? "غیرفعال کردن ذرهبین" : "فعال کردن ذرهبین"}
|
||||
aria-pressed={isActive}
|
||||
title={isActive ? "غیرفعال کردن ذرهبین" : "فعال کردن ذرهبین"}
|
||||
className={clx(
|
||||
"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 transition-all",
|
||||
isActive
|
||||
? "bg-gray-200 ring-2 ring-gray-400 hover:bg-gray-200"
|
||||
: "hover:bg-gray-100 active:bg-gray-200",
|
||||
)}
|
||||
>
|
||||
<SearchNormal1
|
||||
color="black"
|
||||
size={20}
|
||||
variant={isActive ? "Bold" : "Outline"}
|
||||
className="text-gray-700 md:w-6 md:h-6"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
export default ZoomButton;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PageData } from "@/pages/viewer/types";
|
||||
|
||||
export type PageBackgroundDefaults = {
|
||||
backgroundType?: "color" | "gradient" | "image";
|
||||
backgroundColor?: string;
|
||||
backgroundGradient?: { from: string; to: string; angle: number };
|
||||
backgroundImageUrl?: string;
|
||||
};
|
||||
|
||||
/** ترکیب پسزمینهٔ اختصاصی صفحه با مقادیر پیشفرض سند (همان قاعدهٔ استفادهشده در BookViewer) */
|
||||
export function resolvePageBackground(page: PageData, defaults: PageBackgroundDefaults): Required<PageBackgroundDefaults> {
|
||||
return {
|
||||
backgroundType: page.backgroundType ?? defaults.backgroundType ?? "color",
|
||||
backgroundColor: page.backgroundColor ?? defaults.backgroundColor ?? "#ffffff",
|
||||
backgroundGradient: page.backgroundGradient ?? defaults.backgroundGradient ?? { from: "#ffffff", to: "#ffffff", angle: 0 },
|
||||
backgroundImageUrl: page.backgroundImageUrl ?? defaults.backgroundImageUrl ?? "",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user