base animation
This commit is contained in:
@@ -73,6 +73,7 @@ const CatalogPreview: FC<Props> = ({ item }) => {
|
||||
scale={1}
|
||||
pageWidth={pageWidth}
|
||||
pageHeight={pageHeight}
|
||||
disableEntranceAnimations
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TransformSettings,
|
||||
GridSettings,
|
||||
MaskSettings,
|
||||
EntranceAnimationSettings,
|
||||
} from "./settings";
|
||||
import TextInstruction from "./instructions/TextInstruction";
|
||||
|
||||
@@ -117,6 +118,10 @@ const ObjectSettings = ({
|
||||
<GridSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
|
||||
)}
|
||||
|
||||
<EntranceAnimationSettings
|
||||
selectedObject={roundedSelectedObject}
|
||||
onUpdate={handleRoundedUpdate}
|
||||
/>
|
||||
|
||||
<TransformSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
|
||||
</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 GridSettings } from "./GridSettings";
|
||||
export { default as MaskSettings } from "./MaskSettings";
|
||||
export { default as EntranceAnimationSettings } from "./EntranceAnimationSettings";
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export type {
|
||||
DocumentSettings,
|
||||
DisplayStyle,
|
||||
BackgroundType,
|
||||
EntranceAnimationType,
|
||||
} from "./editorStore.types";
|
||||
|
||||
type PageBackgroundSettings = Pick<
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { ShapeType } from "./shapeStore";
|
||||
import type { FillType, LinearGradient } from "../utils/gradient";
|
||||
|
||||
/** انیمیشن ورود (نمایش) شیء در viewer، مشابه PowerPoint */
|
||||
export type EntranceAnimationType =
|
||||
| "none"
|
||||
| "fade"
|
||||
| "flyInLeft"
|
||||
| "flyInRight"
|
||||
| "flyInTop"
|
||||
| "flyInBottom"
|
||||
| "zoomIn"
|
||||
| "zoomOut";
|
||||
|
||||
export type ToolType =
|
||||
| "select"
|
||||
| "rectangle"
|
||||
@@ -70,6 +81,10 @@ export type EditorObject = {
|
||||
maskId?: string;
|
||||
maskInvert?: boolean;
|
||||
groupId?: string;
|
||||
/** نحوهٔ ظاهر شدن در نمایشگر؛ پیشفرض بدون انیمیشن */
|
||||
entranceAnimation?: EntranceAnimationType;
|
||||
/** مدت انیمیشن ورود به میلیثانیه (پیشفرض ۶۰۰) */
|
||||
entranceDurationMs?: number;
|
||||
};
|
||||
|
||||
export type PageGuide = {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { type PageData } from '../types';
|
||||
import type { EditorObject } from '@/pages/editor/store/editorStore';
|
||||
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 = {
|
||||
page: PageData;
|
||||
@@ -17,6 +20,8 @@ type BookPageProps = {
|
||||
angle: number;
|
||||
};
|
||||
backgroundImageUrl?: string;
|
||||
/** در پیشنمایش کاتالوگ انیمیشن ورود غیرفعال باشد */
|
||||
disableEntranceAnimations?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,7 +32,20 @@ type BookPageProps = {
|
||||
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
|
||||
*/
|
||||
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)
|
||||
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
|
||||
// در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است
|
||||
@@ -57,6 +75,25 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
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 actualStrokeWidth = obj.strokeWidth ?? 0;
|
||||
const hasStroke = actualStrokeWidth > 0;
|
||||
@@ -90,7 +127,8 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
return (
|
||||
<div
|
||||
key={obj.id || index}
|
||||
style={{
|
||||
style={withEntrance(
|
||||
{
|
||||
...textBaseStyle,
|
||||
left: `${textLeft}px`,
|
||||
width: textWidth ? `${textWidth}px` : undefined,
|
||||
@@ -104,7 +142,10 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined,
|
||||
}}
|
||||
},
|
||||
obj,
|
||||
index,
|
||||
)}
|
||||
>
|
||||
{obj.text || ''}
|
||||
</div>
|
||||
@@ -117,13 +158,17 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
key={obj.id || index}
|
||||
src={obj.imageUrl || ''}
|
||||
alt=""
|
||||
style={{
|
||||
style={withEntrance(
|
||||
{
|
||||
...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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -133,14 +178,18 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
key={obj.id || index}
|
||||
src={obj.videoUrl || ''}
|
||||
controls
|
||||
style={{
|
||||
style={withEntrance(
|
||||
{
|
||||
...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();
|
||||
}}
|
||||
@@ -175,7 +224,8 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
<button
|
||||
key={obj.id || index}
|
||||
type="button"
|
||||
style={{
|
||||
style={withEntrance(
|
||||
{
|
||||
...linkStyle,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'auto',
|
||||
@@ -183,7 +233,10 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
background: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
},
|
||||
obj,
|
||||
index,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -209,11 +262,15 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
href={obj.linkUrl || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
style={withEntrance(
|
||||
{
|
||||
...linkStyle,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
},
|
||||
obj,
|
||||
index,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
@@ -238,7 +295,8 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
return (
|
||||
<div
|
||||
key={obj.id || index}
|
||||
style={{
|
||||
style={withEntrance(
|
||||
{
|
||||
position: 'absolute',
|
||||
left: `${centerX - radius}px`,
|
||||
top: `${centerY - radius}px`,
|
||||
@@ -254,7 +312,11 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
|
||||
transformOrigin: 'center center',
|
||||
zIndex: index, // برای حفظ ترتیب رندر
|
||||
}}
|
||||
},
|
||||
obj,
|
||||
index,
|
||||
{ transformOrigin: 'center center' },
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -296,7 +358,8 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
return (
|
||||
<svg
|
||||
key={obj.id || index}
|
||||
style={{
|
||||
style={withEntrance(
|
||||
{
|
||||
position: 'absolute',
|
||||
left: `${centerX - radius}px`,
|
||||
top: `${centerY - radius}px`,
|
||||
@@ -307,7 +370,11 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
transformOrigin: 'center center',
|
||||
zIndex: index,
|
||||
overflow: 'visible',
|
||||
}}
|
||||
},
|
||||
obj,
|
||||
index,
|
||||
{ transformOrigin: 'center center' },
|
||||
)}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
>
|
||||
{obj.fillType === 'gradient' && obj.gradient ? (
|
||||
@@ -349,7 +416,8 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
return (
|
||||
<div
|
||||
key={obj.id || index}
|
||||
style={{
|
||||
style={withEntrance(
|
||||
{
|
||||
position: 'absolute',
|
||||
left: `${centerX - radius}px`,
|
||||
top: `${centerY - radius}px`,
|
||||
@@ -364,7 +432,11 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
opacity: (obj.opacity ?? 100) / 100,
|
||||
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, // برای حفظ ترتیب رندر
|
||||
};
|
||||
|
||||
return <div key={obj.id || index} style={shapeStyle} />;
|
||||
return <div key={obj.id || index} style={withEntrance(shapeStyle, obj, index)} />;
|
||||
}
|
||||
|
||||
case 'line': {
|
||||
@@ -402,7 +474,8 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
return (
|
||||
<div
|
||||
key={obj.id || index}
|
||||
style={{
|
||||
style={withEntrance(
|
||||
{
|
||||
position: 'absolute',
|
||||
left: `${startX}px`,
|
||||
top: `${startY}px`,
|
||||
@@ -412,7 +485,11 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
opacity: (obj.opacity ?? 100) / 100,
|
||||
transform: `rotate(${lineAngle}deg)`,
|
||||
transformOrigin: 'left center',
|
||||
}}
|
||||
},
|
||||
obj,
|
||||
index,
|
||||
{ rotationDeg: lineAngle, transformOrigin: 'left center' },
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -434,7 +511,8 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
return (
|
||||
<div
|
||||
key={obj.id || index}
|
||||
style={{
|
||||
style={withEntrance(
|
||||
{
|
||||
position: 'absolute',
|
||||
left: `${startX}px`,
|
||||
top: `${startY}px`,
|
||||
@@ -444,7 +522,11 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
opacity: (obj.opacity ?? 100) / 100,
|
||||
transform: `rotate(${arrowAngle}deg)`,
|
||||
transformOrigin: 'left center',
|
||||
}}
|
||||
},
|
||||
obj,
|
||||
index,
|
||||
{ rotationDeg: arrowAngle, transformOrigin: 'left center' },
|
||||
)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -472,13 +554,17 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
return (
|
||||
<div
|
||||
key={obj.id || index}
|
||||
style={{
|
||||
style={withEntrance(
|
||||
{
|
||||
...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) => {
|
||||
@@ -536,7 +622,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
ref={setHostRef}
|
||||
dir="rtl"
|
||||
className="page"
|
||||
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;
|
||||
letterSpacing?: number;
|
||||
tableData?: TableData;
|
||||
entranceAnimation?: EditorObject["entranceAnimation"];
|
||||
entranceDurationMs?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -134,6 +136,13 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
|
||||
baseObject.tableData = obj.tableData;
|
||||
}
|
||||
|
||||
if (obj.entranceAnimation !== undefined) {
|
||||
baseObject.entranceAnimation = obj.entranceAnimation;
|
||||
}
|
||||
if (obj.entranceDurationMs !== undefined) {
|
||||
baseObject.entranceDurationMs = obj.entranceDurationMs;
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** مدت پیشفرض انیمیشن ورود در editor و viewer (میلیثانیه) */
|
||||
export const DEFAULT_ENTRANCE_DURATION_MS = 1000;
|
||||
|
||||
/** تأخیر کوتاه قبل از شروع انیمیشن ورود تا با ورق خوردن صفحه همهماهنگ شود (کمتر = حس فوریتر) */
|
||||
export const ENTRANCE_ANIMATION_BASE_DELAY_MS = 75;
|
||||
Reference in New Issue
Block a user