860 lines
32 KiB
TypeScript
860 lines
32 KiB
TypeScript
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<HTMLDivElement, BookPageProps>(
|
|
({ 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 (
|
|
<div
|
|
key={obj.id || index}
|
|
style={applyStyle(
|
|
{
|
|
...textBaseStyle,
|
|
left: `${textLayout.leftPx}px`,
|
|
width: textLayout.widthPx !== undefined ? `${textLayout.widthPx}px` : 'max-content',
|
|
fontSize: `${fontSize * scale}px`,
|
|
fontFamily: getFontFamily(obj.fontFamily),
|
|
fontWeight: getCssFontWeight(obj.fontWeight),
|
|
lineHeight,
|
|
textAlign: textLayout.textAlign,
|
|
direction: 'rtl',
|
|
color: getColorWithOpacity(obj.fill, obj.opacity),
|
|
whiteSpace: wrapped ? 'pre-wrap' : 'nowrap',
|
|
overflowWrap: wrapped ? 'break-word' : 'normal',
|
|
letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined,
|
|
boxSizing: 'content-box',
|
|
transform: textLayout.transform,
|
|
transformOrigin: textLayout.transformOrigin,
|
|
},
|
|
obj,
|
|
index,
|
|
{ rotationDeg: rotation, transformOrigin: textLayout.transformOrigin },
|
|
)}
|
|
>
|
|
{obj.text || ''}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'image':
|
|
case 'sticker':
|
|
return (
|
|
<img
|
|
key={obj.id || index}
|
|
src={obj.imageUrl || ''}
|
|
alt=""
|
|
style={applyStyle(
|
|
{
|
|
...baseStyle,
|
|
width: obj.width ? `${obj.width * scale}px` : 'auto',
|
|
height: obj.height ? `${obj.height * scale}px` : 'auto',
|
|
objectFit: 'fill', // مشابه Konva که image را با width و height مشخص شده رندر میکند
|
|
zIndex: index, // برای حفظ ترتیب رندر
|
|
},
|
|
obj,
|
|
index,
|
|
)}
|
|
/>
|
|
);
|
|
|
|
case 'video':
|
|
return (
|
|
<video
|
|
key={obj.id || index}
|
|
src={obj.videoUrl || ''}
|
|
controls
|
|
style={applyStyle(
|
|
{
|
|
...baseStyle,
|
|
width: obj.width ? `${obj.width * scale}px` : 'auto',
|
|
height: obj.height ? `${obj.height * scale}px` : 'auto',
|
|
objectFit: 'contain',
|
|
zIndex: index,
|
|
pointerEvents: 'auto',
|
|
},
|
|
obj,
|
|
index,
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
/>
|
|
);
|
|
|
|
case 'audio':
|
|
return (
|
|
<audio
|
|
key={obj.id || index}
|
|
src={obj.audioUrl || ''}
|
|
controls
|
|
preload="metadata"
|
|
style={applyStyle(
|
|
{
|
|
...baseStyle,
|
|
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
|
|
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
|
|
zIndex: index,
|
|
pointerEvents: 'auto',
|
|
},
|
|
obj,
|
|
index,
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
/>
|
|
);
|
|
|
|
case 'link': {
|
|
const isInternalLink = obj.linkUrl?.startsWith('page://');
|
|
const linkStyle = {
|
|
...baseStyle,
|
|
width: obj.width ? `${obj.width * scale}px` : 'auto',
|
|
height: obj.height ? `${obj.height * scale}px` : 'auto',
|
|
color: obj.fill || '#3b82f6',
|
|
fontSize: `${(obj.fontSize || 24) * scale}px`,
|
|
fontFamily: getFontFamily(obj.fontFamily),
|
|
fontWeight: getCssFontWeight(obj.fontWeight),
|
|
textDecoration: 'underline',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
whiteSpace: 'pre-wrap' as const,
|
|
cursor: 'pointer',
|
|
};
|
|
|
|
if (isInternalLink) {
|
|
// لینک داخلی - از button با onClick استفاده میکنیم
|
|
return (
|
|
<button
|
|
key={obj.id || index}
|
|
type="button"
|
|
style={applyStyle(
|
|
{
|
|
...linkStyle,
|
|
zIndex: 9999,
|
|
pointerEvents: 'auto',
|
|
border: 'none',
|
|
background: 'none',
|
|
padding: 0,
|
|
cursor: 'pointer',
|
|
},
|
|
obj,
|
|
index,
|
|
)}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (obj.linkUrl && onLinkClick) {
|
|
onLinkClick(obj.linkUrl);
|
|
}
|
|
}}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
onTouchStart={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
{obj.text || obj.linkUrl || ''}
|
|
</button>
|
|
);
|
|
} else {
|
|
// لینک خارجی - از <a> با target="_blank" استفاده میکنیم
|
|
return (
|
|
<a
|
|
key={obj.id || index}
|
|
href={obj.linkUrl || '#'}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={applyStyle(
|
|
{
|
|
...linkStyle,
|
|
zIndex: 9999,
|
|
pointerEvents: 'auto',
|
|
},
|
|
obj,
|
|
index,
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
{obj.text || obj.linkUrl || ''}
|
|
</a>
|
|
);
|
|
}
|
|
}
|
|
|
|
case 'rectangle': {
|
|
if (obj.shapeType === 'circle') {
|
|
// در editor، circle با مرکز در x, y رندر میشود (radius = width/2)
|
|
// در dataTransformer از گوشه بالا-چپ به مرکز تبدیل شده است
|
|
const radius = ((obj.width || 100) / 2) * scale;
|
|
const centerX = (obj.x || 0) * scale;
|
|
const centerY = (obj.y || 0) * scale;
|
|
|
|
return (
|
|
<div
|
|
key={obj.id || index}
|
|
style={applyStyle(
|
|
{
|
|
position: 'absolute',
|
|
left: `${centerX - radius}px`,
|
|
top: `${centerY - radius}px`,
|
|
width: `${radius * 2}px`,
|
|
height: `${radius * 2}px`,
|
|
borderRadius: '50%',
|
|
...objectFillStyle,
|
|
border: hasStroke && obj.stroke
|
|
? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
|
|
: 'none',
|
|
boxSizing: 'border-box',
|
|
opacity: (obj.opacity ?? 100) / 100,
|
|
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
|
|
transformOrigin: 'center center',
|
|
zIndex: index, // برای حفظ ترتیب رندر
|
|
},
|
|
obj,
|
|
index,
|
|
{ transformOrigin: 'center center' },
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (obj.shapeType === 'triangle') {
|
|
// در editor، triangle با مرکز در x + width/2, y + height/2 رندر میشود
|
|
// RegularPolygon در Konva یک مثلث متساویالاضلاع است با radius از مرکز تا رأس
|
|
// radius باید بر اساس ابعاد اصلی (قبل از scale) محاسبه شود
|
|
const baseWidth = obj.width || 100;
|
|
const baseHeight = obj.height || 100;
|
|
const baseRadius = Math.min(baseWidth, baseHeight) / 2;
|
|
const radius = baseRadius * scale;
|
|
|
|
// محاسبه مرکز مثلث (مثل editor)
|
|
const centerX = ((obj.x || 0) + baseWidth / 2) * scale;
|
|
const centerY = ((obj.y || 0) + baseHeight / 2) * scale;
|
|
|
|
// برای مثلث متساویالاضلاع، size = radius * 2
|
|
const size = radius * 2;
|
|
const halfSize = size / 2;
|
|
|
|
// نقاط مثلث متساویالاضلاع: رأس بالا (50%, 0%), پایین چپ (0%, 100%), پایین راست (100%, 100%)
|
|
// اما برای مثلث متساویالاضلاع واقعی، باید ارتفاع را محاسبه کنیم
|
|
// ارتفاع مثلث متساویالاضلاع = size * sqrt(3) / 2 ≈ size * 0.866
|
|
const triangleHeight = size * 0.8660254; // sqrt(3) / 2
|
|
const topY = (size - triangleHeight) / 2;
|
|
const bottomY = size - topY;
|
|
const halfWidth = triangleHeight * 0.5773503; // tan(30°) = 1/sqrt(3)
|
|
|
|
const topX = halfSize;
|
|
const bottomLeftX = halfSize - halfWidth;
|
|
const bottomRightX = halfSize + halfWidth;
|
|
|
|
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}`;
|
|
|
|
return (
|
|
<svg
|
|
key={obj.id || index}
|
|
style={applyStyle(
|
|
{
|
|
position: 'absolute',
|
|
left: `${centerX - radius}px`,
|
|
top: `${centerY - radius}px`,
|
|
width: `${size}px`,
|
|
height: `${size}px`,
|
|
opacity: (obj.opacity ?? 100) / 100,
|
|
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
|
|
transformOrigin: 'center center',
|
|
zIndex: index,
|
|
overflow: 'visible',
|
|
},
|
|
obj,
|
|
index,
|
|
{ transformOrigin: 'center center' },
|
|
)}
|
|
viewBox={`0 0 ${size} ${size}`}
|
|
>
|
|
{obj.fillType === 'gradient' && obj.gradient ? (
|
|
<defs>
|
|
<linearGradient
|
|
id={gradientId}
|
|
gradientUnits="userSpaceOnUse"
|
|
x1="0"
|
|
y1="0"
|
|
x2={size}
|
|
y2={size}
|
|
gradientTransform={`rotate(${obj.gradient.angle}, ${size / 2}, ${size / 2})`}
|
|
>
|
|
<stop offset="0%" stopColor={obj.gradient.from} />
|
|
<stop offset="100%" stopColor={obj.gradient.to} />
|
|
</linearGradient>
|
|
</defs>
|
|
) : null}
|
|
<polygon
|
|
points={`${topX},${topY} ${bottomLeftX},${bottomY} ${bottomRightX},${bottomY}`}
|
|
fill={obj.fillType === 'gradient' && obj.gradient ? `url(#${gradientId})` : fillColor}
|
|
stroke={strokeColor}
|
|
strokeWidth={strokeWidth}
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
if (obj.shapeType === 'abstract') {
|
|
// در editor، abstract (star) با مرکز در x + width/2, y + height/2 رندر میشود
|
|
const width = (obj.width || 100) * scale;
|
|
const height = (obj.height || 100) * scale;
|
|
const baseRadius = Math.min(width, height) / 2;
|
|
const abstractScale = 0.85;
|
|
const radius = baseRadius * abstractScale;
|
|
const centerX = (obj.x || 0) * scale + width / 2;
|
|
const centerY = (obj.y || 0) * scale + height / 2;
|
|
|
|
return (
|
|
<div
|
|
key={obj.id || index}
|
|
style={applyStyle(
|
|
{
|
|
position: 'absolute',
|
|
left: `${centerX - radius}px`,
|
|
top: `${centerY - radius}px`,
|
|
width: `${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%)',
|
|
...objectFillStyle,
|
|
border: obj.stroke
|
|
? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}`
|
|
: 'none',
|
|
boxSizing: 'border-box',
|
|
opacity: (obj.opacity ?? 100) / 100,
|
|
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
|
|
transformOrigin: 'center center',
|
|
},
|
|
obj,
|
|
index,
|
|
{ transformOrigin: 'center center' },
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Rectangle عادی
|
|
const shapeStyle: React.CSSProperties = {
|
|
...baseStyle,
|
|
width: `${(obj.width || 100) * scale}px`,
|
|
height: `${(obj.height || 100) * scale}px`,
|
|
...objectFillStyle,
|
|
borderRadius: `${Math.max(0, (obj.borderRadius || 0) * scale)}px`,
|
|
border: hasStroke && obj.stroke
|
|
? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
|
|
: 'none',
|
|
boxSizing: 'border-box',
|
|
zIndex: index, // برای حفظ ترتیب رندر
|
|
};
|
|
|
|
return <div key={obj.id || index} style={applyStyle(shapeStyle, obj, index)} />;
|
|
}
|
|
|
|
case 'line': {
|
|
// در EditorObject، width و height به عنوان مختصات انتهایی استفاده میشوند
|
|
// در Konva Line، points به صورت [0, 0, endX - startX, endY - startY] است
|
|
const startX = (obj.x || 0) * scale;
|
|
const startY = (obj.y || 0) * scale;
|
|
// width و height مستقیماً مختصات نقطه پایان هستند
|
|
const endX = (obj.width ?? obj.x ?? 0) * scale;
|
|
const endY = (obj.height ?? obj.y ?? 0) * scale;
|
|
const dx = endX - startX;
|
|
const dy = endY - startY;
|
|
const lineLength = Math.sqrt(dx * dx + dy * dy);
|
|
const lineAngle = Math.atan2(dy, dx) * (180 / Math.PI);
|
|
|
|
return (
|
|
<div
|
|
key={obj.id || index}
|
|
style={applyStyle(
|
|
{
|
|
position: 'absolute',
|
|
left: `${startX}px`,
|
|
top: `${startY}px`,
|
|
width: `${lineLength}px`,
|
|
height: `${(obj.strokeWidth || 2) * scale}px`,
|
|
backgroundColor: obj.stroke || '#000000',
|
|
opacity: (obj.opacity ?? 100) / 100,
|
|
transform: `rotate(${lineAngle}deg)`,
|
|
transformOrigin: 'left center',
|
|
},
|
|
obj,
|
|
index,
|
|
{ rotationDeg: lineAngle, transformOrigin: 'left center' },
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
case 'arrow': {
|
|
// در EditorObject، width و height به عنوان مختصات انتهایی استفاده میشوند
|
|
// در Konva Arrow، points به صورت [0, 0, endX - startX, endY - startY] است
|
|
const startX = (obj.x || 0) * scale;
|
|
const startY = (obj.y || 0) * scale;
|
|
// width و height مستقیماً مختصات نقطه پایان هستند
|
|
const endX = (obj.width ?? obj.x ?? 0) * scale;
|
|
const endY = (obj.height ?? obj.y ?? 0) * scale;
|
|
const dx = endX - startX;
|
|
const dy = endY - startY;
|
|
const arrowLength = Math.sqrt(dx * dx + dy * dy);
|
|
const arrowAngle = Math.atan2(dy, dx) * (180 / Math.PI);
|
|
const strokeWidth = (obj.strokeWidth || 4) * scale;
|
|
|
|
return (
|
|
<div
|
|
key={obj.id || index}
|
|
style={applyStyle(
|
|
{
|
|
position: 'absolute',
|
|
left: `${startX}px`,
|
|
top: `${startY}px`,
|
|
width: `${arrowLength}px`,
|
|
height: `${strokeWidth}px`,
|
|
backgroundColor: obj.stroke || '#000000',
|
|
opacity: (obj.opacity ?? 100) / 100,
|
|
transform: `rotate(${arrowAngle}deg)`,
|
|
transformOrigin: 'left center',
|
|
},
|
|
obj,
|
|
index,
|
|
{ rotationDeg: arrowAngle, transformOrigin: 'left center' },
|
|
)}
|
|
>
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
right: `-${strokeWidth * 2}px`,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
width: 0,
|
|
height: 0,
|
|
borderTop: `${strokeWidth * 2}px solid transparent`,
|
|
borderBottom: `${strokeWidth * 2}px solid transparent`,
|
|
borderLeft: `${strokeWidth * 2}px solid ${obj.stroke || '#000000'}`,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'custom-shape': {
|
|
const rawPoints = obj.points ?? [];
|
|
const isClosed = obj.closed ?? true;
|
|
const minLength = isClosed ? 6 : 4;
|
|
if (rawPoints.length < minLength) return null;
|
|
|
|
const width = (obj.width || 100) * scale;
|
|
const height = (obj.height || 100) * scale;
|
|
const pointsStr = Array.from(
|
|
{ length: rawPoints.length / 2 },
|
|
(_, i) => `${rawPoints[i * 2] * scale},${rawPoints[i * 2 + 1] * scale}`,
|
|
).join(' ');
|
|
|
|
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}`;
|
|
|
|
return (
|
|
<svg
|
|
key={obj.id || index}
|
|
style={applyStyle(
|
|
{
|
|
position: 'absolute',
|
|
left: `${(obj.x || 0) * scale}px`,
|
|
top: `${(obj.y || 0) * scale}px`,
|
|
width: `${width}px`,
|
|
height: `${height}px`,
|
|
opacity: (obj.opacity ?? 100) / 100,
|
|
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
|
|
transformOrigin: 'top left',
|
|
zIndex: index,
|
|
overflow: 'visible',
|
|
},
|
|
obj,
|
|
index,
|
|
)}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
>
|
|
{obj.fillType === 'gradient' && obj.gradient ? (
|
|
<defs>
|
|
<linearGradient
|
|
id={gradientId}
|
|
gradientUnits="userSpaceOnUse"
|
|
x1="0"
|
|
y1="0"
|
|
x2={width}
|
|
y2={height}
|
|
gradientTransform={`rotate(${obj.gradient.angle}, ${width / 2}, ${height / 2})`}
|
|
>
|
|
<stop offset="0%" stopColor={obj.gradient.from} />
|
|
<stop offset="100%" stopColor={obj.gradient.to} />
|
|
</linearGradient>
|
|
</defs>
|
|
) : null}
|
|
{isClosed ? (
|
|
<polygon
|
|
points={pointsStr}
|
|
fill={obj.fillType === 'gradient' && obj.gradient ? `url(#${gradientId})` : fillColor}
|
|
stroke={strokeColor}
|
|
strokeWidth={strokeWidth}
|
|
/>
|
|
) : (
|
|
<polyline
|
|
points={pointsStr}
|
|
fill="none"
|
|
stroke={strokeColor}
|
|
strokeWidth={strokeWidth}
|
|
/>
|
|
)}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
case 'grid': {
|
|
if (!obj.tableData) return null;
|
|
|
|
const { rows, cols, cells, cellWidth, cellHeight, stroke, strokeWidth } =
|
|
obj.tableData;
|
|
|
|
return (
|
|
<div
|
|
key={obj.id || index}
|
|
style={applyStyle(
|
|
{
|
|
...baseStyle,
|
|
display: 'grid',
|
|
gridTemplateRows: `repeat(${rows}, ${cellHeight * scale}px)`,
|
|
gridTemplateColumns: `repeat(${cols}, ${cellWidth * scale}px)`,
|
|
border: stroke ? `${(strokeWidth || 1) * scale}px solid ${stroke}` : 'none',
|
|
},
|
|
obj,
|
|
index,
|
|
)}
|
|
>
|
|
{Array.from({ length: rows }).map((_, rowIndex) =>
|
|
Array.from({ length: cols }).map((_, colIndex) => {
|
|
const cellId = `cell-${rowIndex}-${colIndex}`;
|
|
const cell = cells[cellId];
|
|
|
|
return (
|
|
<div
|
|
key={cellId}
|
|
style={{
|
|
border: stroke ? `${(strokeWidth || 1) * scale}px solid ${stroke}` : `${1 * scale}px solid #ccc`,
|
|
backgroundColor: cell?.background || '#ffffff',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
padding: `${4 * scale}px`,
|
|
fontSize: `${14 * scale}px`,
|
|
}}
|
|
>
|
|
{cell?.text || ''}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const renderObject = (obj: EditorObject, index: number, allElements: EditorObject[]) => {
|
|
if (obj.isMask) return null;
|
|
|
|
const maskShape = obj.maskId
|
|
? allElements.find((m) => m.id === obj.maskId)
|
|
: null;
|
|
|
|
if (!maskShape) {
|
|
return renderObjectContent(obj, index);
|
|
}
|
|
|
|
const layout = getMaskedLayout(obj, maskShape, scale);
|
|
const unionLeft = layout.left / scale;
|
|
const unionTop = layout.top / scale;
|
|
|
|
const content = renderObjectContent(
|
|
{ ...obj, x: (obj.x || 0) - unionLeft, y: (obj.y || 0) - unionTop },
|
|
index,
|
|
{ skipEntrance: true },
|
|
);
|
|
|
|
if (!content) return null;
|
|
|
|
const wrapperStyle: React.CSSProperties = {
|
|
position: 'absolute',
|
|
left: `${layout.left}px`,
|
|
top: `${layout.top}px`,
|
|
width: `${layout.width}px`,
|
|
height: `${layout.height}px`,
|
|
overflow: 'visible',
|
|
zIndex: index,
|
|
...getMaskImageStyle(obj, maskShape, scale, layout),
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={`masked-${obj.id || index}`}
|
|
style={withEntrance(wrapperStyle, obj, index)}
|
|
>
|
|
{content}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const width = pageWidth ?? 794 * scale;
|
|
const height = pageHeight ?? 1123 * scale;
|
|
|
|
const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color';
|
|
const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff';
|
|
const effectiveBackgroundGradient = backgroundGradient ?? page.backgroundGradient;
|
|
const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? '';
|
|
|
|
const bgStyle: React.CSSProperties =
|
|
effectiveBackgroundType === 'image' && effectiveBackgroundImageUrl
|
|
? {
|
|
backgroundImage: `url(${effectiveBackgroundImageUrl})`,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
backgroundRepeat: 'no-repeat',
|
|
}
|
|
: effectiveBackgroundType === 'gradient' && effectiveBackgroundGradient
|
|
? {
|
|
backgroundImage: toCssLinearGradient(effectiveBackgroundGradient),
|
|
}
|
|
: { backgroundColor: effectiveBackgroundColor };
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
dir="rtl"
|
|
className="page"
|
|
data-density={useHardPage ? 'hard' : 'soft'}
|
|
style={{
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
boxSizing: 'border-box',
|
|
width: `${width}px`,
|
|
height: `${height}px`,
|
|
zIndex: 1,
|
|
...(useHardPage
|
|
? { backgroundColor: effectiveBackgroundColor, ...bgStyle }
|
|
: {}),
|
|
}}
|
|
>
|
|
{!useHardPage && (
|
|
<div
|
|
aria-hidden
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
...bgStyle,
|
|
pointerEvents: 'none',
|
|
zIndex: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
|
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
|
|
</div>
|
|
{useHardPage && showPaperShadow && <div aria-hidden className="page-paper-shadow" />}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
BookPage.displayName = 'BookPage';
|
|
|
|
export default BookPage;
|
|
|