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
+5
View File
@@ -271,3 +271,8 @@ html {
pointer-events: auto;
z-index: 100;
}
/* حالت ذره‌بین: همهٔ صفحات visible در اسپرد قابل hit-test باشند */
.flipbook-container.flipbook-magnifier-active .stf__item {
pointer-events: auto;
}
+10 -3
View File
@@ -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',
+67 -19
View File
@@ -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;
+18
View File
@@ -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 ?? "",
};
}