@@ -271,3 +271,8 @@ html {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* حالت ذرهبین: همهٔ صفحات visible در اسپرد قابل hit-test باشند */
|
||||||
|
.flipbook-container.flipbook-magnifier-active .stf__item {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ type BookPageProps = {
|
|||||||
showPaperShadow?: boolean;
|
showPaperShadow?: boolean;
|
||||||
/** ورق سخت بهجای clip-path برای iOS قدیمی */
|
/** ورق سخت بهجای clip-path برای iOS قدیمی */
|
||||||
useHardPage?: boolean;
|
useHardPage?: boolean;
|
||||||
|
/**
|
||||||
|
* پسوند یکتا برای idهای SVG (گرادیان و غیره).
|
||||||
|
* وقتی همان صفحه بیش از یکبار همزمان رندر میشود (مثلاً در Magnifier)
|
||||||
|
* باید idها متفاوت باشند تا با نسخهٔ اصلی تداخل نکنند.
|
||||||
|
*/
|
||||||
|
idSuffix?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,7 +78,7 @@ type BookPageProps = {
|
|||||||
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
|
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
|
||||||
*/
|
*/
|
||||||
const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
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;
|
const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase;
|
||||||
// تابع برای تبدیل opacity به رنگ (مثل editor)
|
// تابع برای تبدیل opacity به رنگ (مثل editor)
|
||||||
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
|
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
|
||||||
@@ -448,7 +454,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0;
|
const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0;
|
||||||
const fillColor = obj.fill || '#000000';
|
const fillColor = obj.fill || '#000000';
|
||||||
const strokeColor = obj.stroke || 'transparent';
|
const strokeColor = obj.stroke || 'transparent';
|
||||||
const gradientId = `triangle-grad-${obj.id}-${index}`;
|
const gradientId = `triangle-grad-${obj.id}-${index}${idSuffix}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -659,7 +665,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0;
|
const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0;
|
||||||
const fillColor = obj.fill || '#3b82f6';
|
const fillColor = obj.fill || '#3b82f6';
|
||||||
const strokeColor = obj.stroke || 'transparent';
|
const strokeColor = obj.stroke || 'transparent';
|
||||||
const gradientId = `custom-shape-grad-${obj.id}-${index}`;
|
const gradientId = `custom-shape-grad-${obj.id}-${index}${idSuffix}`;
|
||||||
const gradientEndpoints =
|
const gradientEndpoints =
|
||||||
obj.fillType === 'gradient' && obj.gradient
|
obj.fillType === 'gradient' && obj.gradient
|
||||||
? getSvgGradientEndpoints(obj.gradient, width, height, 'rect')
|
? getSvgGradientEndpoints(obj.gradient, width, height, 'rect')
|
||||||
@@ -894,6 +900,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
className="page"
|
className="page"
|
||||||
|
data-page-id={page.id}
|
||||||
data-density={useHardPage ? 'hard' : 'soft'}
|
data-density={useHardPage ? 'hard' : 'soft'}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { useBookEntranceController } from "@/pages/viewer/hooks/useBookEntranceC
|
|||||||
import { useIsBrowserFullscreen } from "@/pages/viewer/hooks/useIsBrowserFullscreen";
|
import { useIsBrowserFullscreen } from "@/pages/viewer/hooks/useIsBrowserFullscreen";
|
||||||
import { useViewerViewport } from "@/pages/viewer/hooks/useViewerViewport";
|
import { useViewerViewport } from "@/pages/viewer/hooks/useViewerViewport";
|
||||||
import { isLegacyIOSSafari } from "@/pages/viewer/utils/isLegacyIOSSafari";
|
import { isLegacyIOSSafari } from "@/pages/viewer/utils/isLegacyIOSSafari";
|
||||||
|
import { resolvePageBackground } from "@/pages/viewer/utils/pageBackground";
|
||||||
import { toggleViewerFullscreen } from "@/pages/viewer/utils/viewerFullscreen";
|
import { toggleViewerFullscreen } from "@/pages/viewer/utils/viewerFullscreen";
|
||||||
import { ArrowLeft2, ArrowRight2, Maximize4, Pause, Play, RowVertical } from "iconsax-react";
|
import { ArrowLeft2, ArrowRight2, Maximize4, Pause, Play, RowVertical } from "iconsax-react";
|
||||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
import { type FC, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import HTMLFlipBook from "react-pageflip";
|
import HTMLFlipBook from "react-pageflip";
|
||||||
import { type PageData } from "../types";
|
import { type PageData } from "../types";
|
||||||
import BookPage from "./BookPage";
|
import BookPage from "./BookPage";
|
||||||
|
import Magnifier from "./Magnifier/Magnifier";
|
||||||
|
import ZoomButton from "./Toolbar/ZoomButton";
|
||||||
|
|
||||||
/** فاصله پیشفرض بین هر ورق در پخش خودکار (میلیثانیه) */
|
/** فاصله پیشفرض بین هر ورق در پخش خودکار (میلیثانیه) */
|
||||||
const AUTO_PLAY_INTERVAL_MS = 3000;
|
const AUTO_PLAY_INTERVAL_MS = 3000;
|
||||||
@@ -118,10 +121,13 @@ function triggerFlipPrev(api: PageFlipWithFlipController | undefined) {
|
|||||||
*/
|
*/
|
||||||
const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings }) => {
|
const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings }) => {
|
||||||
const bookRef = useRef<FlipBookInstance | null>(null);
|
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 pageFlipHandlerRef = useRef<(e: FlipEvent) => void>(() => {});
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
const [isAutoPlayActive, setIsAutoPlayActive] = useState(false);
|
const [isAutoPlayActive, setIsAutoPlayActive] = useState(false);
|
||||||
const [startPage, setStartPage] = useState(0);
|
const [startPage, setStartPage] = useState(0);
|
||||||
|
const [magnifierEnabled, setMagnifierEnabled] = useState(false);
|
||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
const legacyIOS = useLegacyIOSSafari();
|
const legacyIOS = useLegacyIOSSafari();
|
||||||
const isBrowserFullscreen = useIsBrowserFullscreen();
|
const isBrowserFullscreen = useIsBrowserFullscreen();
|
||||||
@@ -139,6 +145,10 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
const backgroundColor = documentSettings?.backgroundColor ?? "#ffffff";
|
const backgroundColor = documentSettings?.backgroundColor ?? "#ffffff";
|
||||||
const backgroundGradient = documentSettings?.backgroundGradient;
|
const backgroundGradient = documentSettings?.backgroundGradient;
|
||||||
const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? "";
|
const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? "";
|
||||||
|
const pageBackgroundDefaults = useMemo(
|
||||||
|
() => ({ backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl }),
|
||||||
|
[backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsAutoPlayActive(autoPlay);
|
setIsAutoPlayActive(autoPlay);
|
||||||
@@ -359,6 +369,21 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
setIsAutoPlayActive((prev) => !prev);
|
setIsAutoPlayActive((prev) => !prev);
|
||||||
}, [pages.length]);
|
}, [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(
|
const navigateToPageIndex = useCallback(
|
||||||
(targetIndex: number) => {
|
(targetIndex: number) => {
|
||||||
const pageFlipAPI = bookRef.current?.pageFlip();
|
const pageFlipAPI = bookRef.current?.pageFlip();
|
||||||
@@ -441,7 +466,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
return (
|
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="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 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 عمودی در وسط (فقط حالت دوصفحهای دسکتاپ) */}
|
{/* Border و Shadow عمودی در وسط (فقط حالت دوصفحهای دسکتاپ) */}
|
||||||
{!usePortrait && (
|
{!usePortrait && (
|
||||||
<div
|
<div
|
||||||
@@ -456,9 +481,11 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
ایندکس/onFlip با دکمهها میشود. محتوای فارسی داخل BookPage با dir=rtl است.
|
ایندکس/onFlip با دکمهها میشود. محتوای فارسی داخل BookPage با dir=rtl است.
|
||||||
*/}
|
*/}
|
||||||
<div
|
<div
|
||||||
|
ref={flipbookWrapperRef}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
className="max-w-full flex justify-center shrink-0 rounded-sm"
|
className="max-w-full flex justify-center shrink-0 rounded-sm"
|
||||||
style={{
|
style={{
|
||||||
|
cursor: magnifierEnabled ? "zoom-in" : undefined,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
width: usePortrait ? pagePixelWidth : pagePixelWidth * 2,
|
width: usePortrait ? pagePixelWidth : pagePixelWidth * 2,
|
||||||
height: pagePixelHeight,
|
height: pagePixelHeight,
|
||||||
@@ -484,35 +511,54 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
showCover={hasMultiplePages}
|
showCover={hasMultiplePages}
|
||||||
mobileScrollSupport={!usePortrait}
|
mobileScrollSupport={!usePortrait}
|
||||||
clickEventForward={true}
|
clickEventForward={true}
|
||||||
useMouseEvents={hasMultiplePages}
|
useMouseEvents={hasMultiplePages && !magnifierEnabled}
|
||||||
swipeDistance={hasMultiplePages ? (usePortrait ? 48 : 30) : 9999}
|
swipeDistance={hasMultiplePages ? (usePortrait ? 48 : 30) : 9999}
|
||||||
startZIndex={1}
|
startZIndex={1}
|
||||||
showPageCorners={hasMultiplePages}
|
showPageCorners={hasMultiplePages}
|
||||||
disableFlipByClick={true}
|
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={{}}
|
style={{}}
|
||||||
onFlip={onFlipForwarded}
|
onFlip={onFlipForwarded}
|
||||||
onInit={handleBookInit}
|
onInit={handleBookInit}
|
||||||
onChangeState={handleChangeState}
|
onChangeState={handleChangeState}
|
||||||
>
|
>
|
||||||
{displayPages.map((page) => (
|
{displayPages.map((page) => {
|
||||||
<BookPage
|
const background = resolvePageBackground(page, pageBackgroundDefaults);
|
||||||
key={page.id}
|
return (
|
||||||
page={page}
|
<BookPage
|
||||||
entrancePhase={getEntrancePhase(page.id)}
|
key={page.id}
|
||||||
scale={contentScale}
|
page={page}
|
||||||
pageWidth={pagePixelWidth}
|
entrancePhase={getEntrancePhase(page.id)}
|
||||||
pageHeight={pagePixelHeight}
|
scale={contentScale}
|
||||||
onLinkClick={handleLinkClick}
|
pageWidth={pagePixelWidth}
|
||||||
backgroundType={page.backgroundType ?? backgroundType}
|
pageHeight={pagePixelHeight}
|
||||||
backgroundColor={page.backgroundColor ?? backgroundColor}
|
onLinkClick={handleLinkClick}
|
||||||
backgroundGradient={page.backgroundGradient ?? backgroundGradient}
|
backgroundType={background.backgroundType}
|
||||||
backgroundImageUrl={page.backgroundImageUrl ?? backgroundImageUrl}
|
backgroundColor={background.backgroundColor}
|
||||||
useHardPage={legacyIOS}
|
backgroundGradient={background.backgroundGradient}
|
||||||
/>
|
backgroundImageUrl={background.backgroundImageUrl}
|
||||||
))}
|
useHardPage={legacyIOS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</HTMLFlipBook>
|
</HTMLFlipBook>
|
||||||
</div>
|
</div>
|
||||||
|
<Magnifier
|
||||||
|
enabled={magnifierEnabled}
|
||||||
|
containerRef={bookAreaRef}
|
||||||
|
interactionRef={flipbookWrapperRef}
|
||||||
|
pages={displayPages}
|
||||||
|
pageWidth={pagePixelWidth}
|
||||||
|
pageHeight={pagePixelHeight}
|
||||||
|
contentScale={contentScale}
|
||||||
|
pageDefaults={pageBackgroundDefaults}
|
||||||
|
useHardPage={legacyIOS}
|
||||||
|
pageIndex={currentPage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -570,6 +616,8 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
|
|
||||||
{hasMultiplePages && autoPlay && <AutoPlayToggleButton isActive={isAutoPlayActive} onToggle={toggleAutoPlay} />}
|
{hasMultiplePages && autoPlay && <AutoPlayToggleButton isActive={isAutoPlayActive} onToggle={toggleAutoPlay} />}
|
||||||
|
|
||||||
|
<ZoomButton isActive={magnifierEnabled} onToggle={toggleMagnifier} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleViewerFullscreen}
|
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