Compare commits

..

4 Commits

Author SHA1 Message Date
hamid zarghami 46926c66e7 blue
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-04 14:51:24 +03:30
hamid zarghami a448ff10de rtl in mobile 2026-07-04 14:45:44 +03:30
hamid zarghami 80a92ea4c4 fix in mobile 2026-07-04 14:43:51 +03:30
hamid zarghami 63a774c4af fix repeat animation 2026-07-04 14:21:27 +03:30
15 changed files with 402 additions and 107 deletions
+10
View File
@@ -276,3 +276,13 @@ html {
.flipbook-container.flipbook-magnifier-active .stf__item { .flipbook-container.flipbook-magnifier-active .stf__item {
pointer-events: auto; pointer-events: auto;
} }
/* جلوگیری از اسکرول/ورق‌خوردن پیش‌فرض مرورگر هنگام لمس با ذره‌بین فعال */
.flipbook-container.flipbook-magnifier-active {
touch-action: none;
}
/* در حین انیمیشن ورق، hit-test ذره‌بین روی صفحات در حال حرکت انجام نشود */
.flipbook-flipping .flipbook-container.flipbook-magnifier-active .stf__item {
pointer-events: none;
}
@@ -88,7 +88,7 @@ const ObjectRenderer = ({
groupNode?.clearCache(); groupNode?.clearCache();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient]); }, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient, obj.blur]);
// Refresh cache after transformer is attached (when isSelected changes) // Refresh cache after transformer is attached (when isSelected changes)
useEffect(() => { useEffect(() => {
@@ -20,6 +20,7 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
const baseStroke = selectedObject.stroke ?? "#1e40af"; const baseStroke = selectedObject.stroke ?? "#1e40af";
const baseStrokeWidth = selectedObject.strokeWidth ?? 0; const baseStrokeWidth = selectedObject.strokeWidth ?? 0;
const baseBorderRadius = selectedObject.borderRadius ?? 0; const baseBorderRadius = selectedObject.borderRadius ?? 0;
const baseBlur = selectedObject.blur ?? 0;
const isSquareShape = const isSquareShape =
selectedObject.shapeType === "square" || selectedObject.shapeType === undefined; selectedObject.shapeType === "square" || selectedObject.shapeType === undefined;
@@ -179,6 +180,35 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
min={0} min={0}
/> />
)} )}
<div className="space-y-1">
<label className="text-sm">بلور</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={50}
value={baseBlur}
onChange={(e) =>
onUpdate(selectedObject.id, {
blur: Math.max(0, Math.min(50, Number(e.target.value))),
})
}
className="w-full"
/>
<input
type="number"
min={0}
max={50}
value={baseBlur}
onChange={(e) =>
onUpdate(selectedObject.id, {
blur: Math.max(0, Math.min(50, Number(e.target.value) || 0)),
})
}
className="w-16 h-9 rounded-lg border border-border px-2 text-xs"
/>
</div>
</div>
</div> </div>
); );
}; };
@@ -3,6 +3,7 @@ import { Star } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getKonvaGradientProps } from "../../utils/gradient"; import { getKonvaGradientProps } from "../../utils/gradient";
import { getKonvaBlurProps, useShapeBlurCache } from "../../utils/shapeBlur";
const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Star>(null); const shapeRef = useRef<Konva.Star>(null);
@@ -18,6 +19,18 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const actualStrokeWidth = obj.strokeWidth ?? 0; const actualStrokeWidth = obj.strokeWidth ?? 0;
const hasStroke = actualStrokeWidth > 0; const hasStroke = actualStrokeWidth > 0;
const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur);
useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [
obj.width,
obj.height,
obj.fill,
obj.fillType,
obj.gradient,
obj.stroke,
actualStrokeWidth,
obj.blur,
]);
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
const displayStroke = isSelected const displayStroke = isSelected
@@ -57,6 +70,7 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
strokeWidth={displayStrokeWidth} strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined} dash={isMask && showGuide ? [10, 5] : undefined}
rotation={obj.rotation || 0} rotation={obj.rotation || 0}
{...blurProps}
draggable={draggable} draggable={draggable}
onClick={(e) => { onClick={(e) => {
if (shapeRef.current) { if (shapeRef.current) {
@@ -3,6 +3,7 @@ import { Circle } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getKonvaGradientProps } from "../../utils/gradient"; import { getKonvaGradientProps } from "../../utils/gradient";
import { getKonvaBlurProps, useShapeBlurCache } from "../../utils/shapeBlur";
const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Circle>(null); const shapeRef = useRef<Konva.Circle>(null);
@@ -14,6 +15,17 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
const actualStrokeWidth = obj.strokeWidth ?? 0; const actualStrokeWidth = obj.strokeWidth ?? 0;
const hasStroke = actualStrokeWidth > 0; const hasStroke = actualStrokeWidth > 0;
const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur);
useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [
obj.width,
obj.fill,
obj.fillType,
obj.gradient,
obj.stroke,
actualStrokeWidth,
obj.blur,
]);
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
const displayStroke = isSelected const displayStroke = isSelected
@@ -46,6 +58,7 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
strokeWidth={displayStrokeWidth} strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined} dash={isMask && showGuide ? [10, 5] : undefined}
rotation={obj.rotation || 0} rotation={obj.rotation || 0}
{...blurProps}
draggable={draggable} draggable={draggable}
onClick={(e) => { onClick={(e) => {
if (shapeRef.current) { if (shapeRef.current) {
@@ -3,6 +3,7 @@ import { Rect } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getKonvaGradientProps } from "../../utils/gradient"; import { getKonvaGradientProps } from "../../utils/gradient";
import { getKonvaBlurProps, useShapeBlurCache } from "../../utils/shapeBlur";
const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Rect>(null); const shapeRef = useRef<Konva.Rect>(null);
@@ -15,6 +16,19 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
const actualStrokeWidth = obj.strokeWidth ?? 0; const actualStrokeWidth = obj.strokeWidth ?? 0;
const hasStroke = actualStrokeWidth > 0; const hasStroke = actualStrokeWidth > 0;
const cornerRadius = Math.max(0, obj.borderRadius ?? 0); const cornerRadius = Math.max(0, obj.borderRadius ?? 0);
const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur);
useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [
obj.width,
obj.height,
cornerRadius,
obj.fill,
obj.fillType,
obj.gradient,
obj.stroke,
actualStrokeWidth,
obj.blur,
]);
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
const displayStroke = isSelected const displayStroke = isSelected
@@ -54,6 +68,7 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
strokeWidth={displayStrokeWidth} strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined} dash={isMask && showGuide ? [10, 5] : undefined}
rotation={obj.rotation || 0} rotation={obj.rotation || 0}
{...blurProps}
draggable={draggable} draggable={draggable}
onClick={(e) => { onClick={(e) => {
if (shapeRef.current) { if (shapeRef.current) {
@@ -3,6 +3,7 @@ import { RegularPolygon } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getKonvaGradientProps } from "../../utils/gradient"; import { getKonvaGradientProps } from "../../utils/gradient";
import { getKonvaBlurProps, useShapeBlurCache } from "../../utils/shapeBlur";
const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.RegularPolygon>(null); const shapeRef = useRef<Konva.RegularPolygon>(null);
@@ -15,6 +16,18 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const actualStrokeWidth = obj.strokeWidth ?? 0; const actualStrokeWidth = obj.strokeWidth ?? 0;
const hasStroke = actualStrokeWidth > 0; const hasStroke = actualStrokeWidth > 0;
const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur);
useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [
obj.width,
obj.height,
obj.fill,
obj.fillType,
obj.gradient,
obj.stroke,
actualStrokeWidth,
obj.blur,
]);
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
const displayStroke = isSelected const displayStroke = isSelected
@@ -53,6 +66,7 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
strokeWidth={displayStrokeWidth} strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined} dash={isMask && showGuide ? [10, 5] : undefined}
rotation={obj.rotation || 0} rotation={obj.rotation || 0}
{...blurProps}
draggable={draggable} draggable={draggable}
onClick={(e) => { onClick={(e) => {
if (shapeRef.current) { if (shapeRef.current) {
@@ -82,6 +82,8 @@ export type EditorObject = {
scaleY?: number; scaleY?: number;
shapeType?: ShapeType; shapeType?: ShapeType;
borderRadius?: number; borderRadius?: number;
/** میزان بلور شکل به پیکسل (۰ = بدون بلور) */
blur?: number;
tableData?: TableData; tableData?: TableData;
visible?: boolean; visible?: boolean;
isMask?: boolean; isMask?: boolean;
+42
View File
@@ -0,0 +1,42 @@
import { useLayoutEffect, type CSSProperties } from "react";
import type Konva from "konva";
import KonvaLib from "konva";
export function getKonvaBlurProps(blur?: number) {
const blurRadius = blur ?? 0;
if (blurRadius <= 0) return {};
return {
filters: [KonvaLib.Filters.Blur],
blurRadius,
};
}
export function getCssBlurStyle(blur?: number, scale = 1): CSSProperties {
const blurPx = blur ?? 0;
if (blurPx <= 0) return {};
return { filter: `blur(${blurPx * scale}px)` };
}
export function useShapeBlurCache(
shapeRef: React.RefObject<Konva.Shape | null>,
blur: number | undefined,
cacheKey: unknown,
) {
useLayoutEffect(() => {
const node = shapeRef.current;
if (!node) return;
const blurRadius = blur ?? 0;
if (blurRadius > 0) {
node.clearCache();
node.cache({
offset: blurRadius,
drawBorder: false,
});
} else {
node.clearCache();
}
node.getLayer()?.batchDraw();
}, [blur, cacheKey, shapeRef]);
}
+10 -3
View File
@@ -1,4 +1,4 @@
import { forwardRef } from 'react'; import { forwardRef, memo } from 'react';
import { type PageData } from '../types'; import { type PageData } from '../types';
import type { EditorObject } from '@/pages/editor/store/editorStore'; import type { EditorObject } from '@/pages/editor/store/editorStore';
import { toCssLinearGradient, getSvgGradientEndpoints } from '@/pages/editor/utils/gradient'; import { toCssLinearGradient, getSvgGradientEndpoints } from '@/pages/editor/utils/gradient';
@@ -11,6 +11,7 @@ import {
resolveTextMaxWidth, resolveTextMaxWidth,
usesWrappedLayout, usesWrappedLayout,
} from '@/pages/editor/utils/textStyle'; } from '@/pages/editor/utils/textStyle';
import { getCssBlurStyle } from '@/pages/editor/utils/shapeBlur';
import '@/pages/viewer/styles/entranceAnimations.css'; import '@/pages/viewer/styles/entranceAnimations.css';
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle'; import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle'; import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle';
@@ -84,7 +85,7 @@ type BookPageProps = {
* این کامپوننت به عنوان child برای HTMLFlipBook استفاده می‌شود * این کامپوننت به عنوان child برای HTMLFlipBook استفاده می‌شود
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند * نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
*/ */
const BookPage = forwardRef<HTMLDivElement, BookPageProps>( const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => { ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => {
const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase; const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase;
// تابع برای تبدیل opacity به رنگ (مثل editor) // تابع برای تبدیل opacity به رنگ (مثل editor)
@@ -174,6 +175,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
obj.fillType === 'gradient' && obj.gradient obj.fillType === 'gradient' && obj.gradient
? { backgroundImage: toCssLinearGradient(obj.gradient) } ? { backgroundImage: toCssLinearGradient(obj.gradient) }
: { backgroundColor: obj.fill || 'transparent' }; : { backgroundColor: obj.fill || 'transparent' };
const shapeBlurStyle = getCssBlurStyle(obj.blur, scale);
switch (obj.type) { switch (obj.type) {
case 'text': { case 'text': {
@@ -487,6 +489,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
height: `${radius * 2}px`, height: `${radius * 2}px`,
borderRadius: '50%', borderRadius: '50%',
...objectFillStyle, ...objectFillStyle,
...shapeBlurStyle,
border: hasStroke && obj.stroke border: hasStroke && obj.stroke
? `${actualStrokeWidth * scale}px solid ${obj.stroke}` ? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
: 'none', : 'none',
@@ -549,6 +552,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
width: `${size}px`, width: `${size}px`,
height: `${size}px`, height: `${size}px`,
opacity: (obj.opacity ?? 100) / 100, opacity: (obj.opacity ?? 100) / 100,
...shapeBlurStyle,
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined, transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
transformOrigin: 'center center', transformOrigin: 'center center',
zIndex: index, zIndex: index,
@@ -608,6 +612,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
height: `${radius * 2}px`, height: `${radius * 2}px`,
clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)', clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)',
...objectFillStyle, ...objectFillStyle,
...shapeBlurStyle,
border: obj.stroke border: obj.stroke
? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}` ? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}`
: 'none', : 'none',
@@ -630,6 +635,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
width: `${(obj.width || 100) * scale}px`, width: `${(obj.width || 100) * scale}px`,
height: `${(obj.height || 100) * scale}px`, height: `${(obj.height || 100) * scale}px`,
...objectFillStyle, ...objectFillStyle,
...shapeBlurStyle,
borderRadius: `${Math.max(0, (obj.borderRadius || 0) * scale)}px`, borderRadius: `${Math.max(0, (obj.borderRadius || 0) * scale)}px`,
border: hasStroke && obj.stroke border: hasStroke && obj.stroke
? `${actualStrokeWidth * scale}px solid ${obj.stroke}` ? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
@@ -1037,7 +1043,8 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
{useHardPage && showPaperShadow && <div aria-hidden className="page-paper-shadow" />} {useHardPage && showPaperShadow && <div aria-hidden className="page-paper-shadow" />}
</div> </div>
); );
}); }),
);
BookPage.displayName = 'BookPage'; BookPage.displayName = 'BookPage';
+61 -26
View File
@@ -126,13 +126,23 @@ 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 bookAreaRef = useRef<HTMLDivElement | null>(null);
const flipbookWrapperRef = useRef<HTMLDivElement | null>(null); const flipbookWrapperRef = useRef<HTMLDivElement | null>(null);
const spineOverlayRef = 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 [magnifierEnabled, setMagnifierEnabled] = useState(false);
/** در حالت read خط وسط روی شکاف جلد دیده می‌شود؛ هنگام ورق‌زدن باید زیر ورق باشد */ /** ref نه state — تغییر z-index هنگام hover ورق نباید BookPage را remount/re-render کند (انیمیشن ورود دوباره اجرا می‌شد) */
const [isBookFlipping, setIsBookFlipping] = useState(false); const isBookFlippingRef = useRef(false);
const setBookFlippingVisual = useCallback((flipping: boolean) => {
if (isBookFlippingRef.current === flipping) return;
isBookFlippingRef.current = flipping;
flipbookWrapperRef.current?.classList.toggle("relative", flipping);
flipbookWrapperRef.current?.classList.toggle("z-10", flipping);
flipbookWrapperRef.current?.classList.toggle("flipbook-flipping", flipping);
spineOverlayRef.current?.classList.toggle("z-50", !flipping);
spineOverlayRef.current?.classList.toggle("z-0", flipping);
}, []);
const isDesktop = useIsDesktop(); const isDesktop = useIsDesktop();
const legacyIOS = useLegacyIOSSafari(); const legacyIOS = useLegacyIOSSafari();
const isBrowserFullscreen = useIsBrowserFullscreen(); const isBrowserFullscreen = useIsBrowserFullscreen();
@@ -142,8 +152,8 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
const leftToRightFlip = documentSettings?.leftToRightFlip ?? false; const leftToRightFlip = documentSettings?.leftToRightFlip ?? false;
const displayStyle = documentSettings?.displayStyle ?? "double"; const displayStyle = documentSettings?.displayStyle ?? "double";
const usePortrait = !isDesktop || displayStyle === "single"; const usePortrait = !isDesktop || displayStyle === "single";
/** react-pageflip از RTL پشتیبانی نمی‌کند؛ ترتیب صفحات برعکس + شروع از آخر (فقط دسکتاپ) */ /** react-pageflip از RTL پشتیبانی نمی‌کند؛ ترتیب صفحات برعکس + شروع از آخر */
const isRtlBook = !leftToRightFlip && isDesktop; const isRtlBook = !leftToRightFlip;
/** /**
* page-flip فقط صفحهٔ با ایندکس 0 را به‌عنوان جلد تکی در نظر می‌گیرد. بعد از برعکس‌کردن * page-flip فقط صفحهٔ با ایندکس 0 را به‌عنوان جلد تکی در نظر می‌گیرد. بعد از برعکس‌کردن
* آرایه برای RTL، جلد واقعی (صفحهٔ منطقی 0) در انتهای آرایه قرار می‌گیرد و اگر تعداد کل * آرایه برای RTL، جلد واقعی (صفحهٔ منطقی 0) در انتهای آرایه قرار می‌گیرد و اگر تعداد کل
@@ -222,6 +232,8 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
const pagePixelWidth = viewportFit.w; const pagePixelWidth = viewportFit.w;
const pagePixelHeight = viewportFit.h; const pagePixelHeight = viewportFit.h;
const contentScale = viewportFit.contentScale; const contentScale = viewportFit.contentScale;
const flipDisabledOnMobile = !isDesktop && magnifierEnabled;
const magnifierLockedPageId = usePortrait ? (pages[currentPage]?.id ?? null) : null;
const flipKey = `${usePortrait ? "portrait" : "spread"}-${pagePixelWidth}-${pagePixelHeight}-${flipIndexCount}`; const flipKey = `${usePortrait ? "portrait" : "spread"}-${pagePixelWidth}-${pagePixelHeight}-${flipIndexCount}`;
@@ -390,39 +402,47 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
[flipIndexCount, scheduleEntranceForIndices, getVisibleLogicalIndices, toLogicalIndex], [flipIndexCount, scheduleEntranceForIndices, getVisibleLogicalIndices, toLogicalIndex],
); );
const beginPageNavigation = useCallback(() => {
if (flipDisabledOnMobile) return false;
setBookFlippingVisual(true);
return true;
}, [flipDisabledOnMobile, setBookFlippingVisual]);
/** بعد از اتمام انیمیشن ورق، ایندکس واقعی کتاب را می‌گیریم و انیمیشن ورود را یک‌بار شروع می‌کنیم */ /** بعد از اتمام انیمیشن ورق، ایندکس واقعی کتاب را می‌گیریم و انیمیشن ورود را یک‌بار شروع می‌کنیم */
const handleChangeState = useCallback( const handleChangeState = useCallback(
(e: PageFlipStateEvent) => { (e: PageFlipStateEvent) => {
if (e.data === "read") { if (e.data === "read") {
setIsBookFlipping(false); setBookFlippingVisual(false);
syncPageIndexFromBook(); syncPageIndexFromBook();
commitEntranceAtCurrentSpread(); commitEntranceAtCurrentSpread();
return; return;
} }
setIsBookFlipping(true); setBookFlippingVisual(true);
}, },
[syncPageIndexFromBook, commitEntranceAtCurrentSpread], [syncPageIndexFromBook, commitEntranceAtCurrentSpread, setBookFlippingVisual],
); );
const goToNextPage = useCallback(() => { const goToNextPage = useCallback(() => {
if (currentPage >= pages.length - 1) return; if (currentPage >= pages.length - 1) return;
if (!beginPageNavigation()) return;
const api = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined; const api = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined;
if (isRtlBook) { if (isRtlBook) {
triggerFlipPrev(api); triggerFlipPrev(api);
} else { } else {
api?.flipNext(); api?.flipNext();
} }
}, [currentPage, pages.length, isRtlBook]); }, [currentPage, pages.length, isRtlBook, beginPageNavigation]);
const goToPrevPage = useCallback(() => { const goToPrevPage = useCallback(() => {
if (currentPage <= 0) return; if (currentPage <= 0) return;
if (!beginPageNavigation()) return;
const api = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined; const api = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined;
if (isRtlBook) { if (isRtlBook) {
api?.flipNext(); api?.flipNext();
} else { } else {
triggerFlipPrev(api); triggerFlipPrev(api);
} }
}, [currentPage, isRtlBook]); }, [currentPage, isRtlBook, beginPageNavigation]);
const stopAutoPlayByUser = useCallback(() => { const stopAutoPlayByUser = useCallback(() => {
if (isAutoPlayActive) { if (isAutoPlayActive) {
@@ -450,10 +470,17 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [magnifierEnabled]); }, [magnifierEnabled]);
useEffect(() => {
if (flipDisabledOnMobile && isAutoPlayActive) {
setIsAutoPlayActive(false);
}
}, [flipDisabledOnMobile, isAutoPlayActive]);
const navigateToPageIndex = useCallback( const navigateToPageIndex = useCallback(
(targetIndex: number) => { (targetIndex: number) => {
const pageFlipAPI = bookRef.current?.pageFlip(); const pageFlipAPI = bookRef.current?.pageFlip();
if (!pageFlipAPI) return; if (!pageFlipAPI) return;
if (!beginPageNavigation()) return;
const logicalIdx = Math.max(0, Math.min(targetIndex, pages.length - 1)); const logicalIdx = Math.max(0, Math.min(targetIndex, pages.length - 1));
const flipIdx = toFlipIndex(logicalIdx); const flipIdx = toFlipIndex(logicalIdx);
@@ -461,7 +488,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
lastFlipPageRef.current = logicalIdx; lastFlipPageRef.current = logicalIdx;
scheduleEntranceForIndices(getVisibleLogicalIndices(flipIdx)); scheduleEntranceForIndices(getVisibleLogicalIndices(flipIdx));
}, },
[pages.length, scheduleEntranceForIndices, getVisibleLogicalIndices, toFlipIndex], [pages.length, scheduleEntranceForIndices, getVisibleLogicalIndices, toFlipIndex, beginPageNavigation],
); );
const handleLinkClick = useCallback( const handleLinkClick = useCallback(
@@ -470,6 +497,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
stopAutoPlayByUser(); stopAutoPlayByUser();
if (!linkUrl.startsWith("page://")) return; if (!linkUrl.startsWith("page://")) return;
if (flipDisabledOnMobile) return;
const action = linkUrl.replace("page://", ""); const action = linkUrl.replace("page://", "");
@@ -488,7 +516,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
} }
} }
}, },
[pages.length, goToNextPage, goToPrevPage, navigateToPageIndex, stopAutoPlayByUser], [pages.length, goToNextPage, goToPrevPage, navigateToPageIndex, stopAutoPlayByUser, flipDisabledOnMobile],
); );
const prevFlipKeyRef = useRef(flipKey); const prevFlipKeyRef = useRef(flipKey);
@@ -509,7 +537,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
// پخش خودکار // پخش خودکار
useEffect(() => { useEffect(() => {
if (!isAutoPlayActive || pages.length <= 1) return; if (!isAutoPlayActive || pages.length <= 1 || flipDisabledOnMobile) return;
const timer = setInterval(() => { const timer = setInterval(() => {
if (currentPage >= pages.length - 1) { if (currentPage >= pages.length - 1) {
bookRef.current?.pageFlip()?.turnToPage?.(toFlipIndex(0)); bookRef.current?.pageFlip()?.turnToPage?.(toFlipIndex(0));
@@ -525,7 +553,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
} }
}, AUTO_PLAY_INTERVAL_MS); }, AUTO_PLAY_INTERVAL_MS);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [isAutoPlayActive, currentPage, pages.length, isRtlBook, toFlipIndex]); }, [isAutoPlayActive, currentPage, pages.length, isRtlBook, toFlipIndex, flipDisabledOnMobile]);
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">
@@ -534,10 +562,8 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
{/* Border و Shadow عمودی در وسط (فقط حالت دوصفحه‌ای دسکتاپ) */} {/* Border و Shadow عمودی در وسط (فقط حالت دوصفحه‌ای دسکتاپ) */}
{!usePortrait && ( {!usePortrait && (
<div <div
className={clx( ref={spineOverlayRef}
"hidden md:block absolute inset-y-0 left-1/2 -translate-x-1/2 w-px border-r border-gray-300 pointer-events-none transition-none", className="hidden md:block absolute inset-y-0 left-1/2 -translate-x-1/2 w-px border-r border-gray-300 pointer-events-none transition-none z-50"
isBookFlipping ? "z-0" : "z-50",
)}
style={{ style={{
boxShadow: "-5px 0 15px rgba(0, 0, 0, 0.15), 5px 0 15px rgba(0, 0, 0, 0.15)", boxShadow: "-5px 0 15px rgba(0, 0, 0, 0.15), 5px 0 15px rgba(0, 0, 0, 0.15)",
}} }}
@@ -550,12 +576,10 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
<div <div
ref={flipbookWrapperRef} ref={flipbookWrapperRef}
dir="ltr" dir="ltr"
className={clx( className="max-w-full flex justify-center shrink-0 rounded-sm"
"max-w-full flex justify-center shrink-0 rounded-sm",
isBookFlipping && "relative z-10",
)}
style={{ style={{
cursor: magnifierEnabled ? "zoom-in" : undefined, cursor: magnifierEnabled ? "zoom-in" : undefined,
touchAction: magnifierEnabled ? "none" : undefined,
overflow: "hidden", overflow: "hidden",
width: usePortrait ? pagePixelWidth : pagePixelWidth * 2, width: usePortrait ? pagePixelWidth : pagePixelWidth * 2,
height: pagePixelHeight, height: pagePixelHeight,
@@ -579,12 +603,12 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
autoSize={false} autoSize={false}
maxShadowOpacity={legacyIOS ? 0 : isDesktop ? 0.55 : 0.32} maxShadowOpacity={legacyIOS ? 0 : isDesktop ? 0.55 : 0.32}
showCover={hasMultiplePages} showCover={hasMultiplePages}
mobileScrollSupport={!usePortrait} mobileScrollSupport={!usePortrait && !magnifierEnabled}
clickEventForward={true} clickEventForward={true}
useMouseEvents={hasMultiplePages && !magnifierEnabled} useMouseEvents={hasMultiplePages && !magnifierEnabled}
swipeDistance={hasMultiplePages ? (usePortrait ? 48 : 30) : 9999} swipeDistance={flipDisabledOnMobile || !hasMultiplePages ? 9999 : usePortrait ? 48 : 30}
startZIndex={1} startZIndex={1}
showPageCorners={hasMultiplePages} showPageCorners={hasMultiplePages && !flipDisabledOnMobile}
disableFlipByClick={true} disableFlipByClick={true}
className={clx( className={clx(
"flipbook-container mx-auto max-w-full", "flipbook-container mx-auto max-w-full",
@@ -622,6 +646,9 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
enabled={magnifierEnabled} enabled={magnifierEnabled}
containerRef={bookAreaRef} containerRef={bookAreaRef}
interactionRef={flipbookWrapperRef} interactionRef={flipbookWrapperRef}
isFlippingRef={isBookFlippingRef}
lockedPageId={magnifierLockedPageId}
blockFlipTouch={flipDisabledOnMobile}
pages={displayPages} pages={displayPages}
pageWidth={pagePixelWidth} pageWidth={pagePixelWidth}
pageHeight={pagePixelHeight} pageHeight={pagePixelHeight}
@@ -647,9 +674,13 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
goToPrevPage(); goToPrevPage();
} }
}} }}
disabled={leftToRightFlip ? currentPage >= pages.length - 1 : currentPage <= 0} disabled={
flipDisabledOnMobile ||
(leftToRightFlip ? currentPage >= pages.length - 1 : currentPage <= 0)
}
className="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 hover:bg-gray-100 active:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition-all" className="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 hover:bg-gray-100 active:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition-all"
aria-label={leftToRightFlip ? "صفحه بعد" : "صفحه قبل"} aria-label={leftToRightFlip ? "صفحه بعد" : "صفحه قبل"}
title={flipDisabledOnMobile ? "برای ورق زدن ابتدا ذره‌بین را خاموش کنید" : undefined}
> >
<ArrowRight2 color="black" size={20} className="text-gray-700 md:w-6 md:h-6" /> <ArrowRight2 color="black" size={20} className="text-gray-700 md:w-6 md:h-6" />
</button> </button>
@@ -676,9 +707,13 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
goToNextPage(); goToNextPage();
} }
}} }}
disabled={leftToRightFlip ? currentPage <= 0 : currentPage >= pages.length - 1} disabled={
flipDisabledOnMobile ||
(leftToRightFlip ? currentPage <= 0 : currentPage >= pages.length - 1)
}
className="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 hover:bg-gray-100 active:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition-all" className="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 hover:bg-gray-100 active:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition-all"
aria-label={leftToRightFlip ? "صفحه قبل" : "صفحه بعد"} aria-label={leftToRightFlip ? "صفحه قبل" : "صفحه بعد"}
title={flipDisabledOnMobile ? "برای ورق زدن ابتدا ذره‌بین را خاموش کنید" : undefined}
> >
<ArrowLeft2 color="black" size={20} className="text-gray-700 md:w-6 md:h-6" /> <ArrowLeft2 color="black" size={20} className="text-gray-700 md:w-6 md:h-6" />
</button> </button>
@@ -21,6 +21,11 @@ export type MagnifierProps = {
contentScale: number; contentScale: number;
pageDefaults: PageBackgroundDefaults; pageDefaults: PageBackgroundDefaults;
useHardPage?: boolean; useHardPage?: boolean;
/** در حین انیمیشن ورق‌خوردن true است */
isFlippingRef: RefObject<boolean>;
/** در حالت تک‌صفحه (موبایل) همیشه صفحهٔ فعلی بزرگ‌نمایی می‌شود */
lockedPageId?: number | null;
blockFlipTouch?: boolean;
/** با تغییر آن (ورق خوردن) ذره‌بین ریست می‌شود */ /** با تغییر آن (ورق خوردن) ذره‌بین ریست می‌شود */
pageIndex?: number; pageIndex?: number;
}; };
@@ -36,6 +41,9 @@ const Magnifier = memo(
contentScale, contentScale,
pageDefaults, pageDefaults,
useHardPage = false, useHardPage = false,
isFlippingRef,
lockedPageId = null,
blockFlipTouch = false,
pageIndex, pageIndex,
}: MagnifierProps) => { }: MagnifierProps) => {
const overlayRef = useRef<HTMLDivElement>(null); const overlayRef = useRef<HTMLDivElement>(null);
@@ -54,15 +62,23 @@ const Magnifier = memo(
overlayRef, overlayRef,
lensRef, lensRef,
mirrorRef, mirrorRef,
isFlippingRef,
lockedPageId,
blockFlipTouch,
pageIndex, pageIndex,
onActivePageChange: handleActivePageChange, onActivePageChange: handleActivePageChange,
}); });
const activePage = useMemo( const lockedPage = useMemo(
() => (activePageId != null ? pages.find((p) => p.id === activePageId) : undefined), () => (lockedPageId != null ? pages.find((p) => p.id === lockedPageId) : undefined),
[pages, activePageId], [lockedPageId, pages],
); );
const activePage = useMemo(() => {
if (lockedPage) return lockedPage;
return activePageId != null ? pages.find((p) => p.id === activePageId) : undefined;
}, [lockedPage, pages, activePageId]);
const background = useMemo( const background = useMemo(
() => (activePage ? resolvePageBackground(activePage, pageDefaults) : null), () => (activePage ? resolvePageBackground(activePage, pageDefaults) : null),
[activePage, pageDefaults], [activePage, pageDefaults],
@@ -1,3 +1,12 @@
/** پیدا کردن المان `.page` با شناسهٔ مشخص (حالت تک‌صفحه‌ای موبایل) */
export function findPageById(root: HTMLElement, pageId: number): HTMLElement | null {
const page = root.querySelector<HTMLElement>(`.page[data-page-id="${pageId}"]`);
if (!(page instanceof HTMLElement) || !root.contains(page) || !isPageVisible(page)) {
return null;
}
return page;
}
/** پیدا کردن المان `.page` زیر مختصات مشخص، با اولویت بالاترین z-index در حالت اسپرد */ /** پیدا کردن المان `.page` زیر مختصات مشخص، با اولویت بالاترین z-index در حالت اسپرد */
export function findPageAtPoint( export function findPageAtPoint(
root: HTMLElement, root: HTMLElement,
@@ -1,10 +1,10 @@
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { MAGNIFIER_SIZE, ZOOM_SCALE } from "./constants"; import { MAGNIFIER_SIZE, ZOOM_SCALE } from "./constants";
import { computeMagnifierGeometry, findPageAtPoint } from "./magnifierUtils"; import { computeMagnifierGeometry, findPageAtPoint, findPageById } from "./magnifierUtils";
type UseMagnifierOptions = { type UseMagnifierOptions = {
enabled: boolean; enabled: boolean;
/** ناحیهٔ موقعیت‌دهی لنز (مرجع مختصات نسبی) */ /** مرجع مختصات برای موقعیت‌دهی لنز (معمولاً ناحیهٔ اطراف کتاب) */
containerRef: RefObject<HTMLElement | null>; containerRef: RefObject<HTMLElement | null>;
/** ناحیهٔ تعامل و جستجوی صفحات (wrapper کتاب) */ /** ناحیهٔ تعامل و جستجوی صفحات (wrapper کتاب) */
interactionRef: RefObject<HTMLElement | null>; interactionRef: RefObject<HTMLElement | null>;
@@ -12,6 +12,12 @@ type UseMagnifierOptions = {
lensRef: RefObject<HTMLElement | null>; lensRef: RefObject<HTMLElement | null>;
/** لایهٔ داخل لنز که محتوای صفحه (BookPage با مقیاس بزرگ) داخلش رندر می‌شود */ /** لایهٔ داخل لنز که محتوای صفحه (BookPage با مقیاس بزرگ) داخلش رندر می‌شود */
mirrorRef: RefObject<HTMLElement | null>; mirrorRef: RefObject<HTMLElement | null>;
/** در حین انیمیشن ورق‌خوردن true است — hit-test و به‌روزرسانی لنز متوقف می‌شود */
isFlippingRef: RefObject<boolean>;
/** در حالت تک‌صفحه (موبایل) همیشه همین صفحه بزرگ‌نمایی می‌شود، نه نتیجهٔ hit-test */
lockedPageId?: number | null;
/** جلوگیری از رسیدن touch به page-flip (کتابخانه تنظیمات را بعد از mount به‌روز نمی‌کند) */
blockFlipTouch?: boolean;
/** با تغییر آن (ورق خوردن) وضعیت فعلی ریست می‌شود */ /** با تغییر آن (ورق خوردن) وضعیت فعلی ریست می‌شود */
pageIndex?: number; pageIndex?: number;
/** وقتی صفحهٔ زیر ماوس عوض شود صدا زده می‌شود تا محتوای مناسب رندر شود */ /** وقتی صفحهٔ زیر ماوس عوض شود صدا زده می‌شود تا محتوای مناسب رندر شود */
@@ -25,12 +31,19 @@ export function useMagnifier({
overlayRef, overlayRef,
lensRef, lensRef,
mirrorRef, mirrorRef,
isFlippingRef,
lockedPageId = null,
blockFlipTouch = false,
pageIndex, pageIndex,
onActivePageChange, onActivePageChange,
}: UseMagnifierOptions) { }: UseMagnifierOptions) {
const rafIdRef = useRef<number | null>(null); const rafIdRef = useRef<number | null>(null);
const pendingPointRef = useRef<{ x: number; y: number } | null>(null); const pendingPointRef = useRef<{ x: number; y: number } | null>(null);
const lastPageIdRef = useRef<number | null>(null); const lastPageIdRef = useRef<number | null>(null);
const touchActiveRef = useRef(false);
const lockedPageIdRef = useRef(lockedPageId);
lockedPageIdRef.current = lockedPageId;
const hideLens = useCallback(() => { const hideLens = useCallback(() => {
const lens = lensRef.current; const lens = lensRef.current;
@@ -46,21 +59,11 @@ export function useMagnifier({
} }
}, [onActivePageChange]); }, [onActivePageChange]);
// با تغییر صفحه (ورق خوردن) باید دوباره از صفر hit-test انجام شود const applyGeometryRef = useRef<(clientX: number, clientY: number) => void>(() => {});
useEffect(() => {
if (!enabled) return;
pendingPointRef.current = null;
hideLens();
resetActivePage();
}, [pageIndex, enabled, hideLens, resetActivePage]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!enabled) { applyGeometryRef.current = (clientX: number, clientY: number) => {
pendingPointRef.current = null; if (isFlippingRef.current) {
if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
hideLens(); hideLens();
resetActivePage(); resetActivePage();
return; return;
@@ -68,15 +71,15 @@ export function useMagnifier({
const container = containerRef.current; const container = containerRef.current;
const interaction = interactionRef.current; const interaction = interactionRef.current;
if (!container || !interaction) return;
const half = MAGNIFIER_SIZE / 2;
const applyGeometry = (clientX: number, clientY: number) => {
const lens = lensRef.current; const lens = lensRef.current;
if (!lens) return; if (!container || !interaction || !lens) return;
const lockedId = lockedPageIdRef.current;
const page =
lockedId != null
? findPageById(interaction, lockedId)
: findPageAtPoint(interaction, clientX, clientY, overlayRef.current);
const page = findPageAtPoint(interaction, clientX, clientY, overlayRef.current);
if (!page) { if (!page) {
hideLens(); hideLens();
resetActivePage(); resetActivePage();
@@ -96,6 +99,7 @@ export function useMagnifier({
onActivePageChange(pageId); onActivePageChange(pageId);
} }
const half = MAGNIFIER_SIZE / 2;
const { pageOffsetX, pageOffsetY, localX, localY, clampedX, clampedY } = geometry; const { pageOffsetX, pageOffsetY, localX, localY, clampedX, clampedY } = geometry;
lens.style.visibility = "visible"; lens.style.visibility = "visible";
@@ -103,66 +107,146 @@ export function useMagnifier({
const mirror = mirrorRef.current; const mirror = mirrorRef.current;
if (mirror) { if (mirror) {
const tx = half - localX * ZOOM_SCALE; mirror.style.transform = `translate3d(${half - localX * ZOOM_SCALE}px, ${half - localY * ZOOM_SCALE}px, 0)`;
const ty = half - localY * ZOOM_SCALE;
mirror.style.transform = `translate3d(${tx}px, ${ty}px, 0)`;
} }
}; };
});
const flushPending = () => { // با ورق خوردن، وضعیت ذره‌بین کاملاً ریست می‌شود
rafIdRef.current = null; useEffect(() => {
const point = pendingPointRef.current; if (!enabled) return;
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; pendingPointRef.current = null;
touchActiveRef.current = false;
hideLens(); hideLens();
resetActivePage(); resetActivePage();
}; lastPageIdRef.current = null;
}, [pageIndex, enabled, hideLens, resetActivePage]);
interaction.addEventListener("mousemove", handleMouseMove); useLayoutEffect(() => {
interaction.addEventListener("mouseleave", handleMouseLeave); if (!enabled) {
pendingPointRef.current = null;
return () => { touchActiveRef.current = false;
interaction.removeEventListener("mousemove", handleMouseMove);
interaction.removeEventListener("mouseleave", handleMouseLeave);
if (rafIdRef.current != null) { if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current); cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null; rafIdRef.current = null;
} }
hideLens(); hideLens();
resetActivePage(); resetActivePage();
}; return;
}, [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 container = containerRef.current;
const interaction = interactionRef.current; const interaction = interactionRef.current;
if (!container || !interaction) return; if (!container || !interaction) return;
const page = findPageAtPoint(interaction, point.x, point.y, overlayRef.current);
if (!page) return; const flushPending = () => {
const geometry = computeMagnifierGeometry(point.x, point.y, page, container, MAGNIFIER_SIZE); rafIdRef.current = null;
if (!geometry) return; const point = pendingPointRef.current;
mirror.style.transform = `translate3d(${half - geometry.localX * ZOOM_SCALE}px, ${half - geometry.localY * ZOOM_SCALE}px, 0)`; 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,
]);
} }
@@ -38,6 +38,7 @@ type ViewerDataPage = {
strokeWidth?: number; strokeWidth?: number;
shapeType?: string; shapeType?: string;
borderRadius?: number; borderRadius?: number;
blur?: number;
imageUrl?: string; imageUrl?: string;
videoUrl?: string; videoUrl?: string;
audioUrl?: string; audioUrl?: string;
@@ -129,6 +130,9 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
if (obj.type === "rectangle" && obj.borderRadius !== undefined) { if (obj.type === "rectangle" && obj.borderRadius !== undefined) {
baseObject.borderRadius = obj.borderRadius; baseObject.borderRadius = obj.borderRadius;
} }
if (obj.type === "rectangle" && obj.blur !== undefined) {
baseObject.blur = obj.blur;
}
if ( if (
(obj.type === "image" || obj.type === "sticker" || obj.type === "document") && (obj.type === "image" || obj.type === "sticker" || obj.type === "document") &&