Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c764a1652 | |||
| 954ad48486 | |||
| 368cace143 |
@@ -7,6 +7,7 @@ import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
|
|||||||
import ColorsImage from '@/assets/images/colors.png'
|
import ColorsImage from '@/assets/images/colors.png'
|
||||||
import ColorPicker from '@/components/ColorPicker'
|
import ColorPicker from '@/components/ColorPicker'
|
||||||
import Select from '@/components/Select'
|
import Select from '@/components/Select'
|
||||||
|
import { toCssLinearGradient } from '../utils/gradient'
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
'#a8edcf',
|
'#a8edcf',
|
||||||
@@ -322,7 +323,7 @@ const SettingsPanel = () => {
|
|||||||
<div
|
<div
|
||||||
className="h-12 rounded-xl border border-border"
|
className="h-12 rounded-xl border border-border"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(${backgroundGradient.angle}deg, ${backgroundGradient.from} 0%, ${backgroundGradient.to} 100%)`,
|
backgroundImage: toCssLinearGradient(backgroundGradient),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const ObjectRenderer = ({
|
|||||||
groupNode?.clearCache();
|
groupNode?.clearCache();
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient, obj.blur]);
|
}, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient, obj.blur, obj.borderRadius]);
|
||||||
|
|
||||||
// Refresh cache after transformer is attached (when isSelected changes)
|
// Refresh cache after transformer is attached (when isSelected changes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ const SizeSettings = ({ selectedObject, onUpdate, defaultWidth = 100, defaultHei
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
label="گردی گوشه"
|
||||||
|
type="number"
|
||||||
|
value={selectedObject.borderRadius ?? 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate(selectedObject.id, {
|
||||||
|
borderRadius: Math.max(0, parseInt(e.target.value) || 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
|
|||||||
import { Rect, Group, Circle, Text as KonvaText } from "react-konva";
|
import { Rect, Group, Circle, Text as KonvaText } from "react-konva";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import type { ShapeProps } from "./types";
|
import type { ShapeProps } from "./types";
|
||||||
|
import { getObjectBorderRadius } from "../../utils/borderRadius";
|
||||||
|
|
||||||
const AudioShape = ({
|
const AudioShape = ({
|
||||||
obj,
|
obj,
|
||||||
@@ -15,6 +16,7 @@ const AudioShape = ({
|
|||||||
|
|
||||||
const containerWidth = obj.width || 320;
|
const containerWidth = obj.width || 320;
|
||||||
const containerHeight = obj.height || 56;
|
const containerHeight = obj.height || 56;
|
||||||
|
const cornerRadius = getObjectBorderRadius(obj.borderRadius ?? 8);
|
||||||
const barY = containerHeight * 0.55;
|
const barY = containerHeight * 0.55;
|
||||||
const barHeight = Math.max(4, containerHeight * 0.12);
|
const barHeight = Math.max(4, containerHeight * 0.12);
|
||||||
const playRadius = Math.min(18, containerHeight * 0.35);
|
const playRadius = Math.min(18, containerHeight * 0.35);
|
||||||
@@ -70,7 +72,7 @@ const AudioShape = ({
|
|||||||
width={containerWidth}
|
width={containerWidth}
|
||||||
height={containerHeight}
|
height={containerHeight}
|
||||||
fill="#f3f4f6"
|
fill="#f3f4f6"
|
||||||
cornerRadius={8}
|
cornerRadius={cornerRadius}
|
||||||
stroke={isSelected ? "#3b82f6" : "#d1d5db"}
|
stroke={isSelected ? "#3b82f6" : "#d1d5db"}
|
||||||
strokeWidth={isSelected ? 3 : 1}
|
strokeWidth={isSelected ? 3 : 1}
|
||||||
onClick={handleGroupClick}
|
onClick={handleGroupClick}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Image as KonvaImage } from "react-konva";
|
import { Image as KonvaImage, Group, Rect } from "react-konva";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import useImage from "use-image";
|
import useImage from "use-image";
|
||||||
import type { ShapeProps } from "./types";
|
import type { ShapeProps } from "./types";
|
||||||
import { clampPositionToStage } from "../../utils/stageBounds";
|
import { clampPositionToStage } from "../../utils/stageBounds";
|
||||||
|
import { createRoundedRectClipFunc, getObjectBorderRadius } from "../../utils/borderRadius";
|
||||||
|
|
||||||
const ImageObject = ({
|
const ImageObject = ({
|
||||||
obj,
|
obj,
|
||||||
@@ -17,9 +18,8 @@ const ImageObject = ({
|
|||||||
}: ShapeProps) => {
|
}: ShapeProps) => {
|
||||||
const [image, status] = useImage(obj.imageUrl || "");
|
const [image, status] = useImage(obj.imageUrl || "");
|
||||||
const shapeRef = useRef<Konva.Image>(null);
|
const shapeRef = useRef<Konva.Image>(null);
|
||||||
|
const groupRef = useRef<Konva.Group>(null);
|
||||||
|
|
||||||
// Notify parent (ObjectRenderer) as soon as the image is decoded and ready.
|
|
||||||
// This lets the masked group re-cache itself after the image content appears.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "loaded" && onImageReady) {
|
if (status === "loaded" && onImageReady) {
|
||||||
onImageReady();
|
onImageReady();
|
||||||
@@ -31,6 +31,84 @@ const ImageObject = ({
|
|||||||
const width = obj.width || image.width;
|
const width = obj.width || image.width;
|
||||||
const height = obj.height || image.height;
|
const height = obj.height || image.height;
|
||||||
const hasStageBounds = stageWidth != null && stageHeight != null;
|
const hasStageBounds = stageWidth != null && stageHeight != null;
|
||||||
|
const cornerRadius = getObjectBorderRadius(obj.borderRadius);
|
||||||
|
const clipFunc = createRoundedRectClipFunc(width, height, cornerRadius);
|
||||||
|
|
||||||
|
const dragBoundFunc =
|
||||||
|
draggable && hasStageBounds
|
||||||
|
? (pos: { x: number; y: number }) =>
|
||||||
|
clampPositionToStage(
|
||||||
|
pos.x,
|
||||||
|
pos.y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
stageWidth!,
|
||||||
|
stageHeight!,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => {
|
||||||
|
const node = e.target;
|
||||||
|
let x = node.x();
|
||||||
|
let y = node.y();
|
||||||
|
if (hasStageBounds) {
|
||||||
|
({ x, y } = clampPositionToStage(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
stageWidth!,
|
||||||
|
stageHeight!,
|
||||||
|
));
|
||||||
|
node.position({ x, y });
|
||||||
|
}
|
||||||
|
onUpdate(obj.id, { x, y });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
|
const node = cornerRadius > 0 ? groupRef.current : shapeRef.current;
|
||||||
|
if (node) {
|
||||||
|
onSelect(obj.id, node, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cornerRadius > 0) {
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
ref={groupRef}
|
||||||
|
id={obj.id}
|
||||||
|
name="canvas-object"
|
||||||
|
x={obj.x}
|
||||||
|
y={obj.y}
|
||||||
|
rotation={obj.rotation || 0}
|
||||||
|
draggable={draggable}
|
||||||
|
clipFunc={clipFunc}
|
||||||
|
dragBoundFunc={dragBoundFunc}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<KonvaImage
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
image={image}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
{isSelected && (
|
||||||
|
<Rect
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={3}
|
||||||
|
cornerRadius={cornerRadius}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KonvaImage
|
<KonvaImage
|
||||||
@@ -46,44 +124,11 @@ const ImageObject = ({
|
|||||||
strokeWidth={isSelected ? 3 : 0}
|
strokeWidth={isSelected ? 3 : 0}
|
||||||
rotation={obj.rotation || 0}
|
rotation={obj.rotation || 0}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
dragBoundFunc={
|
dragBoundFunc={dragBoundFunc}
|
||||||
draggable && hasStageBounds
|
onClick={handleClick}
|
||||||
? (pos) =>
|
onDragEnd={handleDragEnd}
|
||||||
clampPositionToStage(
|
|
||||||
pos.x,
|
|
||||||
pos.y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
stageWidth!,
|
|
||||||
stageHeight!,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (shapeRef.current) {
|
|
||||||
onSelect(obj.id, shapeRef.current, e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragEnd={(e) => {
|
|
||||||
const node = e.target;
|
|
||||||
let x = node.x();
|
|
||||||
let y = node.y();
|
|
||||||
if (hasStageBounds) {
|
|
||||||
({ x, y } = clampPositionToStage(
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
stageWidth!,
|
|
||||||
stageHeight!,
|
|
||||||
));
|
|
||||||
node.position({ x, y });
|
|
||||||
}
|
|
||||||
onUpdate(obj.id, { x, y });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ImageObject;
|
export default ImageObject;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Konva from "konva";
|
|||||||
import useImage from "use-image";
|
import useImage from "use-image";
|
||||||
import type { ShapeProps } from "./types";
|
import type { ShapeProps } from "./types";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { createRoundedRectClipFunc, getObjectBorderRadius } from "../../utils/borderRadius";
|
||||||
|
|
||||||
const VideoShape = ({
|
const VideoShape = ({
|
||||||
obj,
|
obj,
|
||||||
@@ -72,6 +73,8 @@ const VideoShape = ({
|
|||||||
|
|
||||||
const containerWidth = obj.width || 400;
|
const containerWidth = obj.width || 400;
|
||||||
const containerHeight = obj.height || 300;
|
const containerHeight = obj.height || 300;
|
||||||
|
const cornerRadius = getObjectBorderRadius(obj.borderRadius);
|
||||||
|
const clipFunc = createRoundedRectClipFunc(containerWidth, containerHeight, cornerRadius);
|
||||||
|
|
||||||
// همتراز با viewer که object-fit: contain دارد
|
// همتراز با viewer که object-fit: contain دارد
|
||||||
const imageLayout = useMemo(() => {
|
const imageLayout = useMemo(() => {
|
||||||
@@ -151,10 +154,12 @@ const VideoShape = ({
|
|||||||
width={containerWidth}
|
width={containerWidth}
|
||||||
height={containerHeight}
|
height={containerHeight}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
|
cornerRadius={cornerRadius}
|
||||||
stroke={isSelected ? "#3b82f6" : "#666666"}
|
stroke={isSelected ? "#3b82f6" : "#666666"}
|
||||||
strokeWidth={isSelected ? 3 : (obj.strokeWidth ?? 0)}
|
strokeWidth={isSelected ? 3 : (obj.strokeWidth ?? 0)}
|
||||||
onClick={handleGroupClick}
|
onClick={handleGroupClick}
|
||||||
/>
|
/>
|
||||||
|
<Group clipFunc={clipFunc}>
|
||||||
{image ? (
|
{image ? (
|
||||||
<>
|
<>
|
||||||
<Rect
|
<Rect
|
||||||
@@ -184,6 +189,7 @@ const VideoShape = ({
|
|||||||
onClick={handleGroupClick}
|
onClick={handleGroupClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</Group>
|
||||||
<Group
|
<Group
|
||||||
name="playButton"
|
name="playButton"
|
||||||
onClick={handlePlayClick}
|
onClick={handlePlayClick}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type Konva from "konva";
|
||||||
|
|
||||||
|
export const getObjectBorderRadius = (borderRadius?: number): number =>
|
||||||
|
Math.max(0, borderRadius ?? 0);
|
||||||
|
|
||||||
|
export const clampBorderRadius = (
|
||||||
|
radius: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): number => Math.max(0, Math.min(radius, width / 2, height / 2));
|
||||||
|
|
||||||
|
export const createRoundedRectClipFunc = (
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
radius: number,
|
||||||
|
): Konva.ContainerConfig["clipFunc"] => {
|
||||||
|
const r = clampBorderRadius(radius, width, height);
|
||||||
|
if (r <= 0) return undefined;
|
||||||
|
|
||||||
|
return (ctx) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(0, 0, width, height, r);
|
||||||
|
ctx.closePath();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCssBorderRadius = (
|
||||||
|
borderRadius: number | undefined,
|
||||||
|
scale: number,
|
||||||
|
): string | undefined => {
|
||||||
|
const r = getObjectBorderRadius(borderRadius) * scale;
|
||||||
|
return r > 0 ? `${r}px` : undefined;
|
||||||
|
};
|
||||||
@@ -25,23 +25,24 @@ const getGradientPoints = (
|
|||||||
): { start: Point; end: Point } => {
|
): { start: Point; end: Point } => {
|
||||||
const safeWidth = Math.max(1, width);
|
const safeWidth = Math.max(1, width);
|
||||||
const safeHeight = Math.max(1, height);
|
const safeHeight = Math.max(1, height);
|
||||||
|
// App/UI angle: 0° = top→bottom, 90° = left→right (clockwise).
|
||||||
const rad = toRadians(normalizeAngle(angle));
|
const rad = toRadians(normalizeAngle(angle));
|
||||||
const dx = Math.cos(rad);
|
|
||||||
const dy = Math.sin(rad);
|
|
||||||
const half = Math.sqrt(safeWidth * safeWidth + safeHeight * safeHeight) / 2;
|
const half = Math.sqrt(safeWidth * safeWidth + safeHeight * safeHeight) / 2;
|
||||||
|
const halfX = Math.sin(rad) * half;
|
||||||
|
const halfY = Math.cos(rad) * half;
|
||||||
|
|
||||||
if (mode === "centered") {
|
if (mode === "centered") {
|
||||||
return {
|
return {
|
||||||
start: { x: -dx * half, y: -dy * half },
|
start: { x: -halfX, y: -halfY },
|
||||||
end: { x: dx * half, y: dy * half },
|
end: { x: halfX, y: halfY },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cx = safeWidth / 2;
|
const cx = safeWidth / 2;
|
||||||
const cy = safeHeight / 2;
|
const cy = safeHeight / 2;
|
||||||
return {
|
return {
|
||||||
start: { x: cx - dx * half, y: cy - dy * half },
|
start: { x: cx - halfX, y: cy - halfY },
|
||||||
end: { x: cx + dx * half, y: cy + dy * half },
|
end: { x: cx + halfX, y: cy + halfY },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,7 +92,11 @@ export const getSvgGradientEndpoints = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Convert app/UI angle to CSS linear-gradient degrees (0deg = upward in CSS). */
|
||||||
|
export const toCssGradientAngle = (angle: number) =>
|
||||||
|
normalizeAngle(180 - normalizeAngle(angle));
|
||||||
|
|
||||||
export const toCssLinearGradient = (gradient: LinearGradient | undefined) => {
|
export const toCssLinearGradient = (gradient: LinearGradient | undefined) => {
|
||||||
if (!gradient) return undefined;
|
if (!gradient) return undefined;
|
||||||
return `linear-gradient(${normalizeAngle(gradient.angle)}deg, ${gradient.from} 0%, ${gradient.to} 100%)`;
|
return `linear-gradient(${toCssGradientAngle(gradient.angle)}deg, ${gradient.from} 0%, ${gradient.to} 100%)`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { forwardRef, memo } from 'react';
|
import { forwardRef, memo, useEffect, useRef } 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, getSvgGradientEndpoints } from '@/pages/editor/utils/gradient';
|
import { toCssLinearGradient, getSvgGradientEndpoints } from '@/pages/editor/utils/gradient';
|
||||||
@@ -12,10 +12,12 @@ import {
|
|||||||
usesWrappedLayout,
|
usesWrappedLayout,
|
||||||
} from '@/pages/editor/utils/textStyle';
|
} from '@/pages/editor/utils/textStyle';
|
||||||
import { getCssBlurStyle } from '@/pages/editor/utils/shapeBlur';
|
import { getCssBlurStyle } from '@/pages/editor/utils/shapeBlur';
|
||||||
|
import { getCssBorderRadius } from '@/pages/editor/utils/borderRadius';
|
||||||
import '@/pages/viewer/styles/entranceAnimations.css';
|
import '@/pages/viewer/styles/entranceAnimations.css';
|
||||||
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
|
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
|
||||||
import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle';
|
import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle';
|
||||||
import type { EntrancePhase } from '@/pages/viewer/hooks/useBookEntranceController';
|
import type { EntrancePhase } from '@/pages/viewer/hooks/useBookEntranceController';
|
||||||
|
import { usePageMediaReady } from '@/pages/viewer/hooks/usePageMediaReady';
|
||||||
|
|
||||||
const getRasterObjectStyle = (
|
const getRasterObjectStyle = (
|
||||||
obj: EditorObject,
|
obj: EditorObject,
|
||||||
@@ -27,6 +29,7 @@ const getRasterObjectStyle = (
|
|||||||
const scaleY = obj.scaleY ?? 1;
|
const scaleY = obj.scaleY ?? 1;
|
||||||
const width = obj.width != null ? obj.width * scaleX * scale : undefined;
|
const width = obj.width != null ? obj.width * scaleX * scale : undefined;
|
||||||
const height = obj.height != null ? obj.height * scaleY * scale : undefined;
|
const height = obj.height != null ? obj.height * scaleY * scale : undefined;
|
||||||
|
const borderRadius = getCssBorderRadius(obj.borderRadius, scale);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseStyle,
|
...baseStyle,
|
||||||
@@ -36,6 +39,7 @@ const getRasterObjectStyle = (
|
|||||||
height: height != null ? `${height}px` : 'auto',
|
height: height != null ? `${height}px` : 'auto',
|
||||||
objectFit: 'fill',
|
objectFit: 'fill',
|
||||||
zIndex: index,
|
zIndex: index,
|
||||||
|
...(borderRadius ? { borderRadius, overflow: 'hidden' as const } : {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,6 +68,10 @@ type BookPageProps = {
|
|||||||
entrancePhase?: EntrancePhase;
|
entrancePhase?: EntrancePhase;
|
||||||
/** در پیشنمایش کاتالوگ انیمی션 ورود غیرفعال باشد */
|
/** در پیشنمایش کاتالوگ انیمی션 ورود غیرفعال باشد */
|
||||||
disableEntranceAnimations?: boolean;
|
disableEntranceAnimations?: boolean;
|
||||||
|
/** وقتی رسانهٔ صفحه دیرتر از زمانبندی انیمیشن لود شد */
|
||||||
|
onDeferredEntranceReady?: (pageId: number) => void;
|
||||||
|
/** لایهٔ اسپینر لود رسانه را نشان نده (مثلاً ذرهبین) */
|
||||||
|
hideMediaLoadingOverlay?: boolean;
|
||||||
/** ویدیو/صوت فقط بهصورت کاور — بدون کنترل و بدون کلیک */
|
/** ویدیو/صوت فقط بهصورت کاور — بدون کنترل و بدون کلیک */
|
||||||
staticMedia?: boolean;
|
staticMedia?: boolean;
|
||||||
/** سایهٔ لبهٔ کاغذ (گرادیان داخل DOM — سازگار با iOS قدیمی) */
|
/** سایهٔ لبهٔ کاغذ (گرادیان داخل DOM — سازگار با iOS قدیمی) */
|
||||||
@@ -86,8 +94,40 @@ type BookPageProps = {
|
|||||||
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
|
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
|
||||||
*/
|
*/
|
||||||
const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
||||||
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => {
|
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = false, onDeferredEntranceReady, hideMediaLoadingOverlay = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => {
|
||||||
|
const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color';
|
||||||
|
const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff';
|
||||||
|
const effectiveBackgroundGradient = backgroundGradient ?? page.backgroundGradient;
|
||||||
|
const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? '';
|
||||||
|
const effectiveBackgroundVideoUrl = backgroundVideoUrl ?? page.backgroundVideoUrl ?? '';
|
||||||
|
|
||||||
|
const isMediaReady = usePageMediaReady(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
backgroundType: effectiveBackgroundType,
|
||||||
|
backgroundImageUrl: effectiveBackgroundImageUrl,
|
||||||
|
backgroundVideoUrl: effectiveBackgroundVideoUrl,
|
||||||
|
},
|
||||||
|
!hideMediaLoadingOverlay,
|
||||||
|
);
|
||||||
|
const deferredEntranceRef = useRef(false);
|
||||||
|
|
||||||
const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase;
|
const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase;
|
||||||
|
const effectivePhase: EntrancePhase =
|
||||||
|
hideMediaLoadingOverlay || isMediaReady ? phase : 'idle';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disableEntranceAnimations || hideMediaLoadingOverlay) return;
|
||||||
|
if (phase === 'play' && !isMediaReady) {
|
||||||
|
deferredEntranceRef.current = true;
|
||||||
|
}
|
||||||
|
}, [phase, isMediaReady, disableEntranceAnimations, hideMediaLoadingOverlay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMediaReady || !deferredEntranceRef.current) return;
|
||||||
|
deferredEntranceRef.current = false;
|
||||||
|
onDeferredEntranceReady?.(page.id);
|
||||||
|
}, [isMediaReady, onDeferredEntranceReady, page.id]);
|
||||||
// تابع برای تبدیل 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 تبدیل شده است
|
||||||
@@ -131,7 +171,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
rotationDeg?: number;
|
rotationDeg?: number;
|
||||||
},
|
},
|
||||||
) =>
|
) =>
|
||||||
mergeEntranceAnimationStyle(style, obj, scale, index, phase, {
|
mergeEntranceAnimationStyle(style, obj, scale, index, effectivePhase, {
|
||||||
...extra,
|
...extra,
|
||||||
flyLayoutPx,
|
flyLayoutPx,
|
||||||
});
|
});
|
||||||
@@ -277,7 +317,17 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'video':
|
case 'video': {
|
||||||
|
const videoBorderRadius = getCssBorderRadius(obj.borderRadius, scale);
|
||||||
|
const videoStyle: React.CSSProperties = {
|
||||||
|
...baseStyle,
|
||||||
|
width: obj.width ? `${obj.width * scale}px` : 'auto',
|
||||||
|
height: obj.height ? `${obj.height * scale}px` : 'auto',
|
||||||
|
objectFit: 'contain',
|
||||||
|
zIndex: index,
|
||||||
|
...(videoBorderRadius ? { borderRadius: videoBorderRadius, overflow: 'hidden' } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
if (options.staticMedia) {
|
if (options.staticMedia) {
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
@@ -288,11 +338,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
preload="metadata"
|
preload="metadata"
|
||||||
style={applyStyle(
|
style={applyStyle(
|
||||||
{
|
{
|
||||||
...baseStyle,
|
...videoStyle,
|
||||||
width: obj.width ? `${obj.width * scale}px` : 'auto',
|
|
||||||
height: obj.height ? `${obj.height * scale}px` : 'auto',
|
|
||||||
objectFit: 'contain',
|
|
||||||
zIndex: index,
|
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
},
|
},
|
||||||
obj,
|
obj,
|
||||||
@@ -314,11 +360,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
controls
|
controls
|
||||||
style={applyStyle(
|
style={applyStyle(
|
||||||
{
|
{
|
||||||
...baseStyle,
|
...videoStyle,
|
||||||
width: obj.width ? `${obj.width * scale}px` : 'auto',
|
|
||||||
height: obj.height ? `${obj.height * scale}px` : 'auto',
|
|
||||||
objectFit: 'contain',
|
|
||||||
zIndex: index,
|
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
},
|
},
|
||||||
obj,
|
obj,
|
||||||
@@ -335,20 +377,26 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'audio': {
|
||||||
|
const audioBorderRadius = getCssBorderRadius(obj.borderRadius ?? 8, scale);
|
||||||
|
const audioBaseStyle: React.CSSProperties = {
|
||||||
|
...baseStyle,
|
||||||
|
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
|
||||||
|
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
|
||||||
|
zIndex: index,
|
||||||
|
...(audioBorderRadius ? { borderRadius: audioBorderRadius, overflow: 'hidden' } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
case 'audio':
|
|
||||||
if (options.staticMedia) {
|
if (options.staticMedia) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={obj.id || index}
|
key={obj.id || index}
|
||||||
style={applyStyle(
|
style={applyStyle(
|
||||||
{
|
{
|
||||||
...baseStyle,
|
...audioBaseStyle,
|
||||||
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
|
|
||||||
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
|
|
||||||
backgroundColor: '#f3f4f6',
|
backgroundColor: '#f3f4f6',
|
||||||
borderRadius: `${4 * scale}px`,
|
|
||||||
zIndex: index,
|
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
},
|
},
|
||||||
obj,
|
obj,
|
||||||
@@ -365,10 +413,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
preload="metadata"
|
preload="metadata"
|
||||||
style={applyStyle(
|
style={applyStyle(
|
||||||
{
|
{
|
||||||
...baseStyle,
|
...audioBaseStyle,
|
||||||
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
|
|
||||||
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
|
|
||||||
zIndex: index,
|
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
},
|
},
|
||||||
obj,
|
obj,
|
||||||
@@ -385,6 +430,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case 'link': {
|
case 'link': {
|
||||||
const isInternalLink = obj.linkUrl?.startsWith('page://');
|
const isInternalLink = obj.linkUrl?.startsWith('page://');
|
||||||
@@ -540,6 +586,16 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
const fillColor = obj.fill || '#000000';
|
const fillColor = obj.fill || '#000000';
|
||||||
const strokeColor = obj.stroke || 'transparent';
|
const strokeColor = obj.stroke || 'transparent';
|
||||||
const gradientId = `triangle-grad-${obj.id}-${index}${idSuffix}`;
|
const gradientId = `triangle-grad-${obj.id}-${index}${idSuffix}`;
|
||||||
|
const triangleGradientEndpoints =
|
||||||
|
obj.fillType === 'gradient' && obj.gradient
|
||||||
|
? getSvgGradientEndpoints(
|
||||||
|
obj.gradient,
|
||||||
|
baseWidth * scale,
|
||||||
|
baseHeight * scale,
|
||||||
|
'centered',
|
||||||
|
{ x: halfSize, y: halfSize },
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -564,25 +620,24 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
)}
|
)}
|
||||||
viewBox={`0 0 ${size} ${size}`}
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
>
|
>
|
||||||
{obj.fillType === 'gradient' && obj.gradient ? (
|
{triangleGradientEndpoints ? (
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient
|
<linearGradient
|
||||||
id={gradientId}
|
id={gradientId}
|
||||||
gradientUnits="userSpaceOnUse"
|
gradientUnits="userSpaceOnUse"
|
||||||
x1="0"
|
x1={triangleGradientEndpoints.x1}
|
||||||
y1="0"
|
y1={triangleGradientEndpoints.y1}
|
||||||
x2={size}
|
x2={triangleGradientEndpoints.x2}
|
||||||
y2={size}
|
y2={triangleGradientEndpoints.y2}
|
||||||
gradientTransform={`rotate(${obj.gradient.angle}, ${size / 2}, ${size / 2})`}
|
|
||||||
>
|
>
|
||||||
<stop offset="0%" stopColor={obj.gradient.from} />
|
<stop offset="0%" stopColor={obj.gradient!.from} />
|
||||||
<stop offset="100%" stopColor={obj.gradient.to} />
|
<stop offset="100%" stopColor={obj.gradient!.to} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
) : null}
|
) : null}
|
||||||
<polygon
|
<polygon
|
||||||
points={`${topX},${topY} ${bottomLeftX},${bottomY} ${bottomRightX},${bottomY}`}
|
points={`${topX},${topY} ${bottomLeftX},${bottomY} ${bottomRightX},${bottomY}`}
|
||||||
fill={obj.fillType === 'gradient' && obj.gradient ? `url(#${gradientId})` : fillColor}
|
fill={triangleGradientEndpoints ? `url(#${gradientId})` : fillColor}
|
||||||
stroke={strokeColor}
|
stroke={strokeColor}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
/>
|
/>
|
||||||
@@ -964,12 +1019,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
const width = pageWidth ?? 794 * scale;
|
const width = pageWidth ?? 794 * scale;
|
||||||
const height = pageHeight ?? 1123 * scale;
|
const height = pageHeight ?? 1123 * scale;
|
||||||
|
|
||||||
const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color';
|
|
||||||
const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff';
|
|
||||||
const effectiveBackgroundGradient = backgroundGradient ?? page.backgroundGradient;
|
|
||||||
const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? '';
|
|
||||||
const effectiveBackgroundVideoUrl = backgroundVideoUrl ?? page.backgroundVideoUrl ?? '';
|
|
||||||
|
|
||||||
const bgStyle: React.CSSProperties =
|
const bgStyle: React.CSSProperties =
|
||||||
effectiveBackgroundType === 'image' && effectiveBackgroundImageUrl
|
effectiveBackgroundType === 'image' && effectiveBackgroundImageUrl
|
||||||
? {
|
? {
|
||||||
@@ -984,6 +1033,8 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
}
|
}
|
||||||
: { backgroundColor: effectiveBackgroundColor };
|
: { backgroundColor: effectiveBackgroundColor };
|
||||||
|
|
||||||
|
const showLoadingOverlay = !hideMediaLoadingOverlay && !isMediaReady;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -998,49 +1049,73 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
height: `${height}px`,
|
height: `${height}px`,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
...(useHardPage
|
...(useHardPage && !showLoadingOverlay
|
||||||
? { backgroundColor: effectiveBackgroundColor, ...bgStyle }
|
? { backgroundColor: effectiveBackgroundColor, ...bgStyle }
|
||||||
: {}),
|
: {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!useHardPage && (
|
<div
|
||||||
<>
|
style={{
|
||||||
<div
|
position: 'absolute',
|
||||||
aria-hidden
|
inset: 0,
|
||||||
style={{
|
visibility: showLoadingOverlay ? 'hidden' : 'visible',
|
||||||
position: 'absolute',
|
}}
|
||||||
inset: 0,
|
>
|
||||||
...bgStyle,
|
{!useHardPage && (
|
||||||
pointerEvents: 'none',
|
<>
|
||||||
zIndex: 0,
|
<div
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{effectiveBackgroundType === 'video' && effectiveBackgroundVideoUrl && (
|
|
||||||
<video
|
|
||||||
aria-hidden
|
aria-hidden
|
||||||
src={effectiveBackgroundVideoUrl}
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
width: '100%',
|
...bgStyle,
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover',
|
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
{effectiveBackgroundType === 'video' && effectiveBackgroundVideoUrl && (
|
||||||
</>
|
<video
|
||||||
)}
|
aria-hidden
|
||||||
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
src={effectiveBackgroundVideoUrl}
|
||||||
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
||||||
|
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{useHardPage && showPaperShadow && <div aria-hidden className="page-paper-shadow" />}
|
{showLoadingOverlay && (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center bg-gray-100"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
role="status"
|
||||||
|
aria-label="در حال بارگذاری صفحه"
|
||||||
|
>
|
||||||
|
<div className="size-8 rounded-full border-2 border-gray-200 border-t-gray-600 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{useHardPage && showPaperShadow && !showLoadingOverlay && (
|
||||||
|
<div aria-hidden className="page-paper-shadow" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
const {
|
const {
|
||||||
scheduleEntranceForIndices,
|
scheduleEntranceForIndices,
|
||||||
getEntrancePhase,
|
getEntrancePhase,
|
||||||
|
replayEntranceForPage,
|
||||||
reset: resetEntrance,
|
reset: resetEntrance,
|
||||||
} = useBookEntranceController({ pages });
|
} = useBookEntranceController({ pages });
|
||||||
|
|
||||||
@@ -627,6 +628,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
key={page.id}
|
key={page.id}
|
||||||
page={page}
|
page={page}
|
||||||
entrancePhase={getEntrancePhase(page.id)}
|
entrancePhase={getEntrancePhase(page.id)}
|
||||||
|
onDeferredEntranceReady={replayEntranceForPage}
|
||||||
scale={contentScale}
|
scale={contentScale}
|
||||||
pageWidth={pagePixelWidth}
|
pageWidth={pagePixelWidth}
|
||||||
pageHeight={pagePixelHeight}
|
pageHeight={pagePixelHeight}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ const Magnifier = memo(
|
|||||||
backgroundVideoUrl={background.backgroundVideoUrl}
|
backgroundVideoUrl={background.backgroundVideoUrl}
|
||||||
useHardPage={useHardPage}
|
useHardPage={useHardPage}
|
||||||
disableEntranceAnimations
|
disableEntranceAnimations
|
||||||
|
hideMediaLoadingOverlay
|
||||||
showPaperShadow={false}
|
showPaperShadow={false}
|
||||||
idSuffix="-magnifier"
|
idSuffix="-magnifier"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -115,9 +115,31 @@ export function useBookEntranceController({ pages }: Options) {
|
|||||||
setPlayingPageIds(new Set());
|
setPlayingPageIds(new Set());
|
||||||
}, [clearPlayTimer]);
|
}, [clearPlayTimer]);
|
||||||
|
|
||||||
|
/** وقتی رسانهٔ صفحه دیرتر از زمانبندی انیمیشن لود شد، پخش را دوباره شروع میکند */
|
||||||
|
const replayEntranceForPage = useCallback(
|
||||||
|
(pageId: number) => {
|
||||||
|
if (!playedPageIdsRef.current.has(pageId)) return;
|
||||||
|
|
||||||
|
setPlayingPageIds((prev) => {
|
||||||
|
if (prev.has(pageId)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(pageId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
clearPlayTimer(pageId);
|
||||||
|
playTimersRef.current.set(
|
||||||
|
pageId,
|
||||||
|
setTimeout(() => finishPlaying(pageId), MAX_ENTRANCE_PLAY_MS),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[clearPlayTimer, finishPlaying],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scheduleEntranceForIndices,
|
scheduleEntranceForIndices,
|
||||||
getEntrancePhase,
|
getEntrancePhase,
|
||||||
|
replayEntranceForPage,
|
||||||
reset,
|
reset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { PageData } from '../types';
|
||||||
|
import {
|
||||||
|
extractPageMediaAssets,
|
||||||
|
preloadPagesMedia,
|
||||||
|
} from '../utils/pageMediaUrls';
|
||||||
|
|
||||||
|
type PageBackground = Pick<
|
||||||
|
PageData,
|
||||||
|
'backgroundType' | 'backgroundImageUrl' | 'backgroundVideoUrl'
|
||||||
|
>;
|
||||||
|
|
||||||
|
function mergePageBackground(
|
||||||
|
page: PageData,
|
||||||
|
background?: Partial<PageBackground>,
|
||||||
|
): PageData {
|
||||||
|
if (!background) return page;
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
backgroundType: background.backgroundType ?? page.backgroundType,
|
||||||
|
backgroundImageUrl:
|
||||||
|
background.backgroundImageUrl ?? page.backgroundImageUrl,
|
||||||
|
backgroundVideoUrl:
|
||||||
|
background.backgroundVideoUrl ?? page.backgroundVideoUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePageMediaReady(
|
||||||
|
page: PageData,
|
||||||
|
background?: Partial<PageBackground>,
|
||||||
|
enabled = true,
|
||||||
|
) {
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
|
||||||
|
const mediaKey = enabled
|
||||||
|
? extractPageMediaAssets([mergePageBackground(page, background)])
|
||||||
|
.map((asset) => `${asset.kind}:${asset.url}`)
|
||||||
|
.sort()
|
||||||
|
.join('|')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setIsReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedPage = mergePageBackground(page, background);
|
||||||
|
const assets = extractPageMediaAssets([mergedPage]);
|
||||||
|
|
||||||
|
if (assets.length === 0) {
|
||||||
|
setIsReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setIsReady(false);
|
||||||
|
|
||||||
|
void preloadPagesMedia([mergedPage]).then(() => {
|
||||||
|
if (!cancelled) setIsReady(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [enabled, page.id, mediaKey, background?.backgroundType, background?.backgroundImageUrl, background?.backgroundVideoUrl]);
|
||||||
|
|
||||||
|
return isReady;
|
||||||
|
}
|
||||||
@@ -127,7 +127,7 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
|
|||||||
if (obj.type === "rectangle" && obj.shapeType) {
|
if (obj.type === "rectangle" && obj.shapeType) {
|
||||||
baseObject.shapeType = obj.shapeType as EditorObject["shapeType"];
|
baseObject.shapeType = obj.shapeType as EditorObject["shapeType"];
|
||||||
}
|
}
|
||||||
if (obj.type === "rectangle" && obj.borderRadius !== undefined) {
|
if (obj.borderRadius !== undefined) {
|
||||||
baseObject.borderRadius = obj.borderRadius;
|
baseObject.borderRadius = obj.borderRadius;
|
||||||
}
|
}
|
||||||
if (obj.type === "rectangle" && obj.blur !== undefined) {
|
if (obj.type === "rectangle" && obj.blur !== undefined) {
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ export function extractPageMediaAssets(
|
|||||||
for (const element of page.elements) {
|
for (const element of page.elements) {
|
||||||
if (element.visible === false) continue;
|
if (element.visible === false) continue;
|
||||||
|
|
||||||
if (element.type === 'image' || element.type === 'sticker') {
|
if (
|
||||||
|
element.type === 'image' ||
|
||||||
|
element.type === 'sticker' ||
|
||||||
|
element.type === 'document'
|
||||||
|
) {
|
||||||
addImageUrl(assets, element.imageUrl);
|
addImageUrl(assets, element.imageUrl);
|
||||||
} else if (element.type === 'video') {
|
} else if (element.type === 'video') {
|
||||||
addVideoUrl(assets, element.videoUrl);
|
addVideoUrl(assets, element.videoUrl);
|
||||||
|
|||||||
Reference in New Issue
Block a user