base animation
This commit is contained in:
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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