mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'staging'
This commit is contained in:
@@ -1,18 +1,12 @@
|
|||||||
import { Hero } from "@/components/landing/hero";
|
import { Hero } from "@/components/landing/hero";
|
||||||
import { Header } from "@/components/header";
|
import { Header } from "@/components/header";
|
||||||
import { Footer } from "@/components/footer";
|
import { Footer } from "@/components/footer";
|
||||||
import { getWaitlistCount } from "@/lib/waitlist";
|
|
||||||
|
|
||||||
// Force dynamic rendering so waitlist count updates in real-time
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const signupCount = await getWaitlistCount();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Header />
|
<Header />
|
||||||
<Hero signupCount={signupCount} />
|
<Hero />
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
|
|
||||||
export function MediaView() {
|
export function MediaView() {
|
||||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||||
@@ -288,6 +289,11 @@ export function MediaView() {
|
|||||||
name: item.name,
|
name: item.name,
|
||||||
}}
|
}}
|
||||||
showPlusOnDrag={false}
|
showPlusOnDrag={false}
|
||||||
|
onAddToTimeline={(currentTime) =>
|
||||||
|
useTimelineStore
|
||||||
|
.getState()
|
||||||
|
.addMediaAtTime(item, currentTime)
|
||||||
|
}
|
||||||
rounded={false}
|
rounded={false}
|
||||||
/>
|
/>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|||||||
@@ -1,4 +1,30 @@
|
|||||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||||
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||||
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
|
import { type TextElement } from "@/types/timeline";
|
||||||
|
|
||||||
|
let textData: TextElement = {
|
||||||
|
id: "default-text",
|
||||||
|
type: "text",
|
||||||
|
name: "Default text",
|
||||||
|
content: "Default text",
|
||||||
|
fontSize: 48,
|
||||||
|
fontFamily: "Arial",
|
||||||
|
color: "#ffffff",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
fontWeight: "normal" as const,
|
||||||
|
fontStyle: "normal" as const,
|
||||||
|
textDecoration: "none" as const,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rotation: 0,
|
||||||
|
opacity: 1,
|
||||||
|
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||||
|
startTime: 0,
|
||||||
|
trimStart: 0,
|
||||||
|
trimEnd: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export function TextView() {
|
export function TextView() {
|
||||||
return (
|
return (
|
||||||
@@ -11,12 +37,15 @@ export function TextView() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
dragData={{
|
dragData={{
|
||||||
id: "default-text",
|
id: textData.id,
|
||||||
type: "text",
|
type: textData.type,
|
||||||
name: "Default text",
|
name: textData.name,
|
||||||
content: "Default text",
|
content: textData.content,
|
||||||
}}
|
}}
|
||||||
aspectRatio={1}
|
aspectRatio={1}
|
||||||
|
onAddToTimeline={(currentTime) =>
|
||||||
|
useTimelineStore.getState().addTextAtTime(textData, currentTime)
|
||||||
|
}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
|
|||||||
import {
|
import {
|
||||||
getTrackElementClasses,
|
getTrackElementClasses,
|
||||||
TIMELINE_CONSTANTS,
|
TIMELINE_CONSTANTS,
|
||||||
|
getTrackHeight,
|
||||||
} from "@/constants/timeline-constants";
|
} from "@/constants/timeline-constants";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -267,35 +268,99 @@ export function TimelineElement({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TILE_ASPECT_RATIO = 16 / 9;
|
||||||
|
|
||||||
if (mediaItem.type === "image") {
|
if (mediaItem.type === "image") {
|
||||||
|
// Calculate tile size based on 16:9 aspect ratio
|
||||||
|
const trackHeight = getTrackHeight(track.type);
|
||||||
|
const tileHeight = trackHeight - 8; // Account for padding
|
||||||
|
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<div className="bg-[#004D52] py-3 w-full h-full">
|
<div className="bg-[#004D52] py-3 w-full h-full relative">
|
||||||
<img
|
{/* Background with tiled images */}
|
||||||
src={mediaItem.url}
|
<div
|
||||||
alt={mediaItem.name}
|
className="absolute top-3 bottom-3 left-0 right-0"
|
||||||
className="w-full h-full object-cover"
|
style={{
|
||||||
draggable={false}
|
backgroundImage: mediaItem.url
|
||||||
|
? `url(${mediaItem.url})`
|
||||||
|
: "none",
|
||||||
|
backgroundRepeat: "repeat-x",
|
||||||
|
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
||||||
|
backgroundPosition: "left center",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
aria-label={`Tiled background of ${mediaItem.name}`}
|
||||||
|
/>
|
||||||
|
{/* Overlay with vertical borders */}
|
||||||
|
<div
|
||||||
|
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `repeating-linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent 0px,
|
||||||
|
transparent ${tileWidth - 1}px,
|
||||||
|
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
|
||||||
|
rgba(255, 255, 255, 0.6) ${tileWidth}px
|
||||||
|
)`,
|
||||||
|
backgroundPosition: "left center",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VIDEO_TILE_PADDING = 16;
|
||||||
|
const OVERLAY_SPACE_MULTIPLIER = 1.5;
|
||||||
|
|
||||||
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
|
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
|
||||||
|
const trackHeight = getTrackHeight(track.type);
|
||||||
|
const tileHeight = trackHeight - VIDEO_TILE_PADDING;
|
||||||
|
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center gap-2">
|
<div className="w-full h-full flex items-center gap-2">
|
||||||
<div className="w-8 h-8 flex-shrink-0">
|
<div className="flex-1 h-full relative overflow-hidden">
|
||||||
<img
|
{/* Background with tiled thumbnails */}
|
||||||
src={mediaItem.thumbnailUrl}
|
<div
|
||||||
alt={mediaItem.name}
|
className="absolute inset-0"
|
||||||
className="w-full h-full object-cover rounded-sm"
|
style={{
|
||||||
draggable={false}
|
backgroundImage: mediaItem.thumbnailUrl
|
||||||
|
? `url(${mediaItem.thumbnailUrl})`
|
||||||
|
: "none",
|
||||||
|
backgroundRepeat: "repeat-x",
|
||||||
|
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
||||||
|
backgroundPosition: "left center",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
aria-label={`Tiled thumbnail of ${mediaItem.name}`}
|
||||||
|
/>
|
||||||
|
{/* Overlay with vertical borders */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `repeating-linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent 0px,
|
||||||
|
transparent ${tileWidth - 1}px,
|
||||||
|
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
|
||||||
|
rgba(255, 255, 255, 0.6) ${tileWidth}px
|
||||||
|
)`,
|
||||||
|
backgroundPosition: "left center",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-foreground/80 truncate flex-1">
|
{elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
|
||||||
{element.name}
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
|
||||||
</span>
|
{element.name}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
|
||||||
|
{element.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
TypeIcon,
|
TypeIcon,
|
||||||
Magnet,
|
Magnet,
|
||||||
Lock,
|
Lock,
|
||||||
|
LockOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -376,29 +377,7 @@ export function Timeline() {
|
|||||||
|
|
||||||
if (dragData.type === "text") {
|
if (dragData.type === "text") {
|
||||||
// Always create new text track to avoid overlaps
|
// Always create new text track to avoid overlaps
|
||||||
const newTrackId = addTrack("text");
|
useTimelineStore.getState().addTextToNewTrack(dragData);
|
||||||
|
|
||||||
addElementToTrack(newTrackId, {
|
|
||||||
type: "text",
|
|
||||||
name: dragData.name || "Text",
|
|
||||||
content: dragData.content || "Default Text",
|
|
||||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
|
||||||
startTime: 0,
|
|
||||||
trimStart: 0,
|
|
||||||
trimEnd: 0,
|
|
||||||
fontSize: 48,
|
|
||||||
fontFamily: "Arial",
|
|
||||||
color: "#ffffff",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
textAlign: "center",
|
|
||||||
fontWeight: "normal",
|
|
||||||
fontStyle: "normal",
|
|
||||||
textDecoration: "none",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
rotation: 0,
|
|
||||||
opacity: 1,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Handle media items
|
// Handle media items
|
||||||
const mediaItem = mediaItems.find((item) => item.id === dragData.id);
|
const mediaItem = mediaItems.find((item) => item.id === dragData.id);
|
||||||
@@ -407,19 +386,7 @@ export function Timeline() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackType = dragData.type === "audio" ? "audio" : "media";
|
useTimelineStore.getState().addMediaToNewTrack(mediaItem);
|
||||||
let targetTrack = tracks.find((t) => t.type === trackType);
|
|
||||||
const newTrackId = targetTrack ? targetTrack.id : addTrack(trackType);
|
|
||||||
|
|
||||||
addElementToTrack(newTrackId, {
|
|
||||||
type: "media",
|
|
||||||
mediaId: mediaItem.id,
|
|
||||||
name: mediaItem.name,
|
|
||||||
duration: mediaItem.duration || 5,
|
|
||||||
startTime: 0,
|
|
||||||
trimStart: 0,
|
|
||||||
trimEnd: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error parsing dropped item data:", error);
|
console.error("Error parsing dropped item data:", error);
|
||||||
@@ -447,18 +414,7 @@ export function Timeline() {
|
|||||||
item.name === processedItem.name && item.url === processedItem.url
|
item.name === processedItem.name && item.url === processedItem.url
|
||||||
);
|
);
|
||||||
if (addedItem) {
|
if (addedItem) {
|
||||||
const trackType =
|
useTimelineStore.getState().addMediaToNewTrack(addedItem);
|
||||||
processedItem.type === "audio" ? "audio" : "media";
|
|
||||||
const newTrackId = addTrack(trackType);
|
|
||||||
addElementToTrack(newTrackId, {
|
|
||||||
type: "media",
|
|
||||||
mediaId: addedItem.id,
|
|
||||||
name: addedItem.name,
|
|
||||||
duration: addedItem.duration || 5,
|
|
||||||
startTime: 0,
|
|
||||||
trimStart: 0,
|
|
||||||
trimEnd: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -853,7 +809,11 @@ export function Timeline() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="text" size="icon" onClick={toggleSnapping}>
|
<Button variant="text" size="icon" onClick={toggleSnapping}>
|
||||||
<Lock className="h-4 w-4" />
|
{snappingEnabled ? (
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<LockOpen className="h-4 w-4 text-primary" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Auto snapping</TooltipContent>
|
<TooltipContent>Auto snapping</TooltipContent>
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import { toast } from "sonner";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Handlebars } from "./handlebars";
|
import { Handlebars } from "./handlebars";
|
||||||
|
|
||||||
interface HeroProps {
|
export function Hero() {
|
||||||
signupCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Hero({ signupCount }: HeroProps) {
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -131,18 +127,6 @@ export function Hero({ signupCount }: HeroProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{signupCount > 0 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.8, duration: 0.6 }}
|
|
||||||
className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center"
|
|
||||||
>
|
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
|
||||||
<span>{signupCount.toLocaleString()} people already joined</span>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { ReactNode, useState, useRef, useEffect } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
|
|
||||||
export interface DraggableMediaItemProps {
|
export interface DraggableMediaItemProps {
|
||||||
name: string;
|
name: string;
|
||||||
preview: ReactNode;
|
preview: ReactNode;
|
||||||
dragData: Record<string, any>;
|
dragData: Record<string, any>;
|
||||||
onDragStart?: (e: React.DragEvent) => void;
|
onDragStart?: (e: React.DragEvent) => void;
|
||||||
|
onAddToTimeline?: (currentTime: number) => void;
|
||||||
aspectRatio?: number;
|
aspectRatio?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
showPlusOnDrag?: boolean;
|
showPlusOnDrag?: boolean;
|
||||||
@@ -24,6 +26,7 @@ export function DraggableMediaItem({
|
|||||||
preview,
|
preview,
|
||||||
dragData,
|
dragData,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
|
onAddToTimeline,
|
||||||
aspectRatio = 16 / 9,
|
aspectRatio = 16 / 9,
|
||||||
className = "",
|
className = "",
|
||||||
showPlusOnDrag = true,
|
showPlusOnDrag = true,
|
||||||
@@ -33,6 +36,11 @@ export function DraggableMediaItem({
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||||
const dragRef = useRef<HTMLDivElement>(null);
|
const dragRef = useRef<HTMLDivElement>(null);
|
||||||
|
const currentTime = usePlaybackStore((state) => state.currentTime);
|
||||||
|
|
||||||
|
const handleAddToTimeline = () => {
|
||||||
|
onAddToTimeline?.(currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
const emptyImg = new window.Image();
|
const emptyImg = new window.Image();
|
||||||
emptyImg.src =
|
emptyImg.src =
|
||||||
@@ -92,7 +100,10 @@ export function DraggableMediaItem({
|
|||||||
>
|
>
|
||||||
{preview}
|
{preview}
|
||||||
{!isDragging && (
|
{!isDragging && (
|
||||||
<PlusButton className="opacity-0 group-hover:opacity-100" />
|
<PlusButton
|
||||||
|
className="opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={handleAddToTimeline}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
@@ -128,7 +139,7 @@ export function DraggableMediaItem({
|
|||||||
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
|
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
|
||||||
{preview}
|
{preview}
|
||||||
</div>
|
</div>
|
||||||
{showPlusOnDrag && <PlusButton />}
|
{showPlusOnDrag && <PlusButton onClick={handleAddToTimeline} tooltipText="Add to timeline or drag to position" />}
|
||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -138,11 +149,16 @@ export function DraggableMediaItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlusButton({ className }: { className?: string }) {
|
function PlusButton({ className, onClick }: { className?: string; onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn("absolute bottom-2 right-2 size-4", className)}
|
className={cn("absolute bottom-2 right-2 size-4", className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="!size-3" />
|
<Plus className="!size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 overflow-hidden rounded-md bg-foreground/10 px-3 py-1.5 text-xs text-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const TIMELINE_CONSTANTS = {
|
|||||||
PIXELS_PER_SECOND: 50,
|
PIXELS_PER_SECOND: 50,
|
||||||
TRACK_HEIGHT: 60, // Default fallback
|
TRACK_HEIGHT: 60, // Default fallback
|
||||||
DEFAULT_TEXT_DURATION: 5,
|
DEFAULT_TEXT_DURATION: 5,
|
||||||
|
DEFAULT_IMAGE_DURATION: 5,
|
||||||
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
|
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,22 @@ import {
|
|||||||
CreateTimelineElement,
|
CreateTimelineElement,
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
TextElement,
|
TextElement,
|
||||||
|
DragData,
|
||||||
sortTracksByOrder,
|
sortTracksByOrder,
|
||||||
ensureMainTrack,
|
ensureMainTrack,
|
||||||
validateElementTrackCompatibility,
|
validateElementTrackCompatibility,
|
||||||
} from "@/types/timeline";
|
} from "@/types/timeline";
|
||||||
import { useEditorStore } from "./editor-store";
|
import { useEditorStore } from "./editor-store";
|
||||||
import { useMediaStore, getMediaAspectRatio } from "./media-store";
|
import {
|
||||||
|
useMediaStore,
|
||||||
|
getMediaAspectRatio,
|
||||||
|
type MediaItem,
|
||||||
|
} from "./media-store";
|
||||||
import { storageService } from "@/lib/storage/storage-service";
|
import { storageService } from "@/lib/storage/storage-service";
|
||||||
import { useProjectStore } from "./project-store";
|
import { useProjectStore } from "./project-store";
|
||||||
import { generateUUID } from "@/lib/utils";
|
import { generateUUID } from "@/lib/utils";
|
||||||
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// Helper function to manage element naming with suffixes
|
// Helper function to manage element naming with suffixes
|
||||||
const getElementNameWithSuffix = (
|
const getElementNameWithSuffix = (
|
||||||
@@ -166,6 +173,17 @@ interface TimelineStore {
|
|||||||
>
|
>
|
||||||
>
|
>
|
||||||
) => void;
|
) => void;
|
||||||
|
checkElementOverlap: (
|
||||||
|
trackId: string,
|
||||||
|
startTime: number,
|
||||||
|
duration: number,
|
||||||
|
excludeElementId?: string
|
||||||
|
) => boolean;
|
||||||
|
findOrCreateTrack: (trackType: TrackType) => string;
|
||||||
|
addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean;
|
||||||
|
addTextAtTime: (item: TextElement, currentTime?: number) => boolean;
|
||||||
|
addMediaToNewTrack: (item: MediaItem) => boolean;
|
||||||
|
addTextToNewTrack: (item: TextElement | DragData) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTimelineStore = create<TimelineStore>((set, get) => {
|
export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||||
@@ -963,5 +981,149 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
toggleSnapping: () => {
|
toggleSnapping: () => {
|
||||||
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
|
set((state) => ({ snappingEnabled: !state.snappingEnabled }));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
|
||||||
|
const track = get()._tracks.find((t) => t.id === trackId);
|
||||||
|
if (!track) return false;
|
||||||
|
|
||||||
|
const overlap = track.elements.some((element) => {
|
||||||
|
const elementEnd =
|
||||||
|
element.startTime +
|
||||||
|
element.duration -
|
||||||
|
element.trimStart -
|
||||||
|
element.trimEnd;
|
||||||
|
|
||||||
|
if (element.id === excludeElementId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(startTime >= element.startTime && startTime < elementEnd) ||
|
||||||
|
(startTime + duration > element.startTime &&
|
||||||
|
startTime + duration <= elementEnd) ||
|
||||||
|
(startTime < element.startTime && startTime + duration > elementEnd)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return overlap;
|
||||||
|
},
|
||||||
|
|
||||||
|
findOrCreateTrack: (trackType) => {
|
||||||
|
// Always create new text track to allow multiple text elements
|
||||||
|
if (trackType === "text") {
|
||||||
|
return get().addTrack(trackType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTrack = get()._tracks.find((t) => t.type === trackType);
|
||||||
|
if (existingTrack) {
|
||||||
|
return existingTrack.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return get().addTrack(trackType);
|
||||||
|
},
|
||||||
|
|
||||||
|
addMediaAtTime: (item, currentTime = 0) => {
|
||||||
|
const trackType = item.type === "audio" ? "audio" : "media";
|
||||||
|
const targetTrackId = get().findOrCreateTrack(trackType);
|
||||||
|
|
||||||
|
const duration =
|
||||||
|
item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
|
||||||
|
|
||||||
|
if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
|
||||||
|
toast.error(
|
||||||
|
"Cannot place element here - it would overlap with existing elements"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get().addElementToTrack(targetTrackId, {
|
||||||
|
type: "media",
|
||||||
|
mediaId: item.id,
|
||||||
|
name: item.name,
|
||||||
|
duration,
|
||||||
|
startTime: currentTime,
|
||||||
|
trimStart: 0,
|
||||||
|
trimEnd: 0,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
addTextAtTime: (item, currentTime = 0) => {
|
||||||
|
const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
|
||||||
|
|
||||||
|
get().addElementToTrack(targetTrackId, {
|
||||||
|
type: "text",
|
||||||
|
name: item.name || "Text",
|
||||||
|
content: item.content || "Default Text",
|
||||||
|
duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||||
|
startTime: currentTime,
|
||||||
|
trimStart: 0,
|
||||||
|
trimEnd: 0,
|
||||||
|
fontSize: item.fontSize || 48,
|
||||||
|
fontFamily: item.fontFamily || "Arial",
|
||||||
|
color: item.color || "#ffffff",
|
||||||
|
backgroundColor: item.backgroundColor || "transparent",
|
||||||
|
textAlign: item.textAlign || "center",
|
||||||
|
fontWeight: item.fontWeight || "normal",
|
||||||
|
fontStyle: item.fontStyle || "normal",
|
||||||
|
textDecoration: item.textDecoration || "none",
|
||||||
|
x: item.x || 0,
|
||||||
|
y: item.y || 0,
|
||||||
|
rotation: item.rotation || 0,
|
||||||
|
opacity: item.opacity !== undefined ? item.opacity : 1,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
addMediaToNewTrack: (item) => {
|
||||||
|
const trackType = item.type === "audio" ? "audio" : "media";
|
||||||
|
const targetTrackId = get().findOrCreateTrack(trackType);
|
||||||
|
|
||||||
|
get().addElementToTrack(targetTrackId, {
|
||||||
|
type: "media",
|
||||||
|
mediaId: item.id,
|
||||||
|
name: item.name,
|
||||||
|
duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
|
||||||
|
startTime: 0,
|
||||||
|
trimStart: 0,
|
||||||
|
trimEnd: 0,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
addTextToNewTrack: (item) => {
|
||||||
|
const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
|
||||||
|
|
||||||
|
get().addElementToTrack(targetTrackId, {
|
||||||
|
type: "text",
|
||||||
|
name: item.name || "Text",
|
||||||
|
content:
|
||||||
|
("content" in item ? item.content : "Default Text") || "Default Text",
|
||||||
|
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||||
|
startTime: 0,
|
||||||
|
trimStart: 0,
|
||||||
|
trimEnd: 0,
|
||||||
|
fontSize: ("fontSize" in item ? item.fontSize : 48) || 48,
|
||||||
|
fontFamily:
|
||||||
|
("fontFamily" in item ? item.fontFamily : "Arial") || "Arial",
|
||||||
|
color: ("color" in item ? item.color : "#ffffff") || "#ffffff",
|
||||||
|
backgroundColor:
|
||||||
|
("backgroundColor" in item ? item.backgroundColor : "transparent") ||
|
||||||
|
"transparent",
|
||||||
|
textAlign:
|
||||||
|
("textAlign" in item ? item.textAlign : "center") || "center",
|
||||||
|
fontWeight:
|
||||||
|
("fontWeight" in item ? item.fontWeight : "normal") || "normal",
|
||||||
|
fontStyle:
|
||||||
|
("fontStyle" in item ? item.fontStyle : "normal") || "normal",
|
||||||
|
textDecoration:
|
||||||
|
("textDecoration" in item ? item.textDecoration : "none") || "none",
|
||||||
|
x: ("x" in item ? item.x : 0) || 0,
|
||||||
|
y: ("y" in item ? item.y : 0) || 0,
|
||||||
|
rotation: ("rotation" in item ? item.rotation : 0) || 0,
|
||||||
|
opacity:
|
||||||
|
"opacity" in item && item.opacity !== undefined ? item.opacity : 1,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user