base animation

This commit is contained in:
hamid zarghami
2026-05-12 15:19:44 +03:30
parent 8cfe559fb6
commit 6a8eaab8cb
13 changed files with 548 additions and 122 deletions
@@ -73,6 +73,7 @@ const CatalogPreview: FC<Props> = ({ item }) => {
scale={1} scale={1}
pageWidth={pageWidth} pageWidth={pageWidth}
pageHeight={pageHeight} pageHeight={pageHeight}
disableEntranceAnimations
/> />
</div> </div>
</div> </div>
@@ -11,6 +11,7 @@ import {
TransformSettings, TransformSettings,
GridSettings, GridSettings,
MaskSettings, MaskSettings,
EntranceAnimationSettings,
} from "./settings"; } from "./settings";
import TextInstruction from "./instructions/TextInstruction"; import TextInstruction from "./instructions/TextInstruction";
@@ -117,6 +118,10 @@ const ObjectSettings = ({
<GridSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} /> <GridSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
)} )}
<EntranceAnimationSettings
selectedObject={roundedSelectedObject}
onUpdate={handleRoundedUpdate}
/>
<TransformSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} /> <TransformSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
</div> </div>
@@ -0,0 +1,12 @@
import type { ItemsSelectType } from "@/components/Select";
export const ENTRANCE_ANIMATION_SELECT_ITEMS: ItemsSelectType[] = [
{ value: "none", label: "بدون انیمیشن" },
{ value: "fade", label: "محو شدن (Fade)" },
{ value: "flyInLeft", label: "ورود از چپ" },
{ value: "flyInRight", label: "ورود از راست" },
{ value: "flyInTop", label: "ورود از بالا" },
{ value: "flyInBottom", label: "ورود از پایین" },
{ value: "zoomIn", label: "بزرگ‌نمایی (Zoom in)" },
{ value: "zoomOut", label: "ریزنمایی (Zoom out)" },
];
@@ -0,0 +1,50 @@
import Select from "@/components/Select";
import Input from "@/components/Input";
import type { EditorObject } from "../../../store/editorStore";
import { ENTRANCE_ANIMATION_SELECT_ITEMS } from "../constants/entranceAnimationOptions";
import { DEFAULT_ENTRANCE_DURATION_MS } from "@/shared/entranceAnimation";
type Props = {
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
};
const EntranceAnimationSettings = ({ selectedObject, onUpdate }: Props) => {
const anim = selectedObject.entranceAnimation ?? "none";
const duration = selectedObject.entranceDurationMs ?? DEFAULT_ENTRANCE_DURATION_MS;
return (
<div className="pt-4 flex flex-col gap-4 border-t border-border">
<Select
label="انیمیشن هنگام نمایش"
items={ENTRANCE_ANIMATION_SELECT_ITEMS}
value={anim}
onChange={(e) => {
const v = e.target.value as EditorObject["entranceAnimation"];
onUpdate(selectedObject.id, {
entranceAnimation: v === "none" ? undefined : v,
...(v === "none" || !v ? { entranceDurationMs: undefined } : {}),
});
}}
/>
{anim !== "none" && (
<Input
label="مدت انیمیشن (میلی‌ثانیه)"
type="number"
min={100}
max={4000}
value={duration}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (Number.isNaN(n)) return;
onUpdate(selectedObject.id, {
entranceDurationMs: Math.min(4000, Math.max(100, n)),
});
}}
/>
)}
</div>
);
};
export default EntranceAnimationSettings;
@@ -8,4 +8,5 @@ export { default as AlignmentSettings } from "./AlignmentSettings";
export { default as TransformSettings } from "./TransformSettings"; export { default as TransformSettings } from "./TransformSettings";
export { default as GridSettings } from "./GridSettings"; export { default as GridSettings } from "./GridSettings";
export { default as MaskSettings } from "./MaskSettings"; export { default as MaskSettings } from "./MaskSettings";
export { default as EntranceAnimationSettings } from "./EntranceAnimationSettings";
+1
View File
@@ -42,6 +42,7 @@ export type {
DocumentSettings, DocumentSettings,
DisplayStyle, DisplayStyle,
BackgroundType, BackgroundType,
EntranceAnimationType,
} from "./editorStore.types"; } from "./editorStore.types";
type PageBackgroundSettings = Pick< type PageBackgroundSettings = Pick<
@@ -1,6 +1,17 @@
import type { ShapeType } from "./shapeStore"; import type { ShapeType } from "./shapeStore";
import type { FillType, LinearGradient } from "../utils/gradient"; import type { FillType, LinearGradient } from "../utils/gradient";
/** انیمیشن ورود (نمایش) شیء در viewer، مشابه PowerPoint */
export type EntranceAnimationType =
| "none"
| "fade"
| "flyInLeft"
| "flyInRight"
| "flyInTop"
| "flyInBottom"
| "zoomIn"
| "zoomOut";
export type ToolType = export type ToolType =
| "select" | "select"
| "rectangle" | "rectangle"
@@ -70,6 +81,10 @@ export type EditorObject = {
maskId?: string; maskId?: string;
maskInvert?: boolean; maskInvert?: boolean;
groupId?: string; groupId?: string;
/** نحوهٔ ظاهر شدن در نمایشگر؛ پیش‌فرض بدون انیمیشن */
entranceAnimation?: EntranceAnimationType;
/** مدت انیمیشن ورود به میلی‌ثانیه (پیش‌فرض ۶۰۰) */
entranceDurationMs?: number;
}; };
export type PageGuide = { export type PageGuide = {
+208 -122
View File
@@ -1,7 +1,10 @@
import { forwardRef } from 'react'; import { forwardRef, useCallback } 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 } from '@/pages/editor/utils/gradient'; import { toCssLinearGradient } from '@/pages/editor/utils/gradient';
import '@/pages/viewer/styles/entranceAnimations.css';
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
import { useBookPageEntranceSequence } from '@/pages/viewer/hooks/useBookPageEntranceSequence';
type BookPageProps = { type BookPageProps = {
page: PageData; page: PageData;
@@ -17,6 +20,8 @@ type BookPageProps = {
angle: number; angle: number;
}; };
backgroundImageUrl?: string; backgroundImageUrl?: string;
/** در پیش‌نمایش کاتالوگ انیمیشن ورود غیرفعال باشد */
disableEntranceAnimations?: boolean;
}; };
/** /**
@@ -27,7 +32,20 @@ type BookPageProps = {
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند * نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
*/ */
const BookPage = forwardRef<HTMLDivElement, BookPageProps>( const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl }, ref) => { ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, disableEntranceAnimations = false }, ref) => {
const { attachRootRef, entranceSeq } = useBookPageEntranceSequence(disableEntranceAnimations);
const setHostRef = useCallback(
(node: HTMLDivElement | null) => {
attachRootRef(node);
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}
},
[ref, attachRootRef],
);
// تابع برای تبدیل opacity به رنگ (مثل editor) // تابع برای تبدیل opacity به رنگ (مثل editor)
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد // در editor، getColorWithOpacity انتظار opacity 0-100 دارد
// در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است // در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است
@@ -57,6 +75,25 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return `rgba(${r}, ${g}, ${b}, ${alpha})`; 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, entranceSeq, {
...extra,
flyLayoutPx,
});
const renderObject = (obj: EditorObject, index: number) => { const renderObject = (obj: EditorObject, index: number) => {
const actualStrokeWidth = obj.strokeWidth ?? 0; const actualStrokeWidth = obj.strokeWidth ?? 0;
const hasStroke = actualStrokeWidth > 0; const hasStroke = actualStrokeWidth > 0;
@@ -90,21 +127,25 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return ( return (
<div <div
key={obj.id || index} key={obj.id || index}
style={{ style={withEntrance(
...textBaseStyle, {
left: `${textLeft}px`, ...textBaseStyle,
width: textWidth ? `${textWidth}px` : undefined, left: `${textLeft}px`,
fontSize: `${(obj.fontSize || 16) * scale}px`, width: textWidth ? `${textWidth}px` : undefined,
fontFamily: obj.fontFamily || 'irancell, sans-serif', fontSize: `${(obj.fontSize || 16) * scale}px`,
fontWeight: obj.fontWeight || 'normal', fontFamily: obj.fontFamily || 'irancell, sans-serif',
lineHeight: obj.lineHeight ?? 1.2, fontWeight: obj.fontWeight || 'normal',
textAlign: obj.textAlign ?? 'right', lineHeight: obj.lineHeight ?? 1.2,
direction: 'rtl', textAlign: obj.textAlign ?? 'right',
color: getColorWithOpacity(obj.fill, obj.opacity), direction: 'rtl',
whiteSpace: 'pre-wrap', color: getColorWithOpacity(obj.fill, obj.opacity),
wordBreak: 'break-word', whiteSpace: 'pre-wrap',
letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined, wordBreak: 'break-word',
}} letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined,
},
obj,
index,
)}
> >
{obj.text || ''} {obj.text || ''}
</div> </div>
@@ -117,13 +158,17 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
key={obj.id || index} key={obj.id || index}
src={obj.imageUrl || ''} src={obj.imageUrl || ''}
alt="" alt=""
style={{ style={withEntrance(
...baseStyle, {
width: obj.width ? `${obj.width * scale}px` : 'auto', ...baseStyle,
height: obj.height ? `${obj.height * scale}px` : 'auto', width: obj.width ? `${obj.width * scale}px` : 'auto',
objectFit: 'fill', // مشابه Konva که image را با width و height مشخص شده رندر می‌کند height: obj.height ? `${obj.height * scale}px` : 'auto',
zIndex: index, // برای حفظ ترتیب رندر objectFit: 'fill', // مشابه Konva که image را با width و height مشخص شده رندر می‌کند
}} zIndex: index, // برای حفظ ترتیب رندر
},
obj,
index,
)}
/> />
); );
@@ -133,14 +178,18 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
key={obj.id || index} key={obj.id || index}
src={obj.videoUrl || ''} src={obj.videoUrl || ''}
controls controls
style={{ style={withEntrance(
...baseStyle, {
width: obj.width ? `${obj.width * scale}px` : 'auto', ...baseStyle,
height: obj.height ? `${obj.height * scale}px` : 'auto', width: obj.width ? `${obj.width * scale}px` : 'auto',
objectFit: 'contain', height: obj.height ? `${obj.height * scale}px` : 'auto',
zIndex: index, objectFit: 'contain',
pointerEvents: 'auto', zIndex: index,
}} pointerEvents: 'auto',
},
obj,
index,
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
@@ -175,15 +224,19 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
<button <button
key={obj.id || index} key={obj.id || index}
type="button" type="button"
style={{ style={withEntrance(
...linkStyle, {
zIndex: 9999, ...linkStyle,
pointerEvents: 'auto', zIndex: 9999,
border: 'none', pointerEvents: 'auto',
background: 'none', border: 'none',
padding: 0, background: 'none',
cursor: 'pointer', padding: 0,
}} cursor: 'pointer',
},
obj,
index,
)}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -209,11 +262,15 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
href={obj.linkUrl || '#'} href={obj.linkUrl || '#'}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ style={withEntrance(
...linkStyle, {
zIndex: 9999, ...linkStyle,
pointerEvents: 'auto', zIndex: 9999,
}} pointerEvents: 'auto',
},
obj,
index,
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
@@ -238,23 +295,28 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return ( return (
<div <div
key={obj.id || index} key={obj.id || index}
style={{ style={withEntrance(
position: 'absolute', {
left: `${centerX - radius}px`, position: 'absolute',
top: `${centerY - radius}px`, left: `${centerX - radius}px`,
width: `${radius * 2}px`, top: `${centerY - radius}px`,
height: `${radius * 2}px`, width: `${radius * 2}px`,
borderRadius: '50%', height: `${radius * 2}px`,
...objectFillStyle, borderRadius: '50%',
border: hasStroke && obj.stroke ...objectFillStyle,
? `${actualStrokeWidth * scale}px solid ${obj.stroke}` border: hasStroke && obj.stroke
: 'none', ? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
boxSizing: 'border-box', : 'none',
opacity: (obj.opacity ?? 100) / 100, boxSizing: 'border-box',
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined, opacity: (obj.opacity ?? 100) / 100,
transformOrigin: 'center center', transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
zIndex: index, // برای حفظ ترتیب رندر transformOrigin: 'center center',
}} zIndex: index, // برای حفظ ترتیب رندر
},
obj,
index,
{ transformOrigin: 'center center' },
)}
/> />
); );
} }
@@ -296,18 +358,23 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return ( return (
<svg <svg
key={obj.id || index} key={obj.id || index}
style={{ style={withEntrance(
position: 'absolute', {
left: `${centerX - radius}px`, position: 'absolute',
top: `${centerY - radius}px`, left: `${centerX - radius}px`,
width: `${size}px`, top: `${centerY - radius}px`,
height: `${size}px`, width: `${size}px`,
opacity: (obj.opacity ?? 100) / 100, height: `${size}px`,
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined, opacity: (obj.opacity ?? 100) / 100,
transformOrigin: 'center center', transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
zIndex: index, transformOrigin: 'center center',
overflow: 'visible', zIndex: index,
}} overflow: 'visible',
},
obj,
index,
{ transformOrigin: 'center center' },
)}
viewBox={`0 0 ${size} ${size}`} viewBox={`0 0 ${size} ${size}`}
> >
{obj.fillType === 'gradient' && obj.gradient ? ( {obj.fillType === 'gradient' && obj.gradient ? (
@@ -349,22 +416,27 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return ( return (
<div <div
key={obj.id || index} key={obj.id || index}
style={{ style={withEntrance(
position: 'absolute', {
left: `${centerX - radius}px`, position: 'absolute',
top: `${centerY - radius}px`, left: `${centerX - radius}px`,
width: `${radius * 2}px`, top: `${centerY - radius}px`,
height: `${radius * 2}px`, width: `${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%)', height: `${radius * 2}px`,
...objectFillStyle, clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)',
border: obj.stroke ...objectFillStyle,
? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}` border: obj.stroke
: 'none', ? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}`
boxSizing: 'border-box', : 'none',
opacity: (obj.opacity ?? 100) / 100, boxSizing: 'border-box',
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined, opacity: (obj.opacity ?? 100) / 100,
transformOrigin: 'center center', transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
}} transformOrigin: 'center center',
},
obj,
index,
{ transformOrigin: 'center center' },
)}
/> />
); );
} }
@@ -383,7 +455,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
zIndex: index, // برای حفظ ترتیب رندر zIndex: index, // برای حفظ ترتیب رندر
}; };
return <div key={obj.id || index} style={shapeStyle} />; return <div key={obj.id || index} style={withEntrance(shapeStyle, obj, index)} />;
} }
case 'line': { case 'line': {
@@ -402,17 +474,22 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return ( return (
<div <div
key={obj.id || index} key={obj.id || index}
style={{ style={withEntrance(
position: 'absolute', {
left: `${startX}px`, position: 'absolute',
top: `${startY}px`, left: `${startX}px`,
width: `${lineLength}px`, top: `${startY}px`,
height: `${(obj.strokeWidth || 2) * scale}px`, width: `${lineLength}px`,
backgroundColor: obj.stroke || '#000000', height: `${(obj.strokeWidth || 2) * scale}px`,
opacity: (obj.opacity ?? 100) / 100, backgroundColor: obj.stroke || '#000000',
transform: `rotate(${lineAngle}deg)`, opacity: (obj.opacity ?? 100) / 100,
transformOrigin: 'left center', transform: `rotate(${lineAngle}deg)`,
}} transformOrigin: 'left center',
},
obj,
index,
{ rotationDeg: lineAngle, transformOrigin: 'left center' },
)}
/> />
); );
} }
@@ -434,17 +511,22 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return ( return (
<div <div
key={obj.id || index} key={obj.id || index}
style={{ style={withEntrance(
position: 'absolute', {
left: `${startX}px`, position: 'absolute',
top: `${startY}px`, left: `${startX}px`,
width: `${arrowLength}px`, top: `${startY}px`,
height: `${strokeWidth}px`, width: `${arrowLength}px`,
backgroundColor: obj.stroke || '#000000', height: `${strokeWidth}px`,
opacity: (obj.opacity ?? 100) / 100, backgroundColor: obj.stroke || '#000000',
transform: `rotate(${arrowAngle}deg)`, opacity: (obj.opacity ?? 100) / 100,
transformOrigin: 'left center', transform: `rotate(${arrowAngle}deg)`,
}} transformOrigin: 'left center',
},
obj,
index,
{ rotationDeg: arrowAngle, transformOrigin: 'left center' },
)}
> >
<div <div
style={{ style={{
@@ -472,13 +554,17 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return ( return (
<div <div
key={obj.id || index} key={obj.id || index}
style={{ style={withEntrance(
...baseStyle, {
display: 'grid', ...baseStyle,
gridTemplateRows: `repeat(${rows}, ${cellHeight * scale}px)`, display: 'grid',
gridTemplateColumns: `repeat(${cols}, ${cellWidth * scale}px)`, gridTemplateRows: `repeat(${rows}, ${cellHeight * scale}px)`,
border: stroke ? `${(strokeWidth || 1) * scale}px solid ${stroke}` : 'none', 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: rows }).map((_, rowIndex) =>
Array.from({ length: cols }).map((_, colIndex) => { Array.from({ length: cols }).map((_, colIndex) => {
@@ -536,7 +622,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return ( return (
<div <div
ref={ref} ref={setHostRef}
dir="rtl" dir="rtl"
className="page" className="page"
data-density="soft" data-density="soft"
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* وقتی صفحهٔ کتاب به viewport می‌رسد، یک شمارنده زیاد می‌شود تا انیمیشن ورود دوباره اجرا شود
* (مشابه ورود مجدد به اسلاید در PowerPoint).
*/
export function useBookPageEntranceSequence(disabled: boolean) {
const wasVisibleRef = useRef(false);
const [entranceSeq, setEntranceSeq] = useState(0);
const observerRef = useRef<IntersectionObserver | null>(null);
const attachRootRef = useCallback(
(node: HTMLDivElement | null) => {
observerRef.current?.disconnect();
observerRef.current = null;
if (disabled || !node) {
wasVisibleRef.current = false;
return;
}
const io = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry) return;
const visible = entry.isIntersecting && entry.intersectionRatio >= 0.06;
const mostlyHidden = !entry.isIntersecting || entry.intersectionRatio < 0.02;
if (visible && !wasVisibleRef.current) {
wasVisibleRef.current = true;
setEntranceSeq((s) => s + 1);
} else if (mostlyHidden) {
wasVisibleRef.current = false;
}
},
{ threshold: [0, 0.02, 0.06, 0.12, 0.25] },
);
io.observe(node);
observerRef.current = io;
},
[disabled],
);
useEffect(
() => () => {
observerRef.current?.disconnect();
observerRef.current = null;
},
[],
);
return {
attachRootRef,
entranceSeq: disabled ? 0 : entranceSeq,
};
}
@@ -0,0 +1,76 @@
/* PowerPoint-style entrance animations for viewer (keyframes use --ve-* vars from JS) */
@keyframes viewer-entrance-fade {
from {
opacity: 0;
}
to {
opacity: var(--ve-opacity-end, 1);
}
}
@keyframes viewer-entrance-fly-left {
from {
opacity: 0;
transform: translateX(calc(-1 * var(--ve-dist, 48px))) rotate(var(--ve-rot, 0deg));
}
to {
opacity: var(--ve-opacity-end, 1);
transform: translateX(0) rotate(var(--ve-rot, 0deg));
}
}
@keyframes viewer-entrance-fly-right {
from {
opacity: 0;
transform: translateX(var(--ve-dist, 48px)) rotate(var(--ve-rot, 0deg));
}
to {
opacity: var(--ve-opacity-end, 1);
transform: translateX(0) rotate(var(--ve-rot, 0deg));
}
}
@keyframes viewer-entrance-fly-top {
from {
opacity: 0;
transform: translateY(calc(-1 * var(--ve-dist, 48px))) rotate(var(--ve-rot, 0deg));
}
to {
opacity: var(--ve-opacity-end, 1);
transform: translateY(0) rotate(var(--ve-rot, 0deg));
}
}
@keyframes viewer-entrance-fly-bottom {
from {
opacity: 0;
transform: translateY(var(--ve-dist, 48px)) rotate(var(--ve-rot, 0deg));
}
to {
opacity: var(--ve-opacity-end, 1);
transform: translateY(0) rotate(var(--ve-rot, 0deg));
}
}
@keyframes viewer-entrance-zoom-in {
from {
opacity: 0;
transform: scale(0.35) rotate(var(--ve-rot, 0deg));
}
to {
opacity: var(--ve-opacity-end, 1);
transform: scale(1) rotate(var(--ve-rot, 0deg));
}
}
@keyframes viewer-entrance-zoom-out {
from {
opacity: 0;
transform: scale(1.45) rotate(var(--ve-rot, 0deg));
}
to {
opacity: var(--ve-opacity-end, 1);
transform: scale(1) rotate(var(--ve-rot, 0deg));
}
}
@@ -43,6 +43,8 @@ type ViewerDataPage = {
opacity?: number; opacity?: number;
letterSpacing?: number; letterSpacing?: number;
tableData?: TableData; tableData?: TableData;
entranceAnimation?: EditorObject["entranceAnimation"];
entranceDurationMs?: number;
}>; }>;
}; };
@@ -134,6 +136,13 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
baseObject.tableData = obj.tableData; baseObject.tableData = obj.tableData;
} }
if (obj.entranceAnimation !== undefined) {
baseObject.entranceAnimation = obj.entranceAnimation;
}
if (obj.entranceDurationMs !== undefined) {
baseObject.entranceDurationMs = obj.entranceDurationMs;
}
return baseObject; return baseObject;
}); });
@@ -0,0 +1,109 @@
import type { CSSProperties } from "react";
import type { EditorObject } from "@/pages/editor/store/editorStore";
import {
DEFAULT_ENTRANCE_DURATION_MS,
ENTRANCE_ANIMATION_BASE_DELAY_MS,
} from "@/shared/entranceAnimation";
const ANIMATION_NAMES: Record<
NonNullable<EditorObject["entranceAnimation"]>,
string
> = {
none: "",
fade: "viewer-entrance-fade",
flyInLeft: "viewer-entrance-fly-left",
flyInRight: "viewer-entrance-fly-right",
flyInTop: "viewer-entrance-fly-top",
flyInBottom: "viewer-entrance-fly-bottom",
zoomIn: "viewer-entrance-zoom-in",
zoomOut: "viewer-entrance-zoom-out",
};
/** فاصلهٔ حرکت برای fly تا از بیرون کادر صفحه وارد شود (حداقل به اندازهٔ پهنا/ارتفاع صفحه) */
function flyTravelDistancePx(
type: EditorObject["entranceAnimation"],
layoutW: number,
layoutH: number,
scale: number,
): number {
const pad = 32;
const fallback = Math.max(80, Math.round(56 * scale));
switch (type) {
case "flyInLeft":
case "flyInRight":
return Math.max(layoutW + pad, fallback);
case "flyInTop":
case "flyInBottom":
return Math.max(layoutH + pad, fallback);
default:
return fallback;
}
}
export function mergeEntranceAnimationStyle(
base: CSSProperties,
obj: EditorObject,
scale: number,
elementIndex: number,
entranceSeq: number,
options?: {
transformOrigin?: CSSProperties["transformOrigin"];
rotationDeg?: number;
/** ابعاد کادر صفحه (px) برای محاسبهٔ مسیر fly از بیرون */
flyLayoutPx?: { width: number; height: number };
},
): CSSProperties {
const type = obj.entranceAnimation;
if (!type || type === "none") {
return base;
}
/* تا observer هنوز seq را زیاد نکرده، یک فریم با «ظاهر نهایی» رندر نشود — باعث چشمک می‌شد */
if (entranceSeq === 0) {
const { opacity: _o, ...rest } = base;
return {
...rest,
opacity: 0,
};
}
const name = ANIMATION_NAMES[type];
if (!name) return base;
const durationSec =
Math.min(4000, Math.max(100, obj.entranceDurationMs ?? DEFAULT_ENTRANCE_DURATION_MS)) /
1000;
const baseDelaySec = ENTRANCE_ANIMATION_BASE_DELAY_MS / 1000;
const staggerSec = Math.min(elementIndex * 0.045, 1.2);
const rot = options?.rotationDeg ?? obj.rotation ?? 0;
const opacityEnd = (obj.opacity ?? 100) / 100;
const layout = options?.flyLayoutPx;
const isFly =
type === "flyInLeft" ||
type === "flyInRight" ||
type === "flyInTop" ||
type === "flyInBottom";
const dist =
layout && isFly
? Math.round(flyTravelDistancePx(type, layout.width, layout.height, scale))
: Math.round(56 * scale);
const { transform: _t, opacity: _o, ...restBase } = base;
return {
...restBase,
["--ve-rot" as string]: `${rot}deg`,
["--ve-opacity-end" as string]: String(opacityEnd),
["--ve-dist" as string]: `${dist}px`,
transformOrigin: options?.transformOrigin ?? base.transformOrigin ?? "top left",
willChange: "transform, opacity",
animationName: name,
animationDuration: `${durationSec}s`,
animationDelay: `${baseDelaySec + staggerSec}s`,
animationTimingFunction: "cubic-bezier(0.22, 1, 0.36, 1)",
animationFillMode: "both",
};
}
+5
View File
@@ -0,0 +1,5 @@
/** مدت پیش‌فرض انیمیشن ورود در editor و viewer (میلی‌ثانیه) */
export const DEFAULT_ENTRANCE_DURATION_MS = 1000;
/** تأخیر کوتاه قبل از شروع انیمیشن ورود تا با ورق خوردن صفحه هم‌هماهنگ شود (کمتر = حس فوری‌تر) */
export const ENTRANCE_ANIMATION_BASE_DELAY_MS = 75;