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}
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";
+1
View File
@@ -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 = {
+112 -26
View File
@@ -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",
};
}
+5
View File
@@ -0,0 +1,5 @@
/** مدت پیش‌فرض انیمیشن ورود در editor و viewer (میلی‌ثانیه) */
export const DEFAULT_ENTRANCE_DURATION_MS = 1000;
/** تأخیر کوتاه قبل از شروع انیمیشن ورود تا با ورق خوردن صفحه هم‌هماهنگ شود (کمتر = حس فوری‌تر) */
export const ENTRANCE_ANIMATION_BASE_DELAY_MS = 75;