Compare commits
3 Commits
85ba8e4261
...
2b4fd31ea2
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b4fd31ea2 | |||
| 168af3bf95 | |||
| 0bef54c51c |
@@ -1,33 +1,55 @@
|
||||
import { type FC } from "react";
|
||||
import { DocumentUpload, VideoSquare } from "iconsax-react";
|
||||
import { clx } from "@/helpers/utils";
|
||||
|
||||
type VideoUploadZoneProps = {
|
||||
getRootProps: () => React.HTMLAttributes<HTMLDivElement>;
|
||||
getInputProps: () => React.InputHTMLAttributes<HTMLInputElement>;
|
||||
isLoading?: boolean;
|
||||
loadingLabel?: string;
|
||||
};
|
||||
|
||||
const VideoUploadZone: FC<VideoUploadZoneProps> = ({ getRootProps, getInputProps }) => {
|
||||
const VideoUploadZone: FC<VideoUploadZoneProps> = ({
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isLoading = false,
|
||||
loadingLabel = "در حال آپلود...",
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className="w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
className={clx(
|
||||
"w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl transition-colors",
|
||||
isLoading
|
||||
? "pointer-events-none opacity-80"
|
||||
: "cursor-pointer hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<VideoSquare
|
||||
size={32}
|
||||
color="#8C90A3"
|
||||
variant="Outline"
|
||||
/>
|
||||
<div className="text-description text-xs">
|
||||
ویدیو مورد نظر را آپلود کنید
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentUpload
|
||||
size={16}
|
||||
color="black"
|
||||
/>
|
||||
<div className="text-xs">آپلود</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-black" />
|
||||
<div className="text-description text-xs">{loadingLabel}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<VideoSquare
|
||||
size={32}
|
||||
color="#8C90A3"
|
||||
variant="Outline"
|
||||
/>
|
||||
<div className="text-description text-xs">
|
||||
ویدیو مورد نظر را آپلود کنید
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentUpload
|
||||
size={16}
|
||||
color="black"
|
||||
/>
|
||||
<div className="text-xs">آپلود</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ const CatalogPreview: FC<Props> = ({ item, page, size, className, selected }) =>
|
||||
height: pageHeight,
|
||||
}}
|
||||
>
|
||||
<BookPage page={firstPage} scale={1} pageWidth={pageWidth} pageHeight={pageHeight} disableEntranceAnimations showPaperShadow={false} />
|
||||
<BookPage page={firstPage} scale={1} pageWidth={pageWidth} pageHeight={pageHeight} disableEntranceAnimations staticMedia showPaperShadow={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FC, useCallback, useMemo } from "react";
|
||||
import { type FC, useCallback, useMemo, useState } from "react";
|
||||
import { useEditorStore, type ToolType } from "@/pages/editor/store/editorStore";
|
||||
import { useSingleUpload } from "@/pages/uploader/hooks/useUploaderData";
|
||||
import { CloseCircle } from "iconsax-react";
|
||||
@@ -9,30 +9,38 @@ const VideoInput: FC = () => {
|
||||
const { objects, addObject, setSelectedObjectId, setTool, deleteObject } =
|
||||
useEditorStore();
|
||||
const { mutateAsync: uploadFile } = useSingleUpload();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
for (const file of acceptedFiles) {
|
||||
try {
|
||||
const result = await uploadFile(file);
|
||||
const videoUrl = result?.data?.url;
|
||||
if (!videoUrl) continue;
|
||||
const videoId = `video-${Date.now()}-${Math.random()}`;
|
||||
const newVideo = {
|
||||
id: videoId,
|
||||
type: "video" as ToolType,
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 300,
|
||||
videoUrl,
|
||||
};
|
||||
addObject(newVideo);
|
||||
setSelectedObjectId(newVideo.id);
|
||||
setTool("select");
|
||||
} catch {
|
||||
// TODO: show error toast
|
||||
if (acceptedFiles.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
for (const file of acceptedFiles) {
|
||||
try {
|
||||
const result = await uploadFile(file);
|
||||
const videoUrl = result?.data?.url;
|
||||
if (!videoUrl) continue;
|
||||
const videoId = `video-${Date.now()}-${Math.random()}`;
|
||||
const newVideo = {
|
||||
id: videoId,
|
||||
type: "video" as ToolType,
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 300,
|
||||
videoUrl,
|
||||
};
|
||||
addObject(newVideo);
|
||||
setSelectedObjectId(newVideo.id);
|
||||
setTool("select");
|
||||
} catch {
|
||||
// TODO: show error toast
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[addObject, setSelectedObjectId, setTool, uploadFile]
|
||||
@@ -43,7 +51,8 @@ const VideoInput: FC = () => {
|
||||
accept: {
|
||||
'video/*': ['.mp4', '.webm', '.ogg', '.mov', '.avi']
|
||||
},
|
||||
multiple: true
|
||||
multiple: true,
|
||||
disabled: isUploading,
|
||||
});
|
||||
|
||||
const videoObjects = useMemo(() => {
|
||||
@@ -61,6 +70,7 @@ const VideoInput: FC = () => {
|
||||
<VideoUploadZone
|
||||
getRootProps={getRootProps}
|
||||
getInputProps={getInputProps}
|
||||
isLoading={isUploading}
|
||||
/>
|
||||
|
||||
{videoObjects.length > 0 && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Image as KonvaImage, Rect, Group, Circle, Text as KonvaText } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import useImage from "use-image";
|
||||
@@ -73,6 +73,35 @@ const VideoShape = ({
|
||||
const containerWidth = obj.width || 400;
|
||||
const containerHeight = obj.height || 300;
|
||||
|
||||
// همتراز با viewer که object-fit: contain دارد
|
||||
const imageLayout = useMemo(() => {
|
||||
if (!image) {
|
||||
return { x: 0, y: 0, width: containerWidth, height: containerHeight };
|
||||
}
|
||||
|
||||
const naturalWidth = image.width || 1;
|
||||
const naturalHeight = image.height || 1;
|
||||
const containerRatio = containerWidth / containerHeight;
|
||||
const imageRatio = naturalWidth / naturalHeight;
|
||||
|
||||
let width: number;
|
||||
let height: number;
|
||||
if (imageRatio > containerRatio) {
|
||||
width = containerWidth;
|
||||
height = containerWidth / imageRatio;
|
||||
} else {
|
||||
height = containerHeight;
|
||||
width = containerHeight * imageRatio;
|
||||
}
|
||||
|
||||
return {
|
||||
x: (containerWidth - width) / 2,
|
||||
y: (containerHeight - height) / 2,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}, [image, containerWidth, containerHeight]);
|
||||
|
||||
const handlePlayClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
e.cancelBubble = true;
|
||||
const evt = e.evt;
|
||||
@@ -121,20 +150,30 @@ const VideoShape = ({
|
||||
<Rect
|
||||
width={containerWidth}
|
||||
height={containerHeight}
|
||||
fill="#000000"
|
||||
fill="transparent"
|
||||
stroke={isSelected ? "#3b82f6" : "#666666"}
|
||||
strokeWidth={isSelected ? 3 : (obj.strokeWidth ?? 0)}
|
||||
onClick={handleGroupClick}
|
||||
/>
|
||||
{image ? (
|
||||
<KonvaImage
|
||||
x={0}
|
||||
y={0}
|
||||
width={containerWidth}
|
||||
height={containerHeight}
|
||||
image={image}
|
||||
onClick={handleGroupClick}
|
||||
/>
|
||||
<>
|
||||
<Rect
|
||||
x={imageLayout.x}
|
||||
y={imageLayout.y}
|
||||
width={imageLayout.width}
|
||||
height={imageLayout.height}
|
||||
fill="#000000"
|
||||
onClick={handleGroupClick}
|
||||
/>
|
||||
<KonvaImage
|
||||
x={imageLayout.x}
|
||||
y={imageLayout.y}
|
||||
width={imageLayout.width}
|
||||
height={imageLayout.height}
|
||||
image={image}
|
||||
onClick={handleGroupClick}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Rect
|
||||
x={0}
|
||||
|
||||
@@ -38,6 +38,8 @@ const getRasterObjectStyle = (
|
||||
|
||||
type RenderObjectOptions = {
|
||||
skipEntrance?: boolean;
|
||||
/** در پیشنمایش (لیست صفحات و کاتالوگ) رسانه تعاملی نباشد */
|
||||
staticMedia?: boolean;
|
||||
};
|
||||
|
||||
type BookPageProps = {
|
||||
@@ -56,8 +58,10 @@ type BookPageProps = {
|
||||
backgroundImageUrl?: string;
|
||||
/** فاز انیمیشن ورود — از BookViewer کنترل میشود */
|
||||
entrancePhase?: EntrancePhase;
|
||||
/** در پیشنمایش کاتالوگ انیمیشن ورود غیرفعال باشد */
|
||||
/** در پیشنمایش کاتالوگ انیمی션 ورود غیرفعال باشد */
|
||||
disableEntranceAnimations?: boolean;
|
||||
/** ویدیو/صوت فقط بهصورت کاور — بدون کنترل و بدون کلیک */
|
||||
staticMedia?: boolean;
|
||||
/** سایهٔ لبهٔ کاغذ (گرادیان داخل DOM — سازگار با iOS قدیمی) */
|
||||
showPaperShadow?: boolean;
|
||||
/** ورق سخت بهجای clip-path برای iOS قدیمی */
|
||||
@@ -78,7 +82,7 @@ type BookPageProps = {
|
||||
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
|
||||
*/
|
||||
const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, entrancePhase = 'idle', disableEntranceAnimations = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => {
|
||||
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, entrancePhase = 'idle', disableEntranceAnimations = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => {
|
||||
const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase;
|
||||
// تابع برای تبدیل opacity به رنگ (مثل editor)
|
||||
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
|
||||
@@ -243,6 +247,35 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
);
|
||||
|
||||
case 'video':
|
||||
if (options.staticMedia) {
|
||||
return (
|
||||
<video
|
||||
key={obj.id || index}
|
||||
src={obj.videoUrl || ''}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={applyStyle(
|
||||
{
|
||||
...baseStyle,
|
||||
width: obj.width ? `${obj.width * scale}px` : 'auto',
|
||||
height: obj.height ? `${obj.height * scale}px` : 'auto',
|
||||
objectFit: 'contain',
|
||||
zIndex: index,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
obj,
|
||||
index,
|
||||
)}
|
||||
onLoadedMetadata={(e) => {
|
||||
const video = e.currentTarget;
|
||||
if (video.currentTime === 0) {
|
||||
video.currentTime = 0.1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<video
|
||||
key={obj.id || index}
|
||||
@@ -273,6 +306,26 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
);
|
||||
|
||||
case 'audio':
|
||||
if (options.staticMedia) {
|
||||
return (
|
||||
<div
|
||||
key={obj.id || index}
|
||||
style={applyStyle(
|
||||
{
|
||||
...baseStyle,
|
||||
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
|
||||
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderRadius: `${4 * scale}px`,
|
||||
zIndex: index,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
obj,
|
||||
index,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<audio
|
||||
key={obj.id || index}
|
||||
@@ -837,7 +890,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
: null;
|
||||
|
||||
if (!maskShape) {
|
||||
return renderObjectContent(obj, index);
|
||||
return renderObjectContent(obj, index, { staticMedia });
|
||||
}
|
||||
|
||||
const layout = getMaskedLayout(obj, maskShape, scale);
|
||||
@@ -847,7 +900,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
const content = renderObjectContent(
|
||||
{ ...obj, x: (obj.x || 0) - unionLeft, y: (obj.y || 0) - unionTop },
|
||||
index,
|
||||
{ skipEntrance: true },
|
||||
{ skipEntrance: true, staticMedia },
|
||||
);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
Reference in New Issue
Block a user