import { forwardRef } from 'react'; import { type PageData } from '../types'; import type { EditorObject } from '@/pages/editor/store/editorStore'; import { toCssLinearGradient } from '@/pages/editor/utils/gradient'; import { getFontFamily } from '@/pages/editor/utils/fontFamily'; import { getCssFontWeight, getViewerTextLayout, usesWrappedLayout, } from '@/pages/editor/utils/textStyle'; import '@/pages/viewer/styles/entranceAnimations.css'; import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle'; import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle'; import type { EntrancePhase } from '@/pages/viewer/hooks/useBookEntranceController'; type RenderObjectOptions = { skipEntrance?: boolean; }; type BookPageProps = { page: PageData; scale?: number; pageWidth?: number; pageHeight?: number; onLinkClick?: (linkUrl: string) => void; backgroundType?: 'color' | 'gradient' | 'image'; backgroundColor?: string; backgroundGradient?: { from: string; to: string; angle: number; }; backgroundImageUrl?: string; /** فاز انیمیشن ورود — از BookViewer کنترل می‌شود */ entrancePhase?: EntrancePhase; /** در پیش‌نمایش کاتالوگ انیمیشن ورود غیرفعال باشد */ disableEntranceAnimations?: boolean; /** سایهٔ لبهٔ کاغذ (گرادیان داخل DOM — سازگار با iOS قدیمی) */ showPaperShadow?: boolean; /** ورق سخت به‌جای clip-path برای iOS قدیمی */ useHardPage?: boolean; }; /** * کامپوننت خالص برای نمایش محتوای یک صفحه کتاب * تمام المنت‌ها با absolute positioning رندر می‌شوند * از HTML استفاده می‌شود (نه Canvas) برای سازگاری با Konva در آینده * این کامپوننت به عنوان child برای HTMLFlipBook استفاده می‌شود * نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند */ const BookPage = forwardRef( ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, entrancePhase = 'idle', disableEntranceAnimations = false, showPaperShadow = true, useHardPage = false }, ref) => { const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase; // تابع برای تبدیل opacity به رنگ (مثل editor) // در editor، getColorWithOpacity انتظار opacity 0-100 دارد // در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است // پس opacity را مستقیماً استفاده می‌کنیم const getColorWithOpacity = (fill?: string, opacity?: number): string => { if (!fill) return '#000000'; // opacity در EditorObject به صورت 0-100 است (بعد از تبدیل در dataTransformer) // در editor getColorWithOpacity انتظار 0-100 دارد const opacityValue = opacity !== undefined ? opacity : 100; // اگر opacity 100% باشد، رنگ را بدون تغییر برگردان (مثل editor) if (opacityValue >= 100) return fill; const hex = fill.replace('#', ''); // اگر hex کوتاه‌تر از 6 کاراکتر باشد، padding اضافه کن const paddedHex = hex.length === 3 ? hex.split('').map(char => char + char).join('') : hex; const r = parseInt(paddedHex.substring(0, 2), 16); const g = parseInt(paddedHex.substring(2, 4), 16); const b = parseInt(paddedHex.substring(4, 6), 16); // opacityValue را به alpha تبدیل کن (0-100 -> 0-1) مثل editor const alpha = opacityValue / 100; return `rgba(${r}, ${g}, ${b}, ${alpha})`; }; const flyLayoutPx = { width: pageWidth ?? 794 * scale, height: pageHeight ?? 1123 * scale, }; const withEntrance = ( style: React.CSSProperties, obj: EditorObject, index: number, extra?: { transformOrigin?: React.CSSProperties['transformOrigin']; rotationDeg?: number; }, ) => mergeEntranceAnimationStyle(style, obj, scale, index, phase, { ...extra, flyLayoutPx, }); const renderObjectContent = ( obj: EditorObject, index: number, options: RenderObjectOptions = {}, ) => { const applyStyle = ( style: React.CSSProperties, styleObj: EditorObject, styleIndex: number, extra?: { transformOrigin?: React.CSSProperties['transformOrigin']; rotationDeg?: number; }, ) => options.skipEntrance ? style : withEntrance(style, styleObj, styleIndex, extra); const actualStrokeWidth = obj.strokeWidth ?? 0; const hasStroke = actualStrokeWidth > 0; const baseStyle: React.CSSProperties = { position: 'absolute', left: `${(obj.x || 0) * scale}px`, top: `${(obj.y || 0) * scale}px`, // editor opacity is stored as 0-100, CSS expects 0-1 opacity: (obj.opacity ?? 100) / 100, transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined, // Konva default transform origin is top-left for Rect/Text/Image/etc. transformOrigin: 'top left', }; if (obj.visible === false) { return null; } const objectFillStyle: React.CSSProperties = obj.fillType === 'gradient' && obj.gradient ? { backgroundImage: toCssLinearGradient(obj.gradient) } : { backgroundColor: obj.fill || 'transparent' }; switch (obj.type) { case 'text': { // برای text، opacity در رنگ اعمال می‌شود، پس باید opacity را از baseStyle حذف کنیم // eslint-disable-next-line @typescript-eslint/no-unused-vars const { opacity, transform: _baseTransform, ...textBaseStyle } = baseStyle; const lineHeight = obj.lineHeight ?? 1.2; const wrapped = usesWrappedLayout(obj.text, obj.height, obj.fontSize || 24, lineHeight); const rotation = obj.rotation || 0; const fontSize = obj.fontSize || 24; const measureOpts = { fontSize, fontFamily: obj.fontFamily, fontWeight: obj.fontWeight, letterSpacing: obj.letterSpacing ?? 0, lineHeight, }; const textLayout = getViewerTextLayout({ x: obj.x || 0, width: obj.width, text: obj.text, textAlign: obj.textAlign, rotation, scale, measureOpts, wrapped, }); return (
{obj.text || ''}
); } case 'image': case 'sticker': return ( ); case 'video': return (