diff options
Diffstat (limited to 'apps/web/src')
| -rw-r--r-- | apps/web/src/app/projects/page.tsx | 1091 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 2495 |
2 files changed, 1810 insertions, 1776 deletions
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 7ac08107..a1a61d14 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -1,607 +1,592 @@ "use client"; -import Link from "next/link"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; import { - ChevronLeft, - Plus, - Calendar, - MoreHorizontal, - Video, - Loader2, - X, - Trash2, - Search, + Calendar, + ChevronLeft, + Loader2, + MoreHorizontal, + Plus, + Search, + Trash2, + Video, + X, } from "lucide-react"; -import { TProject } from "@/types/project"; import Image from "next/image"; -import { - DropdownMenu, - DropdownMenuItem, - DropdownMenuContent, - DropdownMenuTrigger, - DropdownMenuSeparator, -} from "@/components/ui/dropdown-menu"; -import { useProjectStore } from "@/stores/project-store"; +import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; import { DeleteProjectDialog } from "@/components/delete-project-dialog"; import { RenameProjectDialog } from "@/components/rename-project-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import type { TProject } from "@/types/project"; export default function ProjectsPage() { - const { - createNewProject, - savedProjects, - isLoading, - isInitialized, - deleteProject, - getFilteredAndSortedProjects, - } = useProjectStore(); - const router = useRouter(); - const [isSelectionMode, setIsSelectionMode] = useState(false); - const [selectedProjects, setSelectedProjects] = useState<Set<string>>( - new Set() - ); - const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [sortOption, setSortOption] = useState("createdAt-desc"); + const { + savedProjects, + isLoading, + isInitialized, + deleteProject, + createNewProject, + getFilteredAndSortedProjects, + } = useProjectStore(); + const [thumbnailCache, setThumbnailCache] = useState< + Record<string, string | null> + >({}); + const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>( + new Set(), + ); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectedProjects, setSelectedProjects] = useState<Set<string>>( + new Set(), + ); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [sortOption, setSortOption] = useState("createdAt-desc"); + const router = useRouter(); + + const getProjectThumbnail = useCallback( + async (projectId: string): Promise<string | null> => { + if (thumbnailCache[projectId] !== undefined) { + return thumbnailCache[projectId]; + } + + setLoadingThumbnails((prev) => new Set(prev).add(projectId)); - const handleCreateProject = async () => { - const projectId = await createNewProject("New Project"); - console.log("projectId", projectId); - router.push(`/editor/${projectId}`); - }; + try { + const thumbnail = await useTimelineStore + .getState() + .getProjectThumbnail(projectId); + setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail })); + return thumbnail; + } finally { + setLoadingThumbnails((prev) => { + const newSet = new Set(prev); + newSet.delete(projectId); + return newSet; + }); + } + }, + [thumbnailCache], + ); - const handleSelectProject = (projectId: string, checked: boolean) => { - const newSelected = new Set(selectedProjects); - if (checked) { - newSelected.add(projectId); - } else { - newSelected.delete(projectId); - } - setSelectedProjects(newSelected); - }; + const handleCreateProject = async () => { + const projectId = await createNewProject("New Project"); + console.log("projectId", projectId); + router.push(`/editor/${projectId}`); + }; - const handleSelectAll = (checked: boolean) => { - if (checked) { - setSelectedProjects(new Set(sortedProjects.map((p) => p.id))); - } else { - setSelectedProjects(new Set()); - } - }; + const handleSelectProject = (projectId: string, checked: boolean) => { + const newSelected = new Set(selectedProjects); + if (checked) { + newSelected.add(projectId); + } else { + newSelected.delete(projectId); + } + setSelectedProjects(newSelected); + }; - const handleCancelSelection = () => { - setIsSelectionMode(false); - setSelectedProjects(new Set()); - }; + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedProjects(new Set(sortedProjects.map((p) => p.id))); + } else { + setSelectedProjects(new Set()); + } + }; - const handleBulkDelete = async () => { - await Promise.all( - Array.from(selectedProjects).map((projectId) => deleteProject(projectId)) - ); - setSelectedProjects(new Set()); - setIsSelectionMode(false); - setIsBulkDeleteDialogOpen(false); - }; + const handleCancelSelection = () => { + setIsSelectionMode(false); + setSelectedProjects(new Set()); + }; - const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption); + const handleBulkDelete = async () => { + await Promise.all( + Array.from(selectedProjects).map((projectId) => deleteProject(projectId)), + ); + setSelectedProjects(new Set()); + setIsSelectionMode(false); + setIsBulkDeleteDialogOpen(false); + }; - const allSelected = - sortedProjects.length > 0 && - selectedProjects.size === sortedProjects.length; - const someSelected = - selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length; + const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption); - return ( - <div className="min-h-screen bg-background"> - <div className="pt-6 px-6 flex items-center justify-between w-full h-16"> - <Link - href="/" - className="flex items-center gap-1 hover:text-muted-foreground transition-colors" - > - <ChevronLeft className="!size-5 shrink-0" /> - <span className="text-sm font-medium">Back</span> - </Link> - <div className="block md:hidden"> - {isSelectionMode ? ( - <div className="flex items-center gap-2"> - <Button - variant="outline" - size="sm" - onClick={handleCancelSelection} - > - <X className="!size-4" /> - Cancel - </Button> - {selectedProjects.size > 0 && ( - <Button - variant="destructive" - size="sm" - onClick={() => setIsBulkDeleteDialogOpen(true)} - > - <Trash2 className="!size-4" /> - Delete ({selectedProjects.size}) - </Button> - )} - </div> - ) : ( - <CreateButton onClick={handleCreateProject} /> - )} - </div> - </div> - <main className="max-w-6xl mx-auto px-6 pt-6 pb-6"> - <div className="mb-8 flex items-center justify-between"> - <div className="flex flex-col gap-3"> - <h1 className="text-2xl md:text-3xl font-bold tracking-tight"> - Your Projects - </h1> - <p className="text-muted-foreground"> - {savedProjects.length}{" "} - {savedProjects.length === 1 ? "project" : "projects"} - {isSelectionMode && selectedProjects.size > 0 && ( - <span className="ml-2 text-primary"> - • {selectedProjects.size} selected - </span> - )} - </p> - </div> - <div className="hidden md:block"> - {isSelectionMode ? ( - <div className="flex items-center gap-2"> - <Button variant="outline" onClick={handleCancelSelection}> - <X className="!size-4" /> - Cancel - </Button> - {selectedProjects.size > 0 && ( - <Button - variant="destructive" - onClick={() => setIsBulkDeleteDialogOpen(true)} - > - <Trash2 className="!size-4" /> - Delete Selected ({selectedProjects.size}) - </Button> - )} - </div> - ) : ( - <div className="flex items-center gap-2"> - <Button - variant="outline" - onClick={() => setIsSelectionMode(true)} - disabled={savedProjects.length === 0} - > - Select Projects - </Button> - <CreateButton onClick={handleCreateProject} /> - </div> - )} - </div> - </div> + const allSelected = + sortedProjects.length > 0 && + selectedProjects.size === sortedProjects.length; + const someSelected = + selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length; - <div className="mb-4 flex items-center justify-between gap-4"> - <div className="flex-1 max-w-72"> - <Input - placeholder="Search projects..." - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - /> - </div> - <Select value={sortOption} onValueChange={setSortOption}> - <SelectTrigger className="w-[180px]"> - <SelectValue placeholder="Sort by" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="createdAt-desc">Newest to Oldest</SelectItem> - <SelectItem value="createdAt-asc">Oldest to Newest</SelectItem> - <SelectItem value="name-asc">Name (A-Z)</SelectItem> - <SelectItem value="name-desc">Name (Z-A)</SelectItem> - </SelectContent> - </Select> - </div> + return ( + <div className="min-h-screen bg-background"> + <div className="pt-6 px-6 flex items-center justify-between w-full h-16"> + <Link + href="/" + className="flex items-center gap-1 hover:text-muted-foreground transition-colors" + > + <ChevronLeft className="!size-5 shrink-0" /> + <span className="text-sm font-medium">Back</span> + </Link> + <div className="block md:hidden"> + {isSelectionMode ? ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleCancelSelection} + > + <X className="!size-4" /> + Cancel + </Button> + {selectedProjects.size > 0 && ( + <Button + variant="destructive" + size="sm" + onClick={() => setIsBulkDeleteDialogOpen(true)} + > + <Trash2 className="!size-4" /> + Delete ({selectedProjects.size}) + </Button> + )} + </div> + ) : ( + <CreateButton onClick={handleCreateProject} /> + )} + </div> + </div> + <main className="max-w-6xl mx-auto px-6 pt-6 pb-6"> + <div className="mb-8 flex items-center justify-between"> + <div className="flex flex-col gap-3"> + <h1 className="text-2xl md:text-3xl font-bold tracking-tight"> + Your Projects + </h1> + <p className="text-muted-foreground"> + {savedProjects.length}{" "} + {savedProjects.length === 1 ? "project" : "projects"} + {isSelectionMode && selectedProjects.size > 0 && ( + <span className="ml-2 text-primary"> + • {selectedProjects.size} selected + </span> + )} + </p> + </div> + <div className="hidden md:block"> + {isSelectionMode ? ( + <div className="flex items-center gap-2"> + <Button variant="outline" onClick={handleCancelSelection}> + <X className="!size-4" /> + Cancel + </Button> + {selectedProjects.size > 0 && ( + <Button + variant="destructive" + onClick={() => setIsBulkDeleteDialogOpen(true)} + > + <Trash2 className="!size-4" /> + Delete Selected ({selectedProjects.size}) + </Button> + )} + </div> + ) : ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + onClick={() => setIsSelectionMode(true)} + disabled={savedProjects.length === 0} + > + Select Projects + </Button> + <CreateButton onClick={handleCreateProject} /> + </div> + )} + </div> + </div> - {isSelectionMode && sortedProjects.length > 0 && ( - <div className="mb-6 p-4 bg-muted/30 rounded-lg border"> - <div className="flex items-center gap-3"> - <Checkbox - checked={someSelected ? "indeterminate" : allSelected} - onCheckedChange={(value) => handleSelectAll(!!value)} - /> - <span className="text-sm font-medium"> - {allSelected ? "Deselect All" : "Select All"} - </span> - <span className="text-sm text-muted-foreground"> - ({selectedProjects.size} of {sortedProjects.length} selected) - </span> - </div> - </div> - )} + <div className="mb-4 flex items-center justify-between gap-4"> + <div className="flex-1 max-w-72"> + <Input + placeholder="Search projects..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + </div> + <Select value={sortOption} onValueChange={setSortOption}> + <SelectTrigger className="w-[180px]"> + <SelectValue placeholder="Sort by" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="createdAt-desc">Newest to Oldest</SelectItem> + <SelectItem value="createdAt-asc">Oldest to Newest</SelectItem> + <SelectItem value="name-asc">Name (A-Z)</SelectItem> + <SelectItem value="name-desc">Name (Z-A)</SelectItem> + </SelectContent> + </Select> + </div> - {isLoading || !isInitialized ? ( - <div className="flex items-center justify-center py-16"> - <Loader2 className="h-8 w-8 text-muted-foreground animate-spin" /> - </div> - ) : savedProjects.length === 0 ? ( - <NoProjects onCreateProject={handleCreateProject} /> - ) : sortedProjects.length === 0 ? ( - <NoResults - searchQuery={searchQuery} - onClearSearch={() => setSearchQuery("")} - /> - ) : ( - <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6"> - {sortedProjects.map((project) => ( - <ProjectCard - key={project.id} - project={project} - isSelectionMode={isSelectionMode} - isSelected={selectedProjects.has(project.id)} - onSelect={handleSelectProject} - /> - ))} - </div> - )} - </main> + {isSelectionMode && sortedProjects.length > 0 && ( + <button + type="button" + onClick={() => handleSelectAll(!allSelected)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectAll(!allSelected); + } + }} + className="w-full hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex" + tabIndex={0} + > + <Checkbox checked={someSelected ? "indeterminate" : allSelected} /> + <span className="text-sm font-medium"> + {allSelected ? "Deselect All" : "Select All"} + </span> + <span className="text-sm text-muted-foreground"> + ({selectedProjects.size} of {sortedProjects.length} selected) + </span> + </button> + )} - <DeleteProjectDialog - isOpen={isBulkDeleteDialogOpen} - onOpenChange={setIsBulkDeleteDialogOpen} - onConfirm={handleBulkDelete} - /> - </div> - ); + {isLoading || !isInitialized ? ( + <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6"> + {Array.from({ length: 8 }, (_, index) => ( + <div + key={`skeleton-${index}-${Date.now()}`} + className="overflow-hidden bg-background border-none p-0" + > + <Skeleton className="aspect-square w-full bg-muted/50" /> + <div className="px-0 pt-5 flex flex-col gap-1"> + <Skeleton className="h-4 w-3/4 bg-muted/50" /> + <div className="flex items-center gap-1.5"> + <Skeleton className="h-4 w-4 bg-muted/50" /> + <Skeleton className="h-4 w-24 bg-muted/50" /> + </div> + </div> + </div> + ))} + </div> + ) : savedProjects.length === 0 ? ( + <NoProjects onCreateProject={handleCreateProject} /> + ) : sortedProjects.length === 0 ? ( + <NoResults + searchQuery={searchQuery} + onClearSearch={() => setSearchQuery("")} + /> + ) : ( + <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6"> + {sortedProjects.map((project) => ( + <ProjectCard + key={project.id} + project={project} + isSelectionMode={isSelectionMode} + isSelected={selectedProjects.has(project.id)} + onSelect={handleSelectProject} + getProjectThumbnail={getProjectThumbnail} + /> + ))} + </div> + )} + </main> + + <DeleteProjectDialog + isOpen={isBulkDeleteDialogOpen} + onOpenChange={setIsBulkDeleteDialogOpen} + onConfirm={handleBulkDelete} + /> + </div> + ); } interface ProjectCardProps { - project: TProject; - isSelectionMode?: boolean; - isSelected?: boolean; - onSelect?: (projectId: string, checked: boolean) => void; + project: TProject; + isSelectionMode?: boolean; + isSelected?: boolean; + onSelect?: (projectId: string, checked: boolean) => void; + getProjectThumbnail: (projectId: string) => Promise<string | null>; } function ProjectCard({ - project, - isSelectionMode = false, - isSelected = false, - onSelect, + project, + isSelectionMode = false, + isSelected = false, + onSelect, + getProjectThumbnail, }: ProjectCardProps) { - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const { deleteProject, renameProject, duplicateProject } = useProjectStore(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [dynamicThumbnail, setDynamicThumbnail] = useState<string | null>(null); + const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(true); + const { deleteProject, renameProject, duplicateProject } = useProjectStore(); + + useEffect(() => { + const loadThumbnail = async () => { + setIsLoadingThumbnail(true); + try { + const thumbnail = await getProjectThumbnail(project.id); + setDynamicThumbnail(thumbnail); + } finally { + setIsLoadingThumbnail(false); + } + }; + loadThumbnail(); + }, [project.id, getProjectThumbnail]); - const formatDate = (date: Date): string => { - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - }; + const formatDate = (date: Date): string => { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; - const handleDeleteProject = async () => { - await deleteProject(project.id); - setIsDropdownOpen(false); - }; + const handleDeleteProject = async () => { + await deleteProject(project.id); + setIsDropdownOpen(false); + }; - const handleRenameProject = async (newName: string) => { - await renameProject(project.id, newName); - setIsRenameDialogOpen(false); - }; + const handleRenameProject = async (newName: string) => { + await renameProject(project.id, newName); + setIsRenameDialogOpen(false); + }; - const handleDuplicateProject = async () => { - setIsDropdownOpen(false); - await duplicateProject(project.id); - }; + const handleDuplicateProject = async () => { + setIsDropdownOpen(false); + await duplicateProject(project.id); + }; - const handleCardClick = (e: React.MouseEvent) => { - if (isSelectionMode) { - e.preventDefault(); - onSelect?.(project.id, !isSelected); - } - }; + const handleCardClick = (e: React.MouseEvent) => { + if (isSelectionMode) { + e.preventDefault(); + onSelect?.(project.id, !isSelected); + } + }; - return ( - <> - {isSelectionMode ? ( - <div onClick={handleCardClick} className="block group cursor-pointer"> - <Card - className={`overflow-hidden bg-background border-none p-0 transition-all ${ - isSelectionMode && isSelected ? "ring-2 ring-primary" : "" - }`} - > - <div - className={`relative aspect-square bg-muted transition-opacity ${ - isDropdownOpen - ? "opacity-65" - : "opacity-100 group-hover:opacity-65" - }`} - > - {/* Selection checkbox */} - {isSelectionMode && ( - <div className="absolute top-3 left-3 z-10"> - <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center"> - <Checkbox - checked={isSelected} - onCheckedChange={(checked) => - onSelect?.(project.id, checked as boolean) - } - onClick={(e) => e.stopPropagation()} - className="w-4 h-4" - /> - </div> - </div> - )} + const handleCardKeyDown = (e: React.KeyboardEvent) => { + if (isSelectionMode && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onSelect?.(project.id, !isSelected); + } + }; - {/* Thumbnail preview or placeholder */} - <div className="absolute inset-0"> - {project.thumbnail ? ( - <Image - src={project.thumbnail} - alt="Project thumbnail" - fill - className="object-cover" - /> - ) : ( - <div className="w-full h-full bg-muted/50 flex items-center justify-center"> - <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" /> - </div> - )} - </div> - </div> + const cardContent = ( + <Card + className={`overflow-hidden bg-background border-none p-0 transition-all ${ + isSelectionMode && isSelected ? "ring-2 ring-primary" : "" + }`} + > + <div + className={`relative aspect-square bg-muted transition-opacity ${ + isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65" + }`} + > + {isSelectionMode && ( + <div className="absolute top-3 left-3 z-10"> + <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center"> + <Checkbox + checked={isSelected} + onCheckedChange={(checked) => + onSelect?.(project.id, checked as boolean) + } + onClick={(e) => e.stopPropagation()} + className="w-4 h-4" + /> + </div> + </div> + )} - <CardContent className="px-0 pt-5 flex flex-col gap-1"> - <div className="flex items-start justify-between"> - <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2"> - {project.name} - </h3> - {!isSelectionMode && ( - <DropdownMenu - open={isDropdownOpen} - onOpenChange={setIsDropdownOpen} - > - <DropdownMenuTrigger asChild> - <Button - variant="text" - size="sm" - className={`size-6 p-0 transition-all shrink-0 ml-2 ${ - isDropdownOpen - ? "opacity-100" - : "opacity-0 group-hover:opacity-100" - }`} - onClick={(e) => e.preventDefault()} - > - <MoreHorizontal /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent - align="end" - onCloseAutoFocus={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - > - <DropdownMenuItem - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDropdownOpen(false); - setIsRenameDialogOpen(true); - }} - > - Rename - </DropdownMenuItem> - <DropdownMenuItem - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleDuplicateProject(); - }} - > - Duplicate - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - variant="destructive" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDropdownOpen(false); - setIsDeleteDialogOpen(true); - }} - > - Delete - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - )} - </div> + <div className="absolute inset-0"> + {isLoadingThumbnail ? ( + <div className="w-full h-full bg-muted/50 flex items-center justify-center"> + <Loader2 className="h-12 w-12 text-muted-foreground animate-spin" /> + </div> + ) : dynamicThumbnail ? ( + <Image + src={dynamicThumbnail} + alt="Project thumbnail" + fill + className="object-cover" + /> + ) : ( + <div className="w-full h-full bg-muted/50 flex items-center justify-center"> + <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" /> + </div> + )} + </div> + </div> - <div className="space-y-1"> - <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> - <Calendar className="!size-4" /> - <span>Created {formatDate(project.createdAt)}</span> - </div> - </div> - </CardContent> - </Card> - </div> - ) : ( - <Link href={`/editor/${project.id}`} className="block group"> - <Card - className={`overflow-hidden bg-background border-none p-0 transition-all ${ - isSelectionMode && isSelected ? "ring-2 ring-primary" : "" - }`} - > - <div - className={`relative aspect-square bg-muted transition-opacity ${ - isDropdownOpen - ? "opacity-65" - : "opacity-100 group-hover:opacity-65" - }`} - > - {/* Thumbnail preview or placeholder */} - <div className="absolute inset-0"> - {project.thumbnail ? ( - <Image - src={project.thumbnail} - alt="Project thumbnail" - fill - className="object-cover" - /> - ) : ( - <div className="w-full h-full bg-muted/50 flex items-center justify-center"> - <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" /> - </div> - )} - </div> - </div> + <CardContent className="px-0 pt-5 flex flex-col gap-1"> + <div className="flex items-start justify-between"> + <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2"> + {project.name} + </h3> + {!isSelectionMode && ( + <DropdownMenu + open={isDropdownOpen} + onOpenChange={setIsDropdownOpen} + > + <DropdownMenuTrigger asChild> + <Button + variant="text" + size="sm" + className={`size-6 p-0 transition-all shrink-0 ml-2 ${ + isDropdownOpen + ? "opacity-100" + : "opacity-0 group-hover:opacity-100" + }`} + onClick={(e) => e.preventDefault()} + > + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + onCloseAutoFocus={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsRenameDialogOpen(true); + }} + > + Rename + </DropdownMenuItem> + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDuplicateProject(); + }} + > + Duplicate + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + variant="destructive" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsDeleteDialogOpen(true); + }} + > + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + )} + </div> - <CardContent className="px-0 pt-5 flex flex-col gap-1"> - <div className="flex items-start justify-between"> - <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2"> - {project.name} - </h3> - <DropdownMenu - open={isDropdownOpen} - onOpenChange={setIsDropdownOpen} - > - <DropdownMenuTrigger asChild> - <Button - variant="text" - size="sm" - className={`size-6 p-0 transition-all shrink-0 ml-2 ${ - isDropdownOpen - ? "opacity-100" - : "opacity-0 group-hover:opacity-100" - }`} - onClick={(e) => e.preventDefault()} - > - <MoreHorizontal /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent - align="end" - onCloseAutoFocus={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - > - <DropdownMenuItem - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDropdownOpen(false); - setIsRenameDialogOpen(true); - }} - > - Rename - </DropdownMenuItem> - <DropdownMenuItem - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - handleDuplicateProject(); - }} - > - Duplicate - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - variant="destructive" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setIsDropdownOpen(false); - setIsDeleteDialogOpen(true); - }} - > - Delete - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </div> + <div className="space-y-1"> + <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> + <Calendar className="!size-4" /> + <span>Created {formatDate(project.createdAt)}</span> + </div> + </div> + </CardContent> + </Card> + ); - <div className="space-y-1"> - <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> - <Calendar className="!size-4" /> - <span>Created {formatDate(project.createdAt)}</span> - </div> - </div> - </CardContent> - </Card> - </Link> - )} - <DeleteProjectDialog - isOpen={isDeleteDialogOpen} - onOpenChange={setIsDeleteDialogOpen} - onConfirm={handleDeleteProject} - projectName={project.name} - /> - <RenameProjectDialog - isOpen={isRenameDialogOpen} - onOpenChange={setIsRenameDialogOpen} - onConfirm={handleRenameProject} - projectName={project.name} - /> - </> - ); + return ( + <> + {isSelectionMode ? ( + <button + type="button" + onClick={handleCardClick} + onKeyDown={handleCardKeyDown} + className="block group cursor-pointer w-full text-left" + > + {cardContent} + </button> + ) : ( + <Link href={`/editor/${project.id}`} className="block group"> + {cardContent} + </Link> + )} + <DeleteProjectDialog + isOpen={isDeleteDialogOpen} + onOpenChange={setIsDeleteDialogOpen} + onConfirm={handleDeleteProject} + /> + <RenameProjectDialog + isOpen={isRenameDialogOpen} + onOpenChange={setIsRenameDialogOpen} + onConfirm={handleRenameProject} + projectName={project.name} + /> + </> + ); } function CreateButton({ onClick }: { onClick?: () => void }) { - return ( - <Button className="flex" onClick={onClick}> - <Plus className="!size-4" /> - <span className="text-sm font-medium">New project</span> - </Button> - ); + return ( + <Button className="flex" onClick={onClick}> + <Plus className="!size-4" /> + <span className="text-sm font-medium">New project</span> + </Button> + ); } function NoProjects({ onCreateProject }: { onCreateProject: () => void }) { - return ( - <div className="flex flex-col items-center justify-center py-16 text-center"> - <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4"> - <Video className="h-8 w-8 text-muted-foreground" /> - </div> - <h3 className="text-lg font-medium mb-2">No projects yet</h3> - <p className="text-muted-foreground mb-6 max-w-md"> - Start creating your first video project. Import media, edit, and export - professional videos. - </p> - <Button size="lg" className="gap-2" onClick={onCreateProject}> - <Plus className="h-4 w-4" /> - Create Your First Project - </Button> - </div> - ); + return ( + <div className="flex flex-col items-center justify-center py-16 text-center"> + <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4"> + <Video className="h-8 w-8 text-muted-foreground" /> + </div> + <h3 className="text-lg font-medium mb-2">No projects yet</h3> + <p className="text-muted-foreground mb-6 max-w-md"> + Start creating your first video project. Import media, edit, and export + professional videos. + </p> + <Button size="lg" className="gap-2" onClick={onCreateProject}> + <Plus className="h-4 w-4" /> + Create Your First Project + </Button> + </div> + ); } function NoResults({ - searchQuery, - onClearSearch, + searchQuery, + onClearSearch, }: { - searchQuery: string; - onClearSearch: () => void; + searchQuery: string; + onClearSearch: () => void; }) { - return ( - <div className="flex flex-col items-center justify-center py-16 text-center"> - <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4"> - <Search className="h-8 w-8 text-muted-foreground" /> - </div> - <h3 className="text-lg font-medium mb-2">No results found</h3> - <p className="text-muted-foreground mb-6 max-w-md"> - Your search for "{searchQuery}" did not return any results. - </p> - <Button onClick={onClearSearch} variant="outline"> - Clear Search - </Button> - </div> - ); + return ( + <div className="flex flex-col items-center justify-center py-16 text-center"> + <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4"> + <Search className="h-8 w-8 text-muted-foreground" /> + </div> + <h3 className="text-lg font-medium mb-2">No results found</h3> + <p className="text-muted-foreground mb-6 max-w-md"> + Your search for "{searchQuery}" did not return any results. + </p> + <Button onClick={onClearSearch} variant="outline"> + Clear Search + </Button> + </div> + ); } diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 1fcfc60e..ca4da916 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,1418 +1,1467 @@ +import { toast } from "sonner"; import { create } from "zustand"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { storageService } from "@/lib/storage/storage-service"; +import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; +import { generateUUID } from "@/lib/utils"; import { - TrackType, - TimelineElement, - CreateTimelineElement, - TimelineTrack, - TextElement, - DragData, - sortTracksByOrder, - ensureMainTrack, - validateElementTrackCompatibility, + type CreateTimelineElement, + type DragData, + ensureMainTrack, + sortTracksByOrder, + type TextElement, + type TimelineElement, + type TimelineTrack, + type TrackType, + validateElementTrackCompatibility, } from "@/types/timeline"; import { useEditorStore } from "./editor-store"; import { - useMediaStore, - getMediaAspectRatio, - type MediaItem, + getMediaAspectRatio, + type MediaItem, + type MediaType, + useMediaStore, } from "./media-store"; -import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; -import { generateUUID } from "@/lib/utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { toast } from "sonner"; -import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; // Helper function to manage element naming with suffixes const getElementNameWithSuffix = ( - originalName: string, - suffix: string + originalName: string, + suffix: string, ): string => { - // Remove existing suffixes to prevent accumulation - const baseName = originalName - .replace(/ \(left\)$/, "") - .replace(/ \(right\)$/, "") - .replace(/ \(audio\)$/, "") - .replace(/ \(split \d+\)$/, ""); + // Remove existing suffixes to prevent accumulation + const baseName = originalName + .replace(/ \(left\)$/, "") + .replace(/ \(right\)$/, "") + .replace(/ \(audio\)$/, "") + .replace(/ \(split \d+\)$/, ""); - return `${baseName} (${suffix})`; + return `${baseName} (${suffix})`; }; interface TimelineStore { - // Private track storage - _tracks: TimelineTrack[]; - history: TimelineTrack[][]; - redoStack: TimelineTrack[][]; + // Private track storage + _tracks: TimelineTrack[]; + history: TimelineTrack[][]; + redoStack: TimelineTrack[][]; - // Always returns properly ordered tracks with main track ensured - tracks: TimelineTrack[]; + // Always returns properly ordered tracks with main track ensured + tracks: TimelineTrack[]; - // Manual method if you need to force recomputation - getSortedTracks: () => TimelineTrack[]; + // Manual method if you need to force recomputation + getSortedTracks: () => TimelineTrack[]; - // Snapping settings - snappingEnabled: boolean; + // Snapping settings + snappingEnabled: boolean; - // Snapping actions - toggleSnapping: () => void; + // Snapping actions + toggleSnapping: () => void; - // Ripple editing mode - rippleEditingEnabled: boolean; - toggleRippleEditing: () => void; + // Ripple editing mode + rippleEditingEnabled: boolean; + toggleRippleEditing: () => void; - // Multi-selection - selectedElements: { trackId: string; elementId: string }[]; - selectElement: (trackId: string, elementId: string, multi?: boolean) => void; - deselectElement: (trackId: string, elementId: string) => void; - clearSelectedElements: () => void; - setSelectedElements: ( - elements: { trackId: string; elementId: string }[] - ) => void; + // Multi-selection + selectedElements: { trackId: string; elementId: string }[]; + selectElement: (trackId: string, elementId: string, multi?: boolean) => void; + deselectElement: (trackId: string, elementId: string) => void; + clearSelectedElements: () => void; + setSelectedElements: ( + elements: { trackId: string; elementId: string }[], + ) => void; - // Drag state - dragState: { - isDragging: boolean; - elementId: string | null; - trackId: string | null; - startMouseX: number; - startElementTime: number; - clickOffsetTime: number; - currentTime: number; - }; - setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void; - startDrag: ( - elementId: string, - trackId: string, - startMouseX: number, - startElementTime: number, - clickOffsetTime: number - ) => void; - updateDragTime: (currentTime: number) => void; - endDrag: () => void; + // Drag state + dragState: { + isDragging: boolean; + elementId: string | null; + trackId: string | null; + startMouseX: number; + startElementTime: number; + clickOffsetTime: number; + currentTime: number; + }; + setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void; + startDrag: ( + elementId: string, + trackId: string, + startMouseX: number, + startElementTime: number, + clickOffsetTime: number, + ) => void; + updateDragTime: (currentTime: number) => void; + endDrag: () => void; - // Actions - addTrack: (type: TrackType) => string; - insertTrackAt: (type: TrackType, index: number) => string; - removeTrack: (trackId: string) => void; - removeTrackWithRipple: (trackId: string) => void; - addElementToTrack: (trackId: string, element: CreateTimelineElement) => void; - removeElementFromTrack: (trackId: string, elementId: string) => void; - moveElementToTrack: ( - fromTrackId: string, - toTrackId: string, - elementId: string - ) => void; - updateElementTrim: ( - trackId: string, - elementId: string, - trimStart: number, - trimEnd: number - ) => void; - updateElementDuration: ( - trackId: string, - elementId: string, - duration: number - ) => void; - updateElementStartTime: ( - trackId: string, - elementId: string, - startTime: number - ) => void; - toggleTrackMute: (trackId: string) => void; + // Actions + addTrack: (type: TrackType) => string; + insertTrackAt: (type: TrackType, index: number) => string; + removeTrack: (trackId: string) => void; + removeTrackWithRipple: (trackId: string) => void; + addElementToTrack: (trackId: string, element: CreateTimelineElement) => void; + removeElementFromTrack: (trackId: string, elementId: string) => void; + moveElementToTrack: ( + fromTrackId: string, + toTrackId: string, + elementId: string, + ) => void; + updateElementTrim: ( + trackId: string, + elementId: string, + trimStart: number, + trimEnd: number, + ) => void; + updateElementDuration: ( + trackId: string, + elementId: string, + duration: number, + ) => void; + updateElementStartTime: ( + trackId: string, + elementId: string, + startTime: number, + ) => void; + toggleTrackMute: (trackId: string) => void; - // Split operations for elements - splitElement: ( - trackId: string, - elementId: string, - splitTime: number - ) => string | null; - splitAndKeepLeft: ( - trackId: string, - elementId: string, - splitTime: number - ) => void; - splitAndKeepRight: ( - trackId: string, - elementId: string, - splitTime: number - ) => void; - separateAudio: (trackId: string, elementId: string) => string | null; + // Split operations for elements + splitElement: ( + trackId: string, + elementId: string, + splitTime: number, + ) => string | null; + splitAndKeepLeft: ( + trackId: string, + elementId: string, + splitTime: number, + ) => void; + splitAndKeepRight: ( + trackId: string, + elementId: string, + splitTime: number, + ) => void; + separateAudio: (trackId: string, elementId: string) => string | null; - // Replace media for an element - replaceElementMedia: ( - trackId: string, - elementId: string, - newFile: File - ) => Promise<boolean>; + // Replace media for an element + replaceElementMedia: ( + trackId: string, + elementId: string, + newFile: File, + ) => Promise<boolean>; - // Ripple editing functions - updateElementStartTimeWithRipple: ( - trackId: string, - elementId: string, - newStartTime: number - ) => void; - removeElementFromTrackWithRipple: ( - trackId: string, - elementId: string - ) => void; + // Ripple editing functions + updateElementStartTimeWithRipple: ( + trackId: string, + elementId: string, + newStartTime: number, + ) => void; + removeElementFromTrackWithRipple: ( + trackId: string, + elementId: string, + ) => void; - // Computed values - getTotalDuration: () => number; + // Computed values + getTotalDuration: () => number; + getProjectThumbnail: (projectId: string) => Promise<string | null>; - // History actions - undo: () => void; - redo: () => void; - pushHistory: () => void; + // History actions + undo: () => void; + redo: () => void; + pushHistory: () => void; - // Persistence actions - loadProjectTimeline: (projectId: string) => Promise<void>; - saveProjectTimeline: (projectId: string) => Promise<void>; - clearTimeline: () => void; - updateTextElement: ( - trackId: string, - elementId: string, - updates: Partial< - Pick< - TextElement, - | "content" - | "fontSize" - | "fontFamily" - | "color" - | "backgroundColor" - | "textAlign" - | "fontWeight" - | "fontStyle" - | "textDecoration" - | "x" - | "y" - | "rotation" - | "opacity" - > - > - ) => 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; + // Persistence actions + loadProjectTimeline: (projectId: string) => Promise<void>; + saveProjectTimeline: (projectId: string) => Promise<void>; + clearTimeline: () => void; + updateTextElement: ( + trackId: string, + elementId: string, + updates: Partial< + Pick< + TextElement, + | "content" + | "fontSize" + | "fontFamily" + | "color" + | "backgroundColor" + | "textAlign" + | "fontWeight" + | "fontStyle" + | "textDecoration" + | "x" + | "y" + | "rotation" + | "opacity" + > + >, + ) => 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) => { - // Helper to update tracks and maintain ordering - const updateTracks = (newTracks: TimelineTrack[]) => { - const tracksWithMain = ensureMainTrack(newTracks); - const sortedTracks = sortTracksByOrder(tracksWithMain); - set({ - _tracks: tracksWithMain, - tracks: sortedTracks, - }); - }; + // Helper to update tracks and maintain ordering + const updateTracks = (newTracks: TimelineTrack[]) => { + const tracksWithMain = ensureMainTrack(newTracks); + const sortedTracks = sortTracksByOrder(tracksWithMain); + set({ + _tracks: tracksWithMain, + tracks: sortedTracks, + }); + }; + + // Helper to auto-save timeline changes + const autoSaveTimeline = async () => { + const activeProject = useProjectStore.getState().activeProject; + if (activeProject) { + try { + await storageService.saveTimeline(activeProject.id, get()._tracks); + } catch (error) { + console.error("Failed to auto-save timeline:", error); + } + } + }; + + // Helper to update tracks and auto-save + const updateTracksAndSave = (newTracks: TimelineTrack[]) => { + updateTracks(newTracks); + // Auto-save in background + setTimeout(autoSaveTimeline, 100); + }; + + // Initialize with proper track ordering + const initialTracks = ensureMainTrack([]); + const sortedInitialTracks = sortTracksByOrder(initialTracks); + + return { + _tracks: initialTracks, + tracks: sortedInitialTracks, + history: [], + redoStack: [], + selectedElements: [], + rippleEditingEnabled: false, + + // Snapping settings defaults + snappingEnabled: true, + + getSortedTracks: () => { + const { _tracks } = get(); + const tracksWithMain = ensureMainTrack(_tracks); + return sortTracksByOrder(tracksWithMain); + }, + + pushHistory: () => { + const { _tracks, history } = get(); + set({ + history: [...history, JSON.parse(JSON.stringify(_tracks))], + redoStack: [], + }); + }, - // Helper to auto-save timeline changes - const autoSaveTimeline = async () => { - const activeProject = useProjectStore.getState().activeProject; - if (activeProject) { - try { - await storageService.saveTimeline(activeProject.id, get()._tracks); - } catch (error) { - console.error("Failed to auto-save timeline:", error); - } - } - }; + undo: () => { + const { history, redoStack, _tracks } = get(); + if (history.length === 0) return; + const prev = history[history.length - 1]; + updateTracksAndSave(prev); + set({ + history: history.slice(0, -1), + redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))], + }); + }, - // Helper to update tracks and auto-save - const updateTracksAndSave = (newTracks: TimelineTrack[]) => { - updateTracks(newTracks); - // Auto-save in background - setTimeout(autoSaveTimeline, 100); - }; + selectElement: (trackId, elementId, multi = false) => { + set((state) => { + const exists = state.selectedElements.some( + (c) => c.trackId === trackId && c.elementId === elementId, + ); + if (multi) { + return exists + ? { + selectedElements: state.selectedElements.filter( + (c) => !(c.trackId === trackId && c.elementId === elementId), + ), + } + : { + selectedElements: [ + ...state.selectedElements, + { trackId, elementId }, + ], + }; + } else { + return { selectedElements: [{ trackId, elementId }] }; + } + }); + }, - // Initialize with proper track ordering - const initialTracks = ensureMainTrack([]); - const sortedInitialTracks = sortTracksByOrder(initialTracks); + deselectElement: (trackId, elementId) => { + set((state) => ({ + selectedElements: state.selectedElements.filter( + (c) => !(c.trackId === trackId && c.elementId === elementId), + ), + })); + }, - return { - _tracks: initialTracks, - tracks: sortedInitialTracks, - history: [], - redoStack: [], - selectedElements: [], - rippleEditingEnabled: false, + clearSelectedElements: () => { + set({ selectedElements: [] }); + }, - // Snapping settings defaults - snappingEnabled: true, + setSelectedElements: (elements) => set({ selectedElements: elements }), - getSortedTracks: () => { - const { _tracks } = get(); - const tracksWithMain = ensureMainTrack(_tracks); - return sortTracksByOrder(tracksWithMain); - }, + addTrack: (type) => { + get().pushHistory(); - pushHistory: () => { - const { _tracks, history } = get(); - set({ - history: [...history, JSON.parse(JSON.stringify(_tracks))], - redoStack: [], - }); - }, + // Generate proper track name based on type + const trackName = + type === "media" + ? "Media Track" + : type === "text" + ? "Text Track" + : type === "audio" + ? "Audio Track" + : "Track"; - undo: () => { - const { history, redoStack, _tracks } = get(); - if (history.length === 0) return; - const prev = history[history.length - 1]; - updateTracksAndSave(prev); - set({ - history: history.slice(0, -1), - redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))], - }); - }, + const newTrack: TimelineTrack = { + id: generateUUID(), + name: trackName, + type, + elements: [], + muted: false, + }; - selectElement: (trackId, elementId, multi = false) => { - set((state) => { - const exists = state.selectedElements.some( - (c) => c.trackId === trackId && c.elementId === elementId - ); - if (multi) { - return exists - ? { - selectedElements: state.selectedElements.filter( - (c) => !(c.trackId === trackId && c.elementId === elementId) - ), - } - : { - selectedElements: [ - ...state.selectedElements, - { trackId, elementId }, - ], - }; - } else { - return { selectedElements: [{ trackId, elementId }] }; - } - }); - }, + updateTracksAndSave([...get()._tracks, newTrack]); + return newTrack.id; + }, - deselectElement: (trackId, elementId) => { - set((state) => ({ - selectedElements: state.selectedElements.filter( - (c) => !(c.trackId === trackId && c.elementId === elementId) - ), - })); - }, + insertTrackAt: (type, index) => { + get().pushHistory(); - clearSelectedElements: () => { - set({ selectedElements: [] }); - }, + // Generate proper track name based on type + const trackName = + type === "media" + ? "Media Track" + : type === "text" + ? "Text Track" + : type === "audio" + ? "Audio Track" + : "Track"; - setSelectedElements: (elements) => set({ selectedElements: elements }), + const newTrack: TimelineTrack = { + id: generateUUID(), + name: trackName, + type, + elements: [], + muted: false, + }; - addTrack: (type) => { - get().pushHistory(); + const newTracks = [...get()._tracks]; + newTracks.splice(index, 0, newTrack); + updateTracksAndSave(newTracks); + return newTrack.id; + }, - // Generate proper track name based on type - const trackName = - type === "media" - ? "Media Track" - : type === "text" - ? "Text Track" - : type === "audio" - ? "Audio Track" - : "Track"; + removeTrack: (trackId) => { + const { rippleEditingEnabled } = get(); - const newTrack: TimelineTrack = { - id: generateUUID(), - name: trackName, - type, - elements: [], - muted: false, - }; + if (rippleEditingEnabled) { + get().removeTrackWithRipple(trackId); + } else { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.filter((track) => track.id !== trackId), + ); + } + }, - updateTracksAndSave([...get()._tracks, newTrack]); - return newTrack.id; - }, + removeTrackWithRipple: (trackId) => { + const { _tracks } = get(); + const trackToRemove = _tracks.find((t) => t.id === trackId); - insertTrackAt: (type, index) => { - get().pushHistory(); + if (!trackToRemove) return; - // Generate proper track name based on type - const trackName = - type === "media" - ? "Media Track" - : type === "text" - ? "Text Track" - : type === "audio" - ? "Audio Track" - : "Track"; + get().pushHistory(); - const newTrack: TimelineTrack = { - id: generateUUID(), - name: trackName, - type, - elements: [], - muted: false, - }; + // If track has no elements, just remove it normally + if (trackToRemove.elements.length === 0) { + updateTracksAndSave(_tracks.filter((track) => track.id !== trackId)); + return; + } - const newTracks = [...get()._tracks]; - newTracks.splice(index, 0, newTrack); - updateTracksAndSave(newTracks); - return newTrack.id; - }, + // Find all the time ranges occupied by elements in the track being removed + const occupiedRanges = trackToRemove.elements.map((element) => ({ + startTime: element.startTime, + endTime: + element.startTime + + (element.duration - element.trimStart - element.trimEnd), + })); - removeTrack: (trackId) => { - const { rippleEditingEnabled } = get(); + // Sort ranges by start time + occupiedRanges.sort((a, b) => a.startTime - b.startTime); - if (rippleEditingEnabled) { - get().removeTrackWithRipple(trackId); - } else { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.filter((track) => track.id !== trackId) - ); - } - }, + // Merge overlapping ranges to get consolidated gaps + const mergedRanges: Array<{ + startTime: number; + endTime: number; + duration: number; + }> = []; - removeTrackWithRipple: (trackId) => { - const { _tracks } = get(); - const trackToRemove = _tracks.find((t) => t.id === trackId); + for (const range of occupiedRanges) { + if (mergedRanges.length === 0) { + mergedRanges.push({ + startTime: range.startTime, + endTime: range.endTime, + duration: range.endTime - range.startTime, + }); + } else { + const lastRange = mergedRanges[mergedRanges.length - 1]; + if (range.startTime <= lastRange.endTime) { + // Overlapping or adjacent ranges, merge them + lastRange.endTime = Math.max(lastRange.endTime, range.endTime); + lastRange.duration = lastRange.endTime - lastRange.startTime; + } else { + // Non-overlapping range, add as new + mergedRanges.push({ + startTime: range.startTime, + endTime: range.endTime, + duration: range.endTime - range.startTime, + }); + } + } + } - if (!trackToRemove) return; + // Remove the track and apply ripple effects to remaining tracks + const updatedTracks = _tracks + .filter((track) => track.id !== trackId) + .map((track) => { + const updatedElements = track.elements.map((element) => { + let newStartTime = element.startTime; - get().pushHistory(); + // Process gaps from right to left (latest to earliest) to avoid cumulative shifts + for (let i = mergedRanges.length - 1; i >= 0; i--) { + const gap = mergedRanges[i]; + // If this element starts after the gap, shift it left by the gap duration + if (newStartTime >= gap.endTime) { + newStartTime -= gap.duration; + } + } - // If track has no elements, just remove it normally - if (trackToRemove.elements.length === 0) { - updateTracksAndSave(_tracks.filter((track) => track.id !== trackId)); - return; - } + return { + ...element, + startTime: Math.max(0, newStartTime), + }; + }); - // Find all the time ranges occupied by elements in the track being removed - const occupiedRanges = trackToRemove.elements.map((element) => ({ - startTime: element.startTime, - endTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd), - })); + // Check for overlaps and resolve them if necessary + const hasOverlaps = checkElementOverlaps(updatedElements); + if (hasOverlaps) { + const resolvedElements = resolveElementOverlaps(updatedElements); + return { ...track, elements: resolvedElements }; + } - // Sort ranges by start time - occupiedRanges.sort((a, b) => a.startTime - b.startTime); + return { ...track, elements: updatedElements }; + }); - // Merge overlapping ranges to get consolidated gaps - const mergedRanges: Array<{ - startTime: number; - endTime: number; - duration: number; - }> = []; + updateTracksAndSave(updatedTracks); + }, - for (const range of occupiedRanges) { - if (mergedRanges.length === 0) { - mergedRanges.push({ - startTime: range.startTime, - endTime: range.endTime, - duration: range.endTime - range.startTime, - }); - } else { - const lastRange = mergedRanges[mergedRanges.length - 1]; - if (range.startTime <= lastRange.endTime) { - // Overlapping or adjacent ranges, merge them - lastRange.endTime = Math.max(lastRange.endTime, range.endTime); - lastRange.duration = lastRange.endTime - lastRange.startTime; - } else { - // Non-overlapping range, add as new - mergedRanges.push({ - startTime: range.startTime, - endTime: range.endTime, - duration: range.endTime - range.startTime, - }); - } - } - } + addElementToTrack: (trackId, elementData) => { + get().pushHistory(); - // Remove the track and apply ripple effects to remaining tracks - const updatedTracks = _tracks - .filter((track) => track.id !== trackId) - .map((track) => { - const updatedElements = track.elements.map((element) => { - let newStartTime = element.startTime; + // Validate element type matches track type + const track = get()._tracks.find((t) => t.id === trackId); + if (!track) { + console.error("Track not found:", trackId); + return; + } - // Process gaps from right to left (latest to earliest) to avoid cumulative shifts - for (let i = mergedRanges.length - 1; i >= 0; i--) { - const gap = mergedRanges[i]; - // If this element starts after the gap, shift it left by the gap duration - if (newStartTime >= gap.endTime) { - newStartTime -= gap.duration; - } - } + // Use utility function for validation + const validation = validateElementTrackCompatibility(elementData, track); + if (!validation.isValid) { + console.error(validation.errorMessage); + return; + } - return { - ...element, - startTime: Math.max(0, newStartTime), - }; - }); + // For media elements, validate mediaId exists + if (elementData.type === "media" && !elementData.mediaId) { + console.error("Media element must have mediaId"); + return; + } - // Check for overlaps and resolve them if necessary - const hasOverlaps = checkElementOverlaps(updatedElements); - if (hasOverlaps) { - const resolvedElements = resolveElementOverlaps(updatedElements); - return { ...track, elements: resolvedElements }; - } + // For text elements, validate required text properties + if (elementData.type === "text" && !elementData.content) { + console.error("Text element must have content"); + return; + } - return { ...track, elements: updatedElements }; - }); + // Check if this is the first element being added to the timeline + const currentState = get(); + const totalElementsInTimeline = currentState._tracks.reduce( + (total, track) => total + track.elements.length, + 0, + ); + const isFirstElement = totalElementsInTimeline === 0; - updateTracksAndSave(updatedTracks); - }, + const newElement: TimelineElement = { + ...elementData, + id: generateUUID(), + startTime: elementData.startTime || 0, + trimStart: 0, + trimEnd: 0, + } as TimelineElement; // Type assertion since we trust the caller passes valid data - addElementToTrack: (trackId, elementData) => { - get().pushHistory(); + // If this is the first element and it's a media element, automatically set the project canvas size + // to match the media's aspect ratio and FPS (for videos) + if (isFirstElement && newElement.type === "media") { + const mediaStore = useMediaStore.getState(); + const mediaItem = mediaStore.mediaItems.find( + (item) => item.id === newElement.mediaId, + ); - // Validate element type matches track type - const track = get()._tracks.find((t) => t.id === trackId); - if (!track) { - console.error("Track not found:", trackId); - return; - } + if ( + mediaItem && + (mediaItem.type === "image" || mediaItem.type === "video") + ) { + const editorStore = useEditorStore.getState(); + editorStore.setCanvasSizeFromAspectRatio( + getMediaAspectRatio(mediaItem), + ); + } - // Use utility function for validation - const validation = validateElementTrackCompatibility(elementData, track); - if (!validation.isValid) { - console.error(validation.errorMessage); - return; - } + // Set project FPS from the first video element + if (mediaItem && mediaItem.type === "video" && mediaItem.fps) { + const projectStore = useProjectStore.getState(); + if (projectStore.activeProject) { + projectStore.updateProjectFps(mediaItem.fps); + } + } + } - // For media elements, validate mediaId exists - if (elementData.type === "media" && !elementData.mediaId) { - console.error("Media element must have mediaId"); - return; - } + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { ...track, elements: [...track.elements, newElement] } + : track, + ), + ); + }, - // For text elements, validate required text properties - if (elementData.type === "text" && !elementData.content) { - console.error("Text element must have content"); - return; - } + removeElementFromTrack: (trackId, elementId) => { + const { rippleEditingEnabled } = get(); - // Check if this is the first element being added to the timeline - const currentState = get(); - const totalElementsInTimeline = currentState._tracks.reduce( - (total, track) => total + track.elements.length, - 0 - ); - const isFirstElement = totalElementsInTimeline === 0; + if (rippleEditingEnabled) { + get().removeElementFromTrackWithRipple(trackId, elementId); + } else { + get().pushHistory(); + updateTracksAndSave( + get() + ._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.filter( + (element) => element.id !== elementId, + ), + } + : track, + ) + .filter((track) => track.elements.length > 0), + ); + } + }, - const newElement: TimelineElement = { - ...elementData, - id: generateUUID(), - startTime: elementData.startTime || 0, - trimStart: 0, - trimEnd: 0, - } as TimelineElement; // Type assertion since we trust the caller passes valid data + removeElementFromTrackWithRipple: (trackId, elementId) => { + const { _tracks, rippleEditingEnabled } = get(); - // If this is the first element and it's a media element, automatically set the project canvas size - // to match the media's aspect ratio and FPS (for videos) - if (isFirstElement && newElement.type === "media") { - const mediaStore = useMediaStore.getState(); - const mediaItem = mediaStore.mediaItems.find( - (item) => item.id === newElement.mediaId - ); + if (!rippleEditingEnabled) { + // If ripple editing is disabled, use regular removal + get().removeElementFromTrack(trackId, elementId); + return; + } - if ( - mediaItem && - (mediaItem.type === "image" || mediaItem.type === "video") - ) { - const editorStore = useEditorStore.getState(); - editorStore.setCanvasSizeFromAspectRatio( - getMediaAspectRatio(mediaItem) - ); - } + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); - // Set project FPS from the first video element - if (mediaItem && mediaItem.type === "video" && mediaItem.fps) { - const projectStore = useProjectStore.getState(); - if (projectStore.activeProject) { - projectStore.updateProjectFps(mediaItem.fps); - } - } - } + if (!element || !track) return; - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { ...track, elements: [...track.elements, newElement] } - : track - ) - ); - }, + get().pushHistory(); - removeElementFromTrack: (trackId, elementId) => { - const { rippleEditingEnabled } = get(); + const elementStartTime = element.startTime; + const elementDuration = + element.duration - element.trimStart - element.trimEnd; + const elementEndTime = elementStartTime + elementDuration; - if (rippleEditingEnabled) { - get().removeElementFromTrackWithRipple(trackId, elementId); - } else { - get().pushHistory(); - updateTracksAndSave( - get() - ._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.filter( - (element) => element.id !== elementId - ), - } - : track - ) - .filter((track) => track.elements.length > 0) - ); - } - }, + // Remove the element and shift all elements that come after it + const updatedTracks = _tracks + .map((currentTrack) => { + // Only apply ripple effects to the same track unless multi-track ripple is enabled + const shouldApplyRipple = currentTrack.id === trackId; - removeElementFromTrackWithRipple: (trackId, elementId) => { - const { _tracks, rippleEditingEnabled } = get(); + const updatedElements = currentTrack.elements + .filter((currentElement) => { + // Remove the target element + if ( + currentElement.id === elementId && + currentTrack.id === trackId + ) { + return false; + } + return true; + }) + .map((currentElement) => { + // Only apply ripple effects if we should process this track + if (!shouldApplyRipple) { + return currentElement; + } - if (!rippleEditingEnabled) { - // If ripple editing is disabled, use regular removal - get().removeElementFromTrack(trackId, elementId); - return; - } + // Shift elements that start after the removed element + if (currentElement.startTime >= elementEndTime) { + return { + ...currentElement, + startTime: Math.max( + 0, + currentElement.startTime - elementDuration, + ), + }; + } + return currentElement; + }); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); + // Check for overlaps and resolve them if necessary + const hasOverlaps = checkElementOverlaps(updatedElements); + if (hasOverlaps) { + // Resolve overlaps by adjusting element positions + const resolvedElements = resolveElementOverlaps(updatedElements); + return { ...currentTrack, elements: resolvedElements }; + } - if (!element || !track) return; + return { ...currentTrack, elements: updatedElements }; + }) + .filter((track) => track.elements.length > 0 || track.isMain); - get().pushHistory(); + updateTracksAndSave(updatedTracks); + }, - const elementStartTime = element.startTime; - const elementDuration = - element.duration - element.trimStart - element.trimEnd; - const elementEndTime = elementStartTime + elementDuration; + moveElementToTrack: (fromTrackId, toTrackId, elementId) => { + get().pushHistory(); - // Remove the element and shift all elements that come after it - const updatedTracks = _tracks - .map((currentTrack) => { - // Only apply ripple effects to the same track unless multi-track ripple is enabled - const shouldApplyRipple = currentTrack.id === trackId; + const fromTrack = get()._tracks.find((track) => track.id === fromTrackId); + const toTrack = get()._tracks.find((track) => track.id === toTrackId); + const elementToMove = fromTrack?.elements.find( + (element) => element.id === elementId, + ); - const updatedElements = currentTrack.elements - .filter((currentElement) => { - // Remove the target element - if ( - currentElement.id === elementId && - currentTrack.id === trackId - ) { - return false; - } - return true; - }) - .map((currentElement) => { - // Only apply ripple effects if we should process this track - if (!shouldApplyRipple) { - return currentElement; - } + if (!elementToMove || !toTrack) return; - // Shift elements that start after the removed element - if (currentElement.startTime >= elementEndTime) { - return { - ...currentElement, - startTime: Math.max( - 0, - currentElement.startTime - elementDuration - ), - }; - } - return currentElement; - }); + // Validate element type compatibility with target track + const validation = validateElementTrackCompatibility( + elementToMove, + toTrack, + ); + if (!validation.isValid) { + console.error(validation.errorMessage); + return; + } - // Check for overlaps and resolve them if necessary - const hasOverlaps = checkElementOverlaps(updatedElements); - if (hasOverlaps) { - // Resolve overlaps by adjusting element positions - const resolvedElements = resolveElementOverlaps(updatedElements); - return { ...currentTrack, elements: resolvedElements }; - } + const newTracks = get() + ._tracks.map((track) => { + if (track.id === fromTrackId) { + return { + ...track, + elements: track.elements.filter( + (element) => element.id !== elementId, + ), + }; + } else if (track.id === toTrackId) { + return { + ...track, + elements: [...track.elements, elementToMove], + }; + } + return track; + }) + .filter((track) => track.elements.length > 0); - return { ...currentTrack, elements: updatedElements }; - }) - .filter((track) => track.elements.length > 0 || track.isMain); + updateTracksAndSave(newTracks); + }, - updateTracksAndSave(updatedTracks); - }, + updateElementTrim: (trackId, elementId, trimStart, trimEnd) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((element) => + element.id === elementId + ? { ...element, trimStart, trimEnd } + : element, + ), + } + : track, + ), + ); + }, - moveElementToTrack: (fromTrackId, toTrackId, elementId) => { - get().pushHistory(); + updateElementDuration: (trackId, elementId, duration) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((element) => + element.id === elementId ? { ...element, duration } : element, + ), + } + : track, + ), + ); + }, - const fromTrack = get()._tracks.find((track) => track.id === fromTrackId); - const toTrack = get()._tracks.find((track) => track.id === toTrackId); - const elementToMove = fromTrack?.elements.find( - (element) => element.id === elementId - ); + updateElementStartTime: (trackId, elementId, startTime) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((element) => + element.id === elementId + ? { ...element, startTime } + : element, + ), + } + : track, + ), + ); + }, - if (!elementToMove || !toTrack) return; + updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => { + const { _tracks, rippleEditingEnabled } = get(); - // Validate element type compatibility with target track - const validation = validateElementTrackCompatibility( - elementToMove, - toTrack - ); - if (!validation.isValid) { - console.error(validation.errorMessage); - return; - } + if (!rippleEditingEnabled) { + // If ripple editing is disabled, use regular update + get().updateElementStartTime(trackId, elementId, newStartTime); + return; + } - const newTracks = get() - ._tracks.map((track) => { - if (track.id === fromTrackId) { - return { - ...track, - elements: track.elements.filter( - (element) => element.id !== elementId - ), - }; - } else if (track.id === toTrackId) { - return { - ...track, - elements: [...track.elements, elementToMove], - }; - } - return track; - }) - .filter((track) => track.elements.length > 0); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); - updateTracksAndSave(newTracks); - }, + if (!element || !track) return; - updateElementTrim: (trackId, elementId, trimStart, trimEnd) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId - ? { ...element, trimStart, trimEnd } - : element - ), - } - : track - ) - ); - }, + get().pushHistory(); - updateElementDuration: (trackId, elementId, duration) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId ? { ...element, duration } : element - ), - } - : track - ) - ); - }, + const oldStartTime = element.startTime; + const oldEndTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + const newEndTime = + newStartTime + (element.duration - element.trimStart - element.trimEnd); + const timeDelta = newStartTime - oldStartTime; - updateElementStartTime: (trackId, elementId, startTime) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId ? { ...element, startTime } : element - ), - } - : track - ) - ); - }, + // Update tracks based on multi-track ripple setting + const updatedTracks = _tracks.map((currentTrack) => { + // Only apply ripple effects to the same track unless multi-track ripple is enabled + const shouldApplyRipple = currentTrack.id === trackId; - updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => { - const { _tracks, rippleEditingEnabled } = get(); + const updatedElements = currentTrack.elements.map((currentElement) => { + if (currentElement.id === elementId && currentTrack.id === trackId) { + // Update the moved element + return { ...currentElement, startTime: newStartTime }; + } - if (!rippleEditingEnabled) { - // If ripple editing is disabled, use regular update - get().updateElementStartTime(trackId, elementId, newStartTime); - return; - } + // Only apply ripple effects if we should process this track + if (!shouldApplyRipple) { + return currentElement; + } - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); + // For ripple editing, we need to move elements that come after the moved element + const currentElementStart = currentElement.startTime; + const _currentElementEnd = + currentElement.startTime + + (currentElement.duration - + currentElement.trimStart - + currentElement.trimEnd); - if (!element || !track) return; + // If moving element to the right (positive delta) + if (timeDelta > 0) { + // Move elements that start after the original position of the moved element + if (currentElementStart >= oldEndTime) { + return { + ...currentElement, + startTime: currentElementStart + timeDelta, + }; + } + } + // If moving element to the left (negative delta) + else if (timeDelta < 0) { + // Move elements that start after the new position of the moved element + if ( + currentElementStart >= newEndTime && + currentElementStart >= oldStartTime + ) { + return { + ...currentElement, + startTime: Math.max(0, currentElementStart + timeDelta), + }; + } + } - get().pushHistory(); + return currentElement; + }); - const oldStartTime = element.startTime; - const oldEndTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - const newEndTime = - newStartTime + (element.duration - element.trimStart - element.trimEnd); - const timeDelta = newStartTime - oldStartTime; + // Check for overlaps and resolve them if necessary + const hasOverlaps = checkElementOverlaps(updatedElements); + if (hasOverlaps) { + // Resolve overlaps by adjusting element positions + const resolvedElements = resolveElementOverlaps(updatedElements); + return { ...currentTrack, elements: resolvedElements }; + } - // Update tracks based on multi-track ripple setting - const updatedTracks = _tracks.map((currentTrack) => { - // Only apply ripple effects to the same track unless multi-track ripple is enabled - const shouldApplyRipple = currentTrack.id === trackId; + return { ...currentTrack, elements: updatedElements }; + }); - const updatedElements = currentTrack.elements.map((currentElement) => { - if (currentElement.id === elementId && currentTrack.id === trackId) { - // Update the moved element - return { ...currentElement, startTime: newStartTime }; - } + updateTracksAndSave(updatedTracks); + }, - // Only apply ripple effects if we should process this track - if (!shouldApplyRipple) { - return currentElement; - } + toggleTrackMute: (trackId) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId ? { ...track, muted: !track.muted } : track, + ), + ); + }, - // For ripple editing, we need to move elements that come after the moved element - const currentElementStart = currentElement.startTime; - const currentElementEnd = - currentElement.startTime + - (currentElement.duration - - currentElement.trimStart - - currentElement.trimEnd); + updateTextElement: (trackId, elementId, updates) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((element) => + element.id === elementId && element.type === "text" + ? { ...element, ...updates } + : element, + ), + } + : track, + ), + ); + }, - // If moving element to the right (positive delta) - if (timeDelta > 0) { - // Move elements that start after the original position of the moved element - if (currentElementStart >= oldEndTime) { - return { - ...currentElement, - startTime: currentElementStart + timeDelta, - }; - } - } - // If moving element to the left (negative delta) - else if (timeDelta < 0) { - // Move elements that start after the new position of the moved element - if ( - currentElementStart >= newEndTime && - currentElementStart >= oldStartTime - ) { - return { - ...currentElement, - startTime: Math.max(0, currentElementStart + timeDelta), - }; - } - } + splitElement: (trackId, elementId, splitTime) => { + const { _tracks } = get(); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); - return currentElement; - }); + if (!element) return null; - // Check for overlaps and resolve them if necessary - const hasOverlaps = checkElementOverlaps(updatedElements); - if (hasOverlaps) { - // Resolve overlaps by adjusting element positions - const resolvedElements = resolveElementOverlaps(updatedElements); - return { ...currentTrack, elements: resolvedElements }; - } + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - return { ...currentTrack, elements: updatedElements }; - }); + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null; - updateTracksAndSave(updatedTracks); - }, + get().pushHistory(); - toggleTrackMute: (trackId) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId ? { ...track, muted: !track.muted } : track - ) - ); - }, + const relativeTime = splitTime - element.startTime; + const firstDuration = relativeTime; + const secondDuration = + element.duration - element.trimStart - element.trimEnd - relativeTime; - updateTextElement: (trackId, elementId, updates) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId && element.type === "text" - ? { ...element, ...updates } - : element - ), - } - : track - ) - ); - }, + const secondElementId = generateUUID(); - splitElement: (trackId, elementId, splitTime) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.flatMap((c) => + c.id === elementId + ? [ + { + ...c, + trimEnd: c.trimEnd + secondDuration, + name: getElementNameWithSuffix(c.name, "left"), + }, + { + ...c, + id: secondElementId, + startTime: splitTime, + trimStart: c.trimStart + firstDuration, + name: getElementNameWithSuffix(c.name, "right"), + }, + ] + : [c], + ), + } + : track, + ), + ); - if (!element) return null; + return secondElementId; + }, - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + // Split element and keep only the left portion + splitAndKeepLeft: (trackId, elementId, splitTime) => { + const { _tracks } = get(); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null; + if (!element) return; - get().pushHistory(); + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - const relativeTime = splitTime - element.startTime; - const firstDuration = relativeTime; - const secondDuration = - element.duration - element.trimStart - element.trimEnd - relativeTime; + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; - const secondElementId = generateUUID(); + get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.flatMap((c) => - c.id === elementId - ? [ - { - ...c, - trimEnd: c.trimEnd + secondDuration, - name: getElementNameWithSuffix(c.name, "left"), - }, - { - ...c, - id: secondElementId, - startTime: splitTime, - trimStart: c.trimStart + firstDuration, - name: getElementNameWithSuffix(c.name, "right"), - }, - ] - : [c] - ), - } - : track - ) - ); + const relativeTime = splitTime - element.startTime; + const durationToRemove = + element.duration - element.trimStart - element.trimEnd - relativeTime; - return secondElementId; - }, + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((c) => + c.id === elementId + ? { + ...c, + trimEnd: c.trimEnd + durationToRemove, + name: getElementNameWithSuffix(c.name, "left"), + } + : c, + ), + } + : track, + ), + ); + }, - // Split element and keep only the left portion - splitAndKeepLeft: (trackId, elementId, splitTime) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + // Split element and keep only the right portion + splitAndKeepRight: (trackId, elementId, splitTime) => { + const { _tracks } = get(); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; + if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; - get().pushHistory(); + get().pushHistory(); - const relativeTime = splitTime - element.startTime; - const durationToRemove = - element.duration - element.trimStart - element.trimEnd - relativeTime; + const relativeTime = splitTime - element.startTime; - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((c) => - c.id === elementId - ? { - ...c, - trimEnd: c.trimEnd + durationToRemove, - name: getElementNameWithSuffix(c.name, "left"), - } - : c - ), - } - : track - ) - ); - }, + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((c) => + c.id === elementId + ? { + ...c, + startTime: splitTime, + trimStart: c.trimStart + relativeTime, + name: getElementNameWithSuffix(c.name, "right"), + } + : c, + ), + } + : track, + ), + ); + }, - // Split element and keep only the right portion - splitAndKeepRight: (trackId, elementId, splitTime) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + // Extract audio from video element to an audio track + separateAudio: (trackId, elementId) => { + const { _tracks } = get(); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; + if (!element || track?.type !== "media") return null; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + get().pushHistory(); - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; + // Find existing audio track or prepare to create one + const existingAudioTrack = _tracks.find((t) => t.type === "audio"); + const audioElementId = generateUUID(); - get().pushHistory(); + if (existingAudioTrack) { + // Add audio element to existing audio track + updateTracksAndSave( + get()._tracks.map((track) => + track.id === existingAudioTrack.id + ? { + ...track, + elements: [ + ...track.elements, + { + ...element, + id: audioElementId, + name: getElementNameWithSuffix(element.name, "audio"), + }, + ], + } + : track, + ), + ); + } else { + // Create new audio track with the audio element in a single atomic update + const newAudioTrack: TimelineTrack = { + id: generateUUID(), + name: "Audio Track", + type: "audio", + elements: [ + { + ...element, + id: audioElementId, + name: getElementNameWithSuffix(element.name, "audio"), + }, + ], + muted: false, + }; - const relativeTime = splitTime - element.startTime; + updateTracksAndSave([...get()._tracks, newAudioTrack]); + } - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((c) => - c.id === elementId - ? { - ...c, - startTime: splitTime, - trimStart: c.trimStart + relativeTime, - name: getElementNameWithSuffix(c.name, "right"), - } - : c - ), - } - : track - ) - ); - }, + return audioElementId; + }, - // Extract audio from video element to an audio track - separateAudio: (trackId, elementId) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + // Replace media for an element + replaceElementMedia: async (trackId, elementId, newFile) => { + const { _tracks } = get(); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); - if (!element || track?.type !== "media") return null; + if (!element || element.type !== "media") return false; - get().pushHistory(); + try { + const mediaStore = useMediaStore.getState(); + const projectStore = useProjectStore.getState(); - // Find existing audio track or prepare to create one - const existingAudioTrack = _tracks.find((t) => t.type === "audio"); - const audioElementId = generateUUID(); + if (!projectStore.activeProject) return false; - if (existingAudioTrack) { - // Add audio element to existing audio track - updateTracksAndSave( - get()._tracks.map((track) => - track.id === existingAudioTrack.id - ? { - ...track, - elements: [ - ...track.elements, - { - ...element, - id: audioElementId, - name: getElementNameWithSuffix(element.name, "audio"), - }, - ], - } - : track - ) - ); - } else { - // Create new audio track with the audio element in a single atomic update - const newAudioTrack: TimelineTrack = { - id: generateUUID(), - name: "Audio Track", - type: "audio", - elements: [ - { - ...element, - id: audioElementId, - name: getElementNameWithSuffix(element.name, "audio"), - }, - ], - muted: false, - }; + // Import required media processing functions + const { + getFileType, + getImageDimensions, + generateVideoThumbnail, + getMediaDuration, + } = await import("./media-store"); - updateTracksAndSave([...get()._tracks, newAudioTrack]); - } + const fileType = getFileType(newFile); + if (!fileType) return false; - return audioElementId; - }, + // Process the new media file + const mediaData: { + name: string; + type: MediaType; + file: File; + url: string; + duration?: number; + thumbnailUrl?: string; + width?: number; + height?: number; + } = { + name: newFile.name, + type: fileType, + file: newFile, + url: URL.createObjectURL(newFile), + }; - // Replace media for an element - replaceElementMedia: async (trackId, elementId, newFile) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); + // Get media-specific metadata + if (fileType === "image") { + const { width, height } = await getImageDimensions(newFile); + mediaData.width = width; + mediaData.height = height; + } else if (fileType === "video") { + const [duration, { thumbnailUrl, width, height }] = await Promise.all( + [getMediaDuration(newFile), generateVideoThumbnail(newFile)], + ); + mediaData.duration = duration; + mediaData.thumbnailUrl = thumbnailUrl; + mediaData.width = width; + mediaData.height = height; + } else if (fileType === "audio") { + mediaData.duration = await getMediaDuration(newFile); + } - if (!element || element.type !== "media") return false; + // Add new media item to store + await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData); - try { - const mediaStore = useMediaStore.getState(); - const projectStore = useProjectStore.getState(); + // Find the newly created media item + const newMediaItem = mediaStore.mediaItems.find( + (item) => item.file === newFile, + ); - if (!projectStore.activeProject) return false; + if (!newMediaItem) return false; - // Import required media processing functions - const { - getFileType, - getImageDimensions, - generateVideoThumbnail, - getMediaDuration, - } = await import("./media-store"); + get().pushHistory(); - const fileType = getFileType(newFile); - if (!fileType) return false; + // Update the timeline element to reference the new media + updateTracksAndSave( + _tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((c) => + c.id === elementId + ? { + ...c, + mediaId: newMediaItem.id, + name: newMediaItem.name, + // Update duration if the new media has a different duration + duration: newMediaItem.duration || c.duration, + } + : c, + ), + } + : track, + ), + ); - // Process the new media file - let mediaData: any = { - name: newFile.name, - type: fileType, - file: newFile, - url: URL.createObjectURL(newFile), - }; + return true; + } catch (error) { + console.log( + JSON.stringify({ + error: "Failed to replace element media", + details: error, + }), + ); + return false; + } + }, - // Get media-specific metadata - if (fileType === "image") { - const { width, height } = await getImageDimensions(newFile); - mediaData.width = width; - mediaData.height = height; - } else if (fileType === "video") { - const [duration, { thumbnailUrl, width, height }] = await Promise.all( - [getMediaDuration(newFile), generateVideoThumbnail(newFile)] - ); - mediaData.duration = duration; - mediaData.thumbnailUrl = thumbnailUrl; - mediaData.width = width; - mediaData.height = height; - } else if (fileType === "audio") { - mediaData.duration = await getMediaDuration(newFile); - } + getTotalDuration: () => { + const { _tracks } = get(); + if (_tracks.length === 0) return 0; - // Add new media item to store - await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData); + const trackEndTimes = _tracks.map((track) => + track.elements.reduce((maxEnd, element) => { + const elementEnd = + element.startTime + + element.duration - + element.trimStart - + element.trimEnd; + return Math.max(maxEnd, elementEnd); + }, 0), + ); - // Find the newly created media item - const newMediaItem = mediaStore.mediaItems.find( - (item) => item.file === newFile - ); + return Math.max(...trackEndTimes, 0); + }, - if (!newMediaItem) return false; + getProjectThumbnail: async (projectId) => { + try { + const tracks = await storageService.loadTimeline(projectId); + const mediaItems = await storageService.loadAllMediaItems(projectId); - get().pushHistory(); + if (!tracks || !mediaItems.length) return null; - // Update the timeline element to reference the new media - updateTracksAndSave( - _tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((c) => - c.id === elementId - ? { - ...c, - mediaId: newMediaItem.id, - name: newMediaItem.name, - // Update duration if the new media has a different duration - duration: newMediaItem.duration || c.duration, - } - : c - ), - } - : track - ) - ); + const firstMediaElement = tracks + .flatMap((track) => track.elements) + .filter((element) => element.type === "media") + .sort((a, b) => a.startTime - b.startTime)[0]; - return true; - } catch (error) { - console.log( - JSON.stringify({ - error: "Failed to replace element media", - details: error, - }) - ); - return false; - } - }, + if (!firstMediaElement) return null; - getTotalDuration: () => { - const { _tracks } = get(); - if (_tracks.length === 0) return 0; + const mediaItem = mediaItems.find( + (item) => item.id === firstMediaElement.mediaId, + ); + if (!mediaItem) return null; - const trackEndTimes = _tracks.map((track) => - track.elements.reduce((maxEnd, element) => { - const elementEnd = - element.startTime + - element.duration - - element.trimStart - - element.trimEnd; - return Math.max(maxEnd, elementEnd); - }, 0) - ); + if (mediaItem.type === "video" && mediaItem.file) { + const { generateVideoThumbnail } = await import( + "@/stores/media-store" + ); + const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file); + return thumbnailUrl; + } else if (mediaItem.type === "image" && mediaItem.url) { + return mediaItem.url; + } - return Math.max(...trackEndTimes, 0); - }, + return null; + } catch (error) { + console.error("Failed to get project thumbnail:", error); + return null; + } + }, - redo: () => { - const { redoStack } = get(); - if (redoStack.length === 0) return; - const next = redoStack[redoStack.length - 1]; - updateTracksAndSave(next); - set({ redoStack: redoStack.slice(0, -1) }); - }, + redo: () => { + const { redoStack } = get(); + if (redoStack.length === 0) return; + const next = redoStack[redoStack.length - 1]; + updateTracksAndSave(next); + set({ redoStack: redoStack.slice(0, -1) }); + }, - dragState: { - isDragging: false, - elementId: null, - trackId: null, - startMouseX: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - }, + dragState: { + isDragging: false, + elementId: null, + trackId: null, + startMouseX: 0, + startElementTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }, - setDragState: (dragState) => - set((state) => ({ - dragState: { ...state.dragState, ...dragState }, - })), + setDragState: (dragState) => + set((state) => ({ + dragState: { ...state.dragState, ...dragState }, + })), - startDrag: ( - elementId, - trackId, - startMouseX, - startElementTime, - clickOffsetTime - ) => { - set({ - dragState: { - isDragging: true, - elementId, - trackId, - startMouseX, - startElementTime, - clickOffsetTime, - currentTime: startElementTime, - }, - }); - }, + startDrag: ( + elementId, + trackId, + startMouseX, + startElementTime, + clickOffsetTime, + ) => { + set({ + dragState: { + isDragging: true, + elementId, + trackId, + startMouseX, + startElementTime, + clickOffsetTime, + currentTime: startElementTime, + }, + }); + }, - updateDragTime: (currentTime) => { - set((state) => ({ - dragState: { - ...state.dragState, - currentTime, - }, - })); - }, + updateDragTime: (currentTime) => { + set((state) => ({ + dragState: { + ...state.dragState, + currentTime, + }, + })); + }, - endDrag: () => { - set({ - dragState: { - isDragging: false, - elementId: null, - trackId: null, - startMouseX: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - }, - }); - }, + endDrag: () => { + set({ + dragState: { + isDragging: false, + elementId: null, + trackId: null, + startMouseX: 0, + startElementTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }, + }); + }, - // Persistence methods - loadProjectTimeline: async (projectId) => { - try { - const tracks = await storageService.loadTimeline(projectId); - if (tracks) { - updateTracks(tracks); - } else { - // No timeline saved yet, initialize with default - const defaultTracks = ensureMainTrack([]); - updateTracks(defaultTracks); - } - // Clear history when loading a project - set({ history: [], redoStack: [] }); - } catch (error) { - console.error("Failed to load timeline:", error); - // Initialize with default on error - const defaultTracks = ensureMainTrack([]); - updateTracks(defaultTracks); - set({ history: [], redoStack: [] }); - } - }, + // Persistence methods + loadProjectTimeline: async (projectId) => { + try { + const tracks = await storageService.loadTimeline(projectId); + if (tracks) { + updateTracks(tracks); + } else { + // No timeline saved yet, initialize with default + const defaultTracks = ensureMainTrack([]); + updateTracks(defaultTracks); + } + // Clear history when loading a project + set({ history: [], redoStack: [] }); + } catch (error) { + console.error("Failed to load timeline:", error); + // Initialize with default on error + const defaultTracks = ensureMainTrack([]); + updateTracks(defaultTracks); + set({ history: [], redoStack: [] }); + } + }, - saveProjectTimeline: async (projectId) => { - try { - await storageService.saveTimeline(projectId, get()._tracks); - } catch (error) { - console.error("Failed to save timeline:", error); - } - }, + saveProjectTimeline: async (projectId) => { + try { + await storageService.saveTimeline(projectId, get()._tracks); + } catch (error) { + console.error("Failed to save timeline:", error); + } + }, - clearTimeline: () => { - const defaultTracks = ensureMainTrack([]); - updateTracks(defaultTracks); - set({ history: [], redoStack: [], selectedElements: [] }); - }, + clearTimeline: () => { + const defaultTracks = ensureMainTrack([]); + updateTracks(defaultTracks); + set({ history: [], redoStack: [], selectedElements: [] }); + }, - // Snapping actions - toggleSnapping: () => { - set((state) => ({ snappingEnabled: !state.snappingEnabled })); - }, + // Snapping actions + toggleSnapping: () => { + set((state) => ({ snappingEnabled: !state.snappingEnabled })); + }, - // Ripple editing functions - toggleRippleEditing: () => { - set((state) => ({ - rippleEditingEnabled: !state.rippleEditingEnabled, - })); - }, + // Ripple editing functions + toggleRippleEditing: () => { + set((state) => ({ + rippleEditingEnabled: !state.rippleEditingEnabled, + })); + }, - checkElementOverlap: (trackId, startTime, duration, excludeElementId) => { - const track = get()._tracks.find((t) => t.id === trackId); - if (!track) return false; + 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; + const overlap = track.elements.some((element) => { + const elementEnd = + element.startTime + + element.duration - + element.trimStart - + element.trimEnd; - if (element.id === excludeElementId) { - return false; - } + 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; - }, + 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); - } + 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; - } + const existingTrack = get()._tracks.find((t) => t.type === trackType); + if (existingTrack) { + return existingTrack.id; + } - return get().addTrack(trackType); - }, + return get().addTrack(trackType); + }, - addMediaAtTime: (item, currentTime = 0) => { - const trackType = item.type === "audio" ? "audio" : "media"; - const targetTrackId = get().findOrCreateTrack(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; + 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; - } + 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; - }, + 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 + 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; - }, + 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); + 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; - }, + 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 + 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; - }, - }; + 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; + }, + }; }); |
