mask in viewer
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user