mask in viewer

This commit is contained in:
hamid zarghami
2026-06-13 10:24:16 +03:30
parent ef4ddeb3fa
commit 5174d0371d
3 changed files with 331 additions and 15 deletions
+81 -15
View File
@@ -10,8 +10,13 @@ import {
} 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;
@@ -94,7 +99,24 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
flyLayoutPx,
});
const renderObject = (obj: EditorObject, index: number) => {
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 = {
@@ -146,7 +168,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return (
<div
key={obj.id || index}
style={withEntrance(
style={applyStyle(
{
...textBaseStyle,
left: `${textLayout.leftPx}px`,
@@ -182,7 +204,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
key={obj.id || index}
src={obj.imageUrl || ''}
alt=""
style={withEntrance(
style={applyStyle(
{
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : 'auto',
@@ -202,7 +224,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
key={obj.id || index}
src={obj.videoUrl || ''}
controls
style={withEntrance(
style={applyStyle(
{
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : 'auto',
@@ -233,7 +255,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
src={obj.audioUrl || ''}
controls
preload="metadata"
style={withEntrance(
style={applyStyle(
{
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
@@ -279,7 +301,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
<button
key={obj.id || index}
type="button"
style={withEntrance(
style={applyStyle(
{
...linkStyle,
zIndex: 9999,
@@ -317,7 +339,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
href={obj.linkUrl || '#'}
target="_blank"
rel="noopener noreferrer"
style={withEntrance(
style={applyStyle(
{
...linkStyle,
zIndex: 9999,
@@ -350,7 +372,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return (
<div
key={obj.id || index}
style={withEntrance(
style={applyStyle(
{
position: 'absolute',
left: `${centerX - radius}px`,
@@ -413,7 +435,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return (
<svg
key={obj.id || index}
style={withEntrance(
style={applyStyle(
{
position: 'absolute',
left: `${centerX - radius}px`,
@@ -471,7 +493,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return (
<div
key={obj.id || index}
style={withEntrance(
style={applyStyle(
{
position: 'absolute',
left: `${centerX - radius}px`,
@@ -510,7 +532,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
zIndex: index, // برای حفظ ترتیب رندر
};
return <div key={obj.id || index} style={withEntrance(shapeStyle, obj, index)} />;
return <div key={obj.id || index} style={applyStyle(shapeStyle, obj, index)} />;
}
case 'line': {
@@ -529,7 +551,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return (
<div
key={obj.id || index}
style={withEntrance(
style={applyStyle(
{
position: 'absolute',
left: `${startX}px`,
@@ -566,7 +588,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return (
<div
key={obj.id || index}
style={withEntrance(
style={applyStyle(
{
position: 'absolute',
left: `${startX}px`,
@@ -609,7 +631,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return (
<div
key={obj.id || index}
style={withEntrance(
style={applyStyle(
{
...baseStyle,
display: 'grid',
@@ -653,6 +675,50 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
}
};
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;
@@ -706,7 +772,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
/>
)}
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
{page.elements.map((element, index) => renderObject(element, index))}
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
</div>
{useHardPage && showPaperShadow && <div aria-hidden className="page-paper-shadow" />}
</div>
+13
View File
@@ -47,6 +47,9 @@ type ViewerDataPage = {
entranceAnimation?: EditorObject["entranceAnimation"];
entranceDurationMs?: number;
entranceDelayMs?: number;
isMask?: boolean;
maskId?: string;
maskInvert?: boolean;
}>;
};
@@ -152,6 +155,16 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
baseObject.entranceDelayMs = obj.entranceDelayMs;
}
if (obj.isMask !== undefined) {
baseObject.isMask = obj.isMask;
}
if (obj.maskId !== undefined) {
baseObject.maskId = obj.maskId;
}
if (obj.maskInvert !== undefined) {
baseObject.maskInvert = obj.maskInvert;
}
return baseObject;
});
+237
View File
@@ -0,0 +1,237 @@
import type { CSSProperties } from 'react';
import type { EditorObject } from '@/pages/editor/store/editorStore';
type Bounds = {
left: number;
top: number;
right: number;
bottom: number;
};
export type MaskedLayout = {
left: number;
top: number;
width: number;
height: number;
maskRelX: number;
maskRelY: number;
};
const encodeSvg = (svg: string) =>
`url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
const getObjectBounds = (obj: EditorObject): Bounds => {
if (obj.type === 'rectangle' && obj.shapeType === 'circle') {
const radius = (obj.width || 100) / 2;
const cx = obj.x || 0;
const cy = obj.y || 0;
return {
left: cx - radius,
top: cy - radius,
right: cx + radius,
bottom: cy + radius,
};
}
if (obj.type === 'rectangle' && (obj.shapeType === 'triangle' || obj.shapeType === 'abstract')) {
const width = obj.width || 100;
const height = obj.height || 100;
const radius = Math.min(width, height) / 2;
const cx = (obj.x || 0) + width / 2;
const cy = (obj.y || 0) + height / 2;
return {
left: cx - radius,
top: cy - radius,
right: cx + radius,
bottom: cy + radius,
};
}
return {
left: obj.x || 0,
top: obj.y || 0,
right: (obj.x || 0) + (obj.width || 100),
bottom: (obj.y || 0) + (obj.height || 100),
};
};
const getMaskBounds = (mask: EditorObject): Bounds => {
if (mask.type === 'rectangle' && mask.shapeType === 'circle') {
const radius = (mask.width || 100) / 2;
const cx = mask.x || 0;
const cy = mask.y || 0;
return {
left: cx - radius,
top: cy - radius,
right: cx + radius,
bottom: cy + radius,
};
}
if (mask.type === 'rectangle' && mask.shapeType === 'triangle') {
const width = mask.width || 100;
const height = mask.height || 100;
const radius = Math.min(width, height) / 2;
const cx = (mask.x || 0) + width / 2;
const cy = (mask.y || 0) + height / 2;
return {
left: cx - radius,
top: cy - radius,
right: cx + radius,
bottom: cy + radius,
};
}
if (mask.type === 'rectangle' && mask.shapeType === 'abstract') {
const width = mask.width || 100;
const height = mask.height || 100;
const baseRadius = Math.min(width, height) / 2;
const radius = baseRadius * 0.85;
const cx = (mask.x || 0) + width / 2;
const cy = (mask.y || 0) + height / 2;
return {
left: cx - radius,
top: cy - radius,
right: cx + radius,
bottom: cy + radius,
};
}
return {
left: mask.x || 0,
top: mask.y || 0,
right: (mask.x || 0) + (mask.width || 100),
bottom: (mask.y || 0) + (mask.height || 100),
};
};
export const getMaskedLayout = (
obj: EditorObject,
mask: EditorObject,
scale: number,
): MaskedLayout => {
const objBounds = getObjectBounds(obj);
const maskBounds = getMaskBounds(mask);
const unionLeft = Math.min(objBounds.left, maskBounds.left);
const unionTop = Math.min(objBounds.top, maskBounds.top);
const unionRight = Math.max(objBounds.right, maskBounds.right);
const unionBottom = Math.max(objBounds.bottom, maskBounds.bottom);
let maskRelX: number;
let maskRelY: number;
if (mask.type === 'rectangle' && mask.shapeType === 'circle') {
maskRelX = (mask.x || 0) - unionLeft;
maskRelY = (mask.y || 0) - unionTop;
} else if (
mask.type === 'rectangle' &&
(mask.shapeType === 'triangle' || mask.shapeType === 'abstract')
) {
maskRelX = (mask.x || 0) - unionLeft;
maskRelY = (mask.y || 0) - unionTop;
} else {
maskRelX = (mask.x || 0) - unionLeft;
maskRelY = (mask.y || 0) - unionTop;
}
return {
left: unionLeft * scale,
top: unionTop * scale,
width: Math.max(1, (unionRight - unionLeft) * scale),
height: Math.max(1, (unionBottom - unionTop) * scale),
maskRelX: maskRelX * scale,
maskRelY: maskRelY * scale,
};
};
const renderMaskShapeSvg = (
mask: EditorObject,
relX: number,
relY: number,
scale: number,
fill: string,
): string => {
const width = (mask.width || 100) * scale;
const height = (mask.height || 100) * scale;
const rotation = mask.rotation || 0;
if (mask.type === 'rectangle' && mask.shapeType === 'circle') {
const radius = width / 2;
return `<circle cx="${relX}" cy="${relY}" r="${radius}" fill="${fill}" transform="rotate(${rotation} ${relX} ${relY})" />`;
}
if (mask.type === 'rectangle' && mask.shapeType === 'triangle') {
const radius = Math.min(width, height) / 2;
const cx = relX + width / 2;
const cy = relY + height / 2;
const size = radius * 2;
const triangleHeight = size * 0.8660254;
const topY = (size - triangleHeight) / 2;
const bottomY = size - topY;
const halfWidth = triangleHeight * 0.5773503;
const topX = size / 2;
const bottomLeftX = size / 2 - halfWidth;
const bottomRightX = size / 2 + halfWidth;
const offsetX = cx - radius;
const offsetY = cy - radius;
return `<polygon points="${topX + offsetX},${topY + offsetY} ${bottomLeftX + offsetX},${bottomY + offsetY} ${bottomRightX + offsetX},${bottomY + offsetY}" fill="${fill}" transform="rotate(${rotation} ${cx} ${cy})" />`;
}
if (mask.type === 'rectangle' && mask.shapeType === 'abstract') {
const baseRadius = Math.min(width, height) / 2;
const radius = baseRadius * 0.85;
const cx = relX + width / 2;
const cy = relY + height / 2;
const left = cx - radius;
const top = cy - radius;
const size = radius * 2;
return `<polygon points="${left + size * 0.5},${top} ${left + size * 0.61},${top + size * 0.35} ${left + size * 0.98},${top + size * 0.35} ${left + size * 0.68},${top + size * 0.57} ${left + size * 0.79},${top + size * 0.91} ${left + size * 0.5},${top + size * 0.7} ${left + size * 0.21},${top + size * 0.91} ${left + size * 0.32},${top + size * 0.57} ${left + size * 0.02},${top + size * 0.35} ${left + size * 0.39},${top + size * 0.35}" fill="${fill}" transform="rotate(${rotation} ${cx} ${cy})" />`;
}
const cornerRadius = Math.max(0, (mask.borderRadius ?? 0) * scale);
const rectX = relX;
const rectY = relY;
const pivotX = rectX;
const pivotY = rectY;
return `<rect x="${rectX}" y="${rectY}" width="${width}" height="${height}" rx="${cornerRadius}" ry="${cornerRadius}" fill="${fill}" transform="rotate(${rotation} ${pivotX} ${pivotY})" />`;
};
/**
* Builds CSS mask properties matching editor Konva destination-in / destination-out.
* maskInvert === false → destination-in (show overlap only)
* otherwise → destination-out (hide overlap)
*/
export const getMaskImageStyle = (
obj: EditorObject,
mask: EditorObject,
scale: number,
layout: MaskedLayout,
): CSSProperties => {
const canvasW = layout.width;
const canvasH = layout.height;
const showOverlapOnly = obj.maskInvert === false;
const bgFill = showOverlapOnly ? 'black' : 'white';
const shapeFill = showOverlapOnly ? 'white' : 'black';
const maskShapeX = layout.maskRelX;
const maskShapeY = layout.maskRelY;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${canvasW} ${canvasH}"><rect width="100%" height="100%" fill="${bgFill}"/>${renderMaskShapeSvg(mask, maskShapeX, maskShapeY, scale, shapeFill)}</svg>`;
const maskUrl = encodeSvg(svg);
return {
WebkitMaskImage: maskUrl,
maskImage: maskUrl,
WebkitMaskSize: `${canvasW}px ${canvasH}px`,
maskSize: `${canvasW}px ${canvasH}px`,
WebkitMaskPosition: '0 0',
maskPosition: '0 0',
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
maskMode: 'luminance',
// Safari needs luminance mode for white/black SVG fills
['WebkitMaskMode' as string]: 'luminance',
};
};