diff options
Diffstat (limited to 'apps/web/src')
15 files changed, 1118 insertions, 754 deletions
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 7ac08107..4b68791b 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -1,35 +1,33 @@ "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, + ChevronLeft, + Loader2, MoreHorizontal, + Plus, + Search, + Trash2, Video, - Loader2, X, - Trash2, - Search, } from "lucide-react"; -import { TProject } from "@/types/project"; import Image from "next/image"; +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, - DropdownMenuItem, DropdownMenuContent, - DropdownMenuTrigger, + DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useProjectStore } from "@/stores/project-store"; -import { useRouter } from "next/navigation"; -import { DeleteProjectDialog } from "@/components/delete-project-dialog"; -import { RenameProjectDialog } from "@/components/rename-project-dialog"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -37,17 +35,26 @@ import { 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, + createNewProject, getFilteredAndSortedProjects, } = useProjectStore(); - const router = useRouter(); + 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() @@ -55,6 +62,32 @@ export default function ProjectsPage() { 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)); + + 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 handleCreateProject = async () => { const projectId = await createNewProject("New Project"); @@ -209,25 +242,45 @@ export default function ProjectsPage() { </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> + <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> )} {isLoading || !isInitialized ? ( - <div className="flex items-center justify-center py-16"> - <Loader2 className="h-8 w-8 text-muted-foreground animate-spin" /> + <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} /> @@ -245,6 +298,7 @@ export default function ProjectsPage() { isSelectionMode={isSelectionMode} isSelected={selectedProjects.has(project.id)} onSelect={handleSelectProject} + getProjectThumbnail={getProjectThumbnail} /> ))} </div> @@ -265,6 +319,7 @@ interface ProjectCardProps { isSelectionMode?: boolean; isSelected?: boolean; onSelect?: (projectId: string, checked: boolean) => void; + getProjectThumbnail: (projectId: string) => Promise<string | null>; } function ProjectCard({ @@ -272,12 +327,28 @@ function ProjectCard({ isSelectionMode = false, isSelected = false, onSelect, + getProjectThumbnail, }: ProjectCardProps) { 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", @@ -308,242 +379,156 @@ function ProjectCard({ } }; - 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> + 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="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 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> - <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} + <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()} > - <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> + <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> + ); + + 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} - projectName={project.name} /> <RenameProjectDialog isOpen={isRenameDialogOpen} diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index a04af269..7daf5270 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -8,59 +8,45 @@ import { HeaderBase } from "./header-base"; import { formatTimeCode } from "@/lib/time";
import { useProjectStore } from "@/stores/project-store";
import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
-import { useState, useRef, useEffect } from "react";
+import { useState, useRef } from "react";
import { Input } from "@/components/ui/input";
export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
const { activeProject, renameProject } = useProjectStore();
- // State for edit mode and project name input
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(activeProject?.name || "");
- // Ref for focusing/selecting the input
const inputRef = useRef<HTMLInputElement>(null);
const handleExport = () => {
// TODO: Implement export functionality
+ // NOTE: This is already being worked on
console.log("Export project");
};
- // When user clicks the project name, enter edit mode
const handleNameClick = () => {
if (!activeProject) return;
setNewName(activeProject.name);
setIsEditing(true);
};
- // Focus/select input when entering edit mode
- useEffect(() => {
- if (isEditing && inputRef.current) {
- inputRef.current.focus();
- inputRef.current.select();
- }
- }, [isEditing]);
-
- // Save the new name if changed, exit edit mode
const handleNameSave = async () => {
if (activeProject && newName.trim() && newName !== activeProject.name) {
try {
await renameProject(activeProject.id, newName.trim());
- }catch (error) {
- console.error('Failed to rename project:', error);
- // Reset to original name on error
+ } catch (error) {
+ console.error("Failed to rename project:", error);
setNewName(activeProject.name);
}
}
setIsEditing(false);
};
- // Handle Enter (save) and Escape (cancel) in the input
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") handleNameSave();
else if (e.key === "Escape") setIsEditing(false);
};
- // Project name in header: editable input or span
const leftContent = (
<div className="flex items-center gap-2">
<Link
@@ -71,28 +57,30 @@ export function EditorHeader() { </Link>
<div className="w-[14rem] h-9 flex items-center">
{isEditing ? (
- // Editable input for project name using standard Input component
<Input
ref={inputRef}
className="text-sm font-medium px-2 py-1 h-9 truncate"
value={newName}
- onChange={e => setNewName(e.target.value)}
+ onChange={(e) => setNewName(e.target.value)}
onBlur={handleNameSave}
onKeyDown={handleInputKeyDown}
+ onFocus={(e) => e.target.select()}
maxLength={64}
aria-label="Project name"
autoFocus
/>
) : (
- // Display project name as span, click to edit
<span
className="text-sm font-medium cursor-pointer hover:underline"
title="Click to rename"
role="button"
tabIndex={0}
onClick={handleNameClick}
- onKeyDown={e => e.key === 'Enter' && handleNameClick()}
- ><div className="truncate text-ellipsis overflow-clip w-40">{activeProject?.name}</div>
+ onKeyDown={(e) => e.key === "Enter" && handleNameClick()}
+ >
+ <div className="truncate text-ellipsis overflow-clip w-40">
+ {activeProject?.name}
+ </div>
</span>
)}
</div>
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 2ecee391..2cba1374 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -16,8 +16,8 @@ import { DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { Play, Pause, Expand } from "lucide-react"; -import { useState, useRef, useEffect } from "react"; +import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { FONT_CLASS_MAP } from "@/lib/font-config"; @@ -31,9 +31,9 @@ interface ActiveElement { } export function PreviewPanel() { - const { tracks } = useTimelineStore(); + const { tracks, getTotalDuration } = useTimelineStore(); const { mediaItems } = useMediaStore(); - const { currentTime } = usePlaybackStore(); + const { currentTime, toggle, setCurrentTime, isPlaying } = usePlaybackStore(); const { canvasSize } = useEditorStore(); const previewRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null); @@ -41,52 +41,51 @@ export function PreviewPanel() { width: 0, height: 0, }); + const [isExpanded, setIsExpanded] = useState(false); const { activeProject } = useProjectStore(); - // Calculate optimal preview size that fits in container while maintaining aspect ratio useEffect(() => { const updatePreviewSize = () => { if (!containerRef.current) return; - const container = containerRef.current.getBoundingClientRect(); - const computedStyle = getComputedStyle(containerRef.current); + let availableWidth, availableHeight; - // Get padding values - const paddingTop = parseFloat(computedStyle.paddingTop); - const paddingBottom = parseFloat(computedStyle.paddingBottom); - const paddingLeft = parseFloat(computedStyle.paddingLeft); - const paddingRight = parseFloat(computedStyle.paddingRight); - - // Get gap value (gap-4 = 1rem = 16px) - const gap = parseFloat(computedStyle.gap) || 16; - - // Get toolbar height if it exists - const toolbar = containerRef.current.querySelector("[data-toolbar]"); - const toolbarHeight = toolbar - ? toolbar.getBoundingClientRect().height - : 0; + if (isExpanded) { + const controlsHeight = 80; + const marginSpace = 24; + availableWidth = window.innerWidth - marginSpace; + availableHeight = window.innerHeight - controlsHeight - marginSpace; + } else { + const container = containerRef.current.getBoundingClientRect(); + const computedStyle = getComputedStyle(containerRef.current); + const paddingTop = parseFloat(computedStyle.paddingTop); + const paddingBottom = parseFloat(computedStyle.paddingBottom); + const paddingLeft = parseFloat(computedStyle.paddingLeft); + const paddingRight = parseFloat(computedStyle.paddingRight); + const gap = parseFloat(computedStyle.gap) || 16; + const toolbar = containerRef.current.querySelector("[data-toolbar]"); + const toolbarHeight = toolbar + ? toolbar.getBoundingClientRect().height + : 0; - // Calculate available space after accounting for padding, gap, and toolbar - const availableWidth = container.width - paddingLeft - paddingRight; - const availableHeight = - container.height - - paddingTop - - paddingBottom - - toolbarHeight - - (toolbarHeight > 0 ? gap : 0); + availableWidth = container.width - paddingLeft - paddingRight; + availableHeight = + container.height - + paddingTop - + paddingBottom - + toolbarHeight - + (toolbarHeight > 0 ? gap : 0); + } const targetRatio = canvasSize.width / canvasSize.height; const containerRatio = availableWidth / availableHeight; - let width, height; if (containerRatio > targetRatio) { - // Container is wider - constrain by height - height = availableHeight; + height = availableHeight * (isExpanded ? 0.95 : 1); width = height * targetRatio; } else { - // Container is taller - constrain by width - width = availableWidth; + width = availableWidth * (isExpanded ? 0.95 : 1); height = width / targetRatio; } @@ -94,16 +93,47 @@ export function PreviewPanel() { }; updatePreviewSize(); - const resizeObserver = new ResizeObserver(updatePreviewSize); if (containerRef.current) { resizeObserver.observe(containerRef.current); } + if (isExpanded) { + window.addEventListener("resize", updatePreviewSize); + } + + return () => { + resizeObserver.disconnect(); + if (isExpanded) { + window.removeEventListener("resize", updatePreviewSize); + } + }; + }, [canvasSize.width, canvasSize.height, isExpanded]); + + useEffect(() => { + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && isExpanded) { + setIsExpanded(false); + } + }; + + if (isExpanded) { + document.addEventListener("keydown", handleEscapeKey); + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + + return () => { + document.removeEventListener("keydown", handleEscapeKey); + document.body.style.overflow = ""; + }; + }, [isExpanded]); - return () => resizeObserver.disconnect(); - }, [canvasSize.width, canvasSize.height]); + const toggleExpanded = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); - // Get active elements at current time + const hasAnyElements = tracks.some((track) => track.elements.length > 0); const getActiveElements = (): ActiveElement[] => { const activeElements: ActiveElement[] = []; @@ -116,16 +146,13 @@ export function PreviewPanel() { if (currentTime >= elementStart && currentTime < elementEnd) { let mediaItem = null; - - // Only get media item for media elements if (element.type === "media") { mediaItem = element.mediaId === "test" - ? null // Test elements don't have a real media item + ? null : mediaItems.find((item) => item.id === element.mediaId) || null; } - activeElements.push({ element, track, mediaItem }); } }); @@ -136,9 +163,6 @@ export function PreviewPanel() { const activeElements = getActiveElements(); - // Check if there are any elements in the timeline at all - const hasAnyElements = tracks.some((track) => track.elements.length > 0); - // Get media elements for blur background (video/image only) const getBlurBackgroundElements = (): ActiveElement[] => { return activeElements.filter( @@ -338,58 +362,334 @@ export function PreviewPanel() { }; return ( - <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm"> - <div - ref={containerRef} - className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" - > - <div className="flex-1"></div> - {hasAnyElements ? ( - <div - ref={previewRef} - className="relative overflow-hidden border" - style={{ - width: previewDimensions.width, - height: previewDimensions.height, - backgroundColor: - activeProject?.backgroundType === "blur" - ? "transparent" - : activeProject?.backgroundColor || "#000000", - }} - > - {renderBlurBackground()} - {activeElements.length === 0 ? ( - <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> - No elements at current time - </div> - ) : ( - activeElements.map((elementData, index) => - renderElement(elementData, index) - ) - )} - {/* Show message when blur is selected but no media available */} - {activeProject?.backgroundType === "blur" && - blurBackgroundElements.length === 0 && - activeElements.length > 0 && ( - <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> - Add a video or image to use blur background + <> + <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm"> + <div + ref={containerRef} + className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" + > + <div className="flex-1"></div> + {hasAnyElements ? ( + <div + ref={previewRef} + className="relative overflow-hidden border" + style={{ + width: previewDimensions.width, + height: previewDimensions.height, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "transparent" + : activeProject?.backgroundColor || "#000000", + }} + > + {renderBlurBackground()} + {activeElements.length === 0 ? ( + <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> + No elements at current time </div> + ) : ( + activeElements.map((elementData, index) => + renderElement(elementData, index) + ) )} - </div> - ) : null} + {activeProject?.backgroundType === "blur" && + blurBackgroundElements.length === 0 && + activeElements.length > 0 && ( + <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> + Add a video or image to use blur background + </div> + )} + </div> + ) : null} + + <div className="flex-1"></div> + + <PreviewToolbar + hasAnyElements={hasAnyElements} + onToggleExpanded={toggleExpanded} + isExpanded={isExpanded} + currentTime={currentTime} + setCurrentTime={setCurrentTime} + toggle={toggle} + getTotalDuration={getTotalDuration} + /> + </div> + </div> + + {isExpanded && ( + <FullscreenPreview + previewDimensions={previewDimensions} + activeProject={activeProject} + renderBlurBackground={renderBlurBackground} + activeElements={activeElements} + renderElement={renderElement} + blurBackgroundElements={blurBackgroundElements} + hasAnyElements={hasAnyElements} + toggleExpanded={toggleExpanded} + currentTime={currentTime} + setCurrentTime={setCurrentTime} + toggle={toggle} + getTotalDuration={getTotalDuration} + /> + )} + </> + ); +} + +function FullscreenToolbar({ + hasAnyElements, + onToggleExpanded, + currentTime, + setCurrentTime, + toggle, + getTotalDuration, +}: { + hasAnyElements: boolean; + onToggleExpanded: () => void; + currentTime: number; + setCurrentTime: (time: number) => void; + toggle: () => void; + getTotalDuration: () => number; +}) { + const { isPlaying } = usePlaybackStore(); + const { activeProject } = useProjectStore(); + const [isDragging, setIsDragging] = useState(false); + + const totalDuration = getTotalDuration(); + const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0; + + const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => { + if (!hasAnyElements) return; + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const percentage = Math.max(0, Math.min(1, clickX / rect.width)); + const newTime = percentage * totalDuration; + setCurrentTime(Math.max(0, Math.min(newTime, totalDuration))); + }; + + const handleTimelineDrag = (e: React.MouseEvent<HTMLDivElement>) => { + if (!hasAnyElements) return; + e.preventDefault(); + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + setIsDragging(true); + + const handleMouseMove = (moveEvent: MouseEvent) => { + moveEvent.preventDefault(); + const dragX = moveEvent.clientX - rect.left; + const percentage = Math.max(0, Math.min(1, dragX / rect.width)); + const newTime = percentage * totalDuration; + setCurrentTime(Math.max(0, Math.min(newTime, totalDuration))); + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.userSelect = ""; + }; + + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + handleMouseMove(e.nativeEvent); + }; + + const skipBackward = () => { + const newTime = Math.max(0, currentTime - 1); + setCurrentTime(newTime); + }; + + const skipForward = () => { + const newTime = Math.min(totalDuration, currentTime + 1); + setCurrentTime(newTime); + }; - <div className="flex-1"></div> + return ( + <div + data-toolbar + className="flex items-center gap-2 p-1 pt-2 w-full text-white" + > + <div className="flex items-center gap-1 text-[0.70rem] tabular-nums text-white/90"> + <span className="text-primary"> + {formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)} + </span> + <span className="opacity-50">/</span> + <span> + {formatTimeCode( + totalDuration, + "HH:MM:SS:FF", + activeProject?.fps || 30 + )} + </span> + </div> - <PreviewToolbar hasAnyElements={hasAnyElements} /> + <div className="flex items-center gap-1"> + <Button + variant="text" + size="icon" + onClick={skipBackward} + disabled={!hasAnyElements} + className="h-auto p-0 text-white hover:text-white/80" + title="Skip backward 1s" + > + <SkipBack className="h-3 w-3" /> + </Button> + <Button + variant="text" + size="icon" + onClick={toggle} + disabled={!hasAnyElements} + className="h-auto p-0 text-white hover:text-white/80" + > + {isPlaying ? ( + <Pause className="h-3 w-3" /> + ) : ( + <Play className="h-3 w-3" /> + )} + </Button> + <Button + variant="text" + size="icon" + onClick={skipForward} + disabled={!hasAnyElements} + className="h-auto p-0 text-white hover:text-white/80" + title="Skip forward 1s" + > + <SkipForward className="h-3 w-3" /> + </Button> </div> + + <div className="flex-1 flex items-center gap-2"> + <div + className={cn( + "relative h-1 rounded-full cursor-pointer flex-1 bg-white/20", + !hasAnyElements && "opacity-50 cursor-not-allowed" + )} + onClick={hasAnyElements ? handleTimelineClick : undefined} + onMouseDown={hasAnyElements ? handleTimelineDrag : undefined} + style={{ userSelect: "none" }} + > + <div + className={cn( + "absolute top-0 left-0 h-full rounded-full bg-white", + !isDragging && "duration-100" + )} + style={{ width: `${progress}%` }} + /> + <div + className="absolute top-1/2 w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 shadow-sm bg-white border border-black/20" + style={{ left: `${progress}%` }} + /> + </div> + </div> + + <Button + variant="text" + size="icon" + className="!size-4 text-white/80 hover:text-white" + onClick={onToggleExpanded} + title="Exit fullscreen (Esc)" + > + <Expand className="!size-4" /> + </Button> </div> ); } -function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { - const { isPlaying, toggle, currentTime } = usePlaybackStore(); +function FullscreenPreview({ + previewDimensions, + activeProject, + renderBlurBackground, + activeElements, + renderElement, + blurBackgroundElements, + hasAnyElements, + toggleExpanded, + currentTime, + setCurrentTime, + toggle, + getTotalDuration, +}: { + previewDimensions: { width: number; height: number }; + activeProject: any; + renderBlurBackground: () => React.ReactNode; + activeElements: ActiveElement[]; + renderElement: (elementData: ActiveElement, index: number) => React.ReactNode; + blurBackgroundElements: ActiveElement[]; + hasAnyElements: boolean; + toggleExpanded: () => void; + currentTime: number; + setCurrentTime: (time: number) => void; + toggle: () => void; + getTotalDuration: () => number; +}) { + return ( + <div className="fixed inset-0 z-[9999] flex flex-col"> + <div className="flex-1 flex items-center justify-center bg-background"> + <div + className="relative overflow-hidden border border-border m-3" + style={{ + width: previewDimensions.width, + height: previewDimensions.height, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "#1a1a1a" + : activeProject?.backgroundColor || "#1a1a1a", + }} + > + {renderBlurBackground()} + {activeElements.length === 0 ? ( + <div className="absolute inset-0 flex items-center justify-center text-white/60"> + No elements at current time + </div> + ) : ( + activeElements.map((elementData, index) => + renderElement(elementData, index) + ) + )} + {activeProject?.backgroundType === "blur" && + blurBackgroundElements.length === 0 && + activeElements.length > 0 && ( + <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> + Add a video or image to use blur background + </div> + )} + </div> + </div> + <div className="p-4 bg-black"> + <FullscreenToolbar + hasAnyElements={hasAnyElements} + onToggleExpanded={toggleExpanded} + currentTime={currentTime} + setCurrentTime={setCurrentTime} + toggle={toggle} + getTotalDuration={getTotalDuration} + /> + </div> + </div> + ); +} + +function PreviewToolbar({ + hasAnyElements, + onToggleExpanded, + isExpanded, + currentTime, + setCurrentTime, + toggle, + getTotalDuration, +}: { + hasAnyElements: boolean; + onToggleExpanded: () => void; + isExpanded: boolean; + currentTime: number; + setCurrentTime: (time: number) => void; + toggle: () => void; + getTotalDuration: () => number; +}) { + const { isPlaying } = usePlaybackStore(); const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); - const { getTotalDuration } = useTimelineStore(); const { activeProject } = useProjectStore(); const { currentPreset, @@ -408,6 +708,21 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { setCanvasSizeToOriginal(aspectRatio); }; + if (isExpanded) { + return ( + <FullscreenToolbar + {...{ + hasAnyElements, + onToggleExpanded, + currentTime, + setCurrentTime, + toggle, + getTotalDuration, + }} + /> + ); + } + return ( <div data-toolbar @@ -488,6 +803,8 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { variant="text" size="icon" className="!size-4 text-muted-foreground" + onClick={onToggleExpanded} + title="Enter fullscreen" > <Expand className="!size-4" /> </Button> diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 50f6c5f9..4fc5d89d 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -103,7 +103,9 @@ function PropertyItem({ label, value }: { label: string; value: string }) { return ( <div className="flex justify-between"> <Label className="text-xs text-muted-foreground">{label}</Label> - <span className="text-xs text-right truncate overflow-hidden whitespace-nowrap w-40" title={value}>{value}</span> + <span className="text-xs text-right truncate w-40" title={value}> + {value} + </span> </div> ); } diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline/index.tsx index 6e8c453c..2360eb8a 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -1,7 +1,7 @@ "use client"; -import { ScrollArea } from "../ui/scroll-area"; -import { Button } from "../ui/button"; +import { ScrollArea } from "../../ui/scroll-area"; +import { Button } from "../../ui/button"; import { Scissors, ArrowLeftToLine, @@ -15,22 +15,23 @@ import { Video, Music, TypeIcon, - Lock, - LockOpen, + Magnet, Link, + ZoomIn, + ZoomOut, } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider, -} from "../ui/tooltip"; +} from "../../ui/tooltip"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, -} from "../ui/context-menu"; +} from "../../ui/context-menu"; import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; @@ -44,9 +45,9 @@ import { TimelinePlayhead, useTimelinePlayheadRuler, } from "./timeline-playhead"; -import { SelectionBox } from "./selection-box"; +import { SelectionBox } from "../selection-box"; import { useSelectionBox } from "@/hooks/use-selection-box"; -import { SnapIndicator } from "./snap-indicator"; +import { SnapIndicator } from "../snap-indicator"; import { SnapPoint } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack } from "@/types/timeline"; import { @@ -56,6 +57,7 @@ import { TIMELINE_CONSTANTS, snapTimeToFrame, } from "@/constants/timeline-constants"; +import { Slider } from "@/components/ui/slider"; export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their elements. @@ -64,23 +66,11 @@ export function Timeline() { const { tracks, - addTrack, - addElementToTrack, - removeElementFromTrack, - removeElementFromTrackWithRipple, getTotalDuration, - selectedElements, clearSelectedElements, + snappingEnabled, setSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, toggleTrackMute, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, dragState, } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); @@ -288,7 +278,7 @@ export function Timeline() { Math.min( duration, (mouseX + scrollLeft) / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) ) ); @@ -430,139 +420,6 @@ export function Timeline() { onDrop: handleDrop, }; - // Action handlers for toolbar - const handleSplitSelected = () => { - if (selectedElements.length === 0) { - return; - } - let splitCount = 0; - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (element && track) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newElementId = splitElement(trackId, elementId, currentTime); - if (newElementId) splitCount++; - } - } - }); - if (splitCount === 0) { - toast.error("Playhead must be within selected elements to split"); - } - }; - - const handleDuplicateSelected = () => { - if (selectedElements.length === 0) { - return; - } - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; - - const newSelections: { trackId: string; elementId: string }[] = []; - - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((el) => el.id === elementId); - - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - - // Create element without id (will be generated by store) - const { id, ...elementWithoutId } = element; - - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - - // We can't predict the new id, so just clear selection for now - // TODO: addElementToTrack could return the new element id - } - }); - - clearSelectedElements(); - }; - - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; - - const handleSplitAndKeepLeft = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepLeft(trackId, elementId, currentTime); - }; - - const handleSplitAndKeepRight = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepRight(trackId, elementId, currentTime); - }; - - const handleSeparateAudio = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } - separateAudio(trackId, elementId); - }; - - const handleDeleteSelected = () => { - if (selectedElements.length === 0) { - return; - } - selectedElements.forEach(({ trackId, elementId }) => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(trackId, elementId); - } else { - removeElementFromTrack(trackId, elementId); - } - }); - clearSelectedElements(); - }; - // --- Scroll synchronization effect --- useEffect(() => { const rulerViewport = rulerScrollRef.current?.querySelector( @@ -649,192 +506,7 @@ export function Timeline() { onMouseEnter={() => setIsInTimeline(true)} onMouseLeave={() => setIsInTimeline(false)} > - {/* Toolbar */} - <div className="border-b flex items-center justify-between px-2 py-1"> - <div className="flex items-center gap-1 w-full"> - <TooltipProvider delayDuration={500}> - {/* Play/Pause Button */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={toggle} - className="mr-2" - > - {isPlaying ? ( - <Pause className="h-4 w-4" /> - ) : ( - <Play className="h-4 w-4" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent> - {isPlaying ? "Pause (Space)" : "Play (Space)"} - </TooltipContent> - </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - {/* Time Display */} - <div - className="text-xs text-muted-foreground font-mono px-2" - style={{ minWidth: "18ch", textAlign: "center" }} - > - {currentTime.toFixed(1)}s / {duration.toFixed(1)}s - </div> - {/* Test Clip Button - for debugging */} - {tracks.length === 0 && ( - <> - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size="sm" - onClick={() => { - const trackId = addTrack("media"); - addElementToTrack(trackId, { - type: "media", - mediaId: "test", - name: "Test Clip", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); - }} - className="text-xs" - > - Add Test Clip - </Button> - </TooltipTrigger> - <TooltipContent> - Add a test clip to try playback - </TooltipContent> - </Tooltip> - </> - )} - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitSelected} - > - <Scissors className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split element (Ctrl+S)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepLeft} - > - <ArrowLeftToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepRight} - > - <ArrowRightToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSeparateAudio} - > - <SplitSquareHorizontal className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleDuplicateSelected} - > - <Copy className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleFreezeSelected} - > - <Snowflake className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Freeze frame (F)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleDeleteSelected} - > - <Trash2 className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Delete element (Delete)</TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - <div className="flex items-center gap-1"> - <TooltipProvider delayDuration={500}> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={toggleSnapping}> - {snappingEnabled ? ( - <LockOpen className="h-4 w-4 text-primary" /> - ) : ( - <Lock className="h-4 w-4" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent>Auto snapping</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={toggleRippleEditing} - > - <Link - className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`} - /> - </Button> - </TooltipTrigger> - <TooltipContent> - {rippleEditingEnabled - ? "Disable Ripple Editing" - : "Enable Ripple Editing"} - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </div> + <TimelineToolbar zoomLevel={zoomLevel} setZoomLevel={setZoomLevel} /> {/* Timeline Container */} <div @@ -878,7 +550,13 @@ export function Timeline() { {/* Timeline Ruler */} <div className="flex-1 relative overflow-hidden h-4" - onWheel={handleWheel} + onWheel={(e) => { + // Check if this is horizontal scrolling - if so, don't handle it here + if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + return; // Let ScrollArea handle horizontal scrolling + } + handleWheel(e); + }} onMouseDown={handleSelectionMouseDown} onClick={handleTimelineContentClick} data-ruler-area @@ -920,21 +598,19 @@ export function Timeline() { return ( <div key={i} - className={`absolute top-0 bottom-0 ${ - isMainMarker + className={`absolute top-0 bottom-0 ${isMainMarker ? "border-l border-muted-foreground/40" : "border-l border-muted-foreground/20" - }`} + }`} style={{ left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, }} > <span - className={`absolute top-1 left-1 text-[0.6rem] ${ - isMainMarker + className={`absolute top-1 left-1 text-[0.6rem] ${isMainMarker ? "text-muted-foreground font-medium" : "text-muted-foreground/70" - }`} + }`} > {(() => { const formatTime = (seconds: number) => { @@ -999,7 +675,13 @@ export function Timeline() { {/* Timeline Tracks Content */} <div className="flex-1 relative overflow-hidden" - onWheel={handleWheel} + onWheel={(e) => { + // Check if this is horizontal scrolling - if so, don't handle it here + if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + return; // Let ScrollArea handle horizontal scrolling + } + handleWheel(e); + }} onMouseDown={(e) => { handleTimelineMouseDown(e); handleSelectionMouseDown(e); @@ -1013,7 +695,12 @@ export function Timeline() { containerRef={tracksContainerRef} isActive={selectionBox?.isActive || false} /> - <ScrollArea className="w-full h-full" ref={tracksScrollRef}> + <ScrollArea + className="w-full h-full" + ref={tracksScrollRef} + type="scroll" + showHorizontalScrollbar + > <div className="relative flex-1" style={{ @@ -1090,3 +777,340 @@ function TrackIcon({ track }: { track: TimelineTrack }) { </> ); } + +function TimelineToolbar({ + zoomLevel, + setZoomLevel, +}: { + zoomLevel: number; + setZoomLevel: (zoom: number) => void; +}) { + const { + tracks, + addTrack, + addElementToTrack, + removeElementFromTrack, + removeElementFromTrackWithRipple, + selectedElements, + clearSelectedElements, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + snappingEnabled, + toggleSnapping, + rippleEditingEnabled, + toggleRippleEditing, + } = useTimelineStore(); + const { currentTime, duration, isPlaying, toggle } = usePlaybackStore(); + + // Action handlers + const handleSplitSelected = () => { + if (selectedElements.length === 0) return; + let splitCount = 0; + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (element && track) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const newElementId = splitElement(trackId, elementId, currentTime); + if (newElementId) splitCount++; + } + } + }); + if (splitCount === 0) { + toast.error("Playhead must be within selected elements to split"); + } + }; + + const handleDuplicateSelected = () => { + if (selectedElements.length === 0) return; + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; + + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((el) => el.id === elementId); + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + const { id, ...elementWithoutId } = element; + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + } + }); + clearSelectedElements(); + }; + + const handleFreezeSelected = () => { + toast.info("Freeze frame functionality coming soon!"); + }; + + const handleSplitAndKeepLeft = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepLeft(trackId, elementId, currentTime); + }; + + const handleSplitAndKeepRight = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepRight(trackId, elementId, currentTime); + }; + + const handleSeparateAudio = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one media element to separate audio"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + if (!track || track.type !== "media") { + toast.error("Select a media element to separate audio"); + return; + } + separateAudio(trackId, elementId); + }; + + const handleDeleteSelected = () => { + if (selectedElements.length === 0) return; + selectedElements.forEach(({ trackId, elementId }) => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(trackId, elementId); + } else { + removeElementFromTrack(trackId, elementId); + } + }); + clearSelectedElements(); + }; + + // Zoom handlers + const handleZoomIn = () => { + setZoomLevel(Math.min(4, zoomLevel + 0.25)); + }; + + const handleZoomOut = () => { + setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); + }; + + const handleZoomSliderChange = (values: number[]) => { + setZoomLevel(values[0]); + }; + return ( + <div className="border-b flex items-center justify-between px-2 py-1"> + <div className="flex items-center gap-1 w-full"> + <TooltipProvider delayDuration={500}> + {/* Play/Pause Button */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={toggle} + className="mr-2" + > + {isPlaying ? ( + <Pause className="h-4 w-4" /> + ) : ( + <Play className="h-4 w-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + {isPlaying ? "Pause (Space)" : "Play (Space)"} + </TooltipContent> + </Tooltip> + <div className="w-px h-6 bg-border mx-1" /> + {/* Time Display */} + <div + className="text-xs text-muted-foreground font-mono px-2" + style={{ minWidth: "18ch", textAlign: "center" }} + > + {currentTime.toFixed(1)}s / {duration.toFixed(1)}s + </div> + {/* Test Clip Button - for debugging */} + {tracks.length === 0 && ( + <> + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => { + const trackId = addTrack("media"); + addElementToTrack(trackId, { + type: "media", + mediaId: "test", + name: "Test Clip", + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + }} + className="text-xs" + > + Add Test Clip + </Button> + </TooltipTrigger> + <TooltipContent>Add a test clip to try playback</TooltipContent> + </Tooltip> + </> + )} + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleSplitSelected}> + <Scissors className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split element (Ctrl+S)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitAndKeepLeft} + > + <ArrowLeftToLine className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitAndKeepRight} + > + <ArrowRightToLine className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleSeparateAudio}> + <SplitSquareHorizontal className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleDuplicateSelected} + > + <Copy className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleFreezeSelected}> + <Snowflake className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Freeze frame (F)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleDeleteSelected}> + <Trash2 className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Delete element (Delete)</TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={toggleSnapping}> + {snappingEnabled ? ( + <Magnet className="h-4 w-4 text-primary" /> + ) : ( + <Magnet className="h-4 w-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent>Auto snapping</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={toggleRippleEditing}> + <Link + className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`} + /> + </Button> + </TooltipTrigger> + <TooltipContent> + {rippleEditingEnabled + ? "Disable Ripple Editing" + : "Enable Ripple Editing"} + </TooltipContent> + </Tooltip> + </TooltipProvider> + <div className="flex items-center gap-1"> + <Button variant="text" size="icon" onClick={handleZoomOut}> + <ZoomOut className="h-4 w-4" /> + </Button> + <Slider + className="w-24" + value={[zoomLevel]} + onValueChange={handleZoomSliderChange} + min={0.25} + max={4} + step={0.25} + /> + <Button variant="text" size="icon" onClick={handleZoomIn}> + <ZoomIn className="h-4 w-4" /> + </Button> + </div> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index eb9f1c01..8aadde82 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Button } from "../ui/button"; +import { Button } from "../../ui/button"; import { MoreVertical, Scissors, @@ -17,7 +17,7 @@ import { import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; -import AudioWaveform from "./audio-waveform"; +import AudioWaveform from "../audio-waveform"; import { toast } from "sonner"; import { TimelineElementProps, TrackType } from "@/types/timeline"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; @@ -35,14 +35,14 @@ import { DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, -} from "../ui/dropdown-menu"; +} from "../../ui/dropdown-menu"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, -} from "../ui/context-menu"; +} from "../../ui/context-menu"; export function TimelineElement({ element, @@ -239,15 +239,15 @@ export function TimelineElement({ if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - VIDEO_TILE_PADDING; + const tileHeight = trackHeight - 8; // Match image padding const tileWidth = tileHeight * TILE_ASPECT_RATIO; return ( - <div className="w-full h-full flex items-center gap-2"> - <div className="flex-1 h-full relative overflow-hidden"> + <div className="w-full h-full flex items-center justify-center"> + <div className="bg-[#004D52] py-3 w-full h-full relative"> {/* Background with tiled thumbnails */} <div - className="absolute inset-0" + className="absolute top-3 bottom-3 left-0 right-0" style={{ backgroundImage: mediaItem.thumbnailUrl ? `url(${mediaItem.thumbnailUrl})` @@ -261,7 +261,7 @@ export function TimelineElement({ /> {/* Overlay with vertical borders */} <div - className="absolute inset-0 pointer-events-none" + className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" style={{ backgroundImage: `repeating-linear-gradient( to right, @@ -274,15 +274,6 @@ export function TimelineElement({ }} /> </div> - {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( - <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate"> - {element.name} - </div> - ) : ( - <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]"> - {element.name} - </span> - )} </div> ); } diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index 7a357067..7a357067 100644 --- a/apps/web/src/components/editor/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 82ca0b98..82ca0b98 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index c609aab2..dba958ac 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -70,7 +70,7 @@ export function Hero() { size="lg" className="px-6 h-11 text-base bg-foreground" > - Try early alpha + Try early beta <ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" /> </Button> </Link> diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/onboarding.tsx index 87cd0115..80b5f99c 100644 --- a/apps/web/src/components/onboarding.tsx +++ b/apps/web/src/components/onboarding.tsx @@ -32,7 +32,7 @@ export function Onboarding() { return (
<div className="space-y-5">
<div className="space-y-3">
- <Title title="Welcome to OpenCut alpha! 🎉" />
+ <Title title="Welcome to OpenCut Beta! 🎉" />
<Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
@@ -42,7 +42,7 @@ export function Onboarding() { return (
<div className="space-y-5">
<div className="space-y-3">
- <Title title="⚠️ This is a super early alpha!" />
+ <Title title="⚠️ This is a super early beta!" />
<Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
<Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
</div>
diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 6b7c3c53..5c9db727 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -7,17 +7,22 @@ import { cn } from "../../lib/utils"; const ScrollArea = React.forwardRef< React.ElementRef<typeof ScrollAreaPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { + type?: "auto" | "always" | "scroll" | "hover"; + showHorizontalScrollbar?: boolean; + } +>(({ className, children, type, showHorizontalScrollbar, ...props }, ref) => ( <ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} + type={type} {...props} > <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> {children} </ScrollAreaPrimitive.Viewport> <ScrollBar /> + {showHorizontalScrollbar && <ScrollBar orientation="horizontal" />} <ScrollAreaPrimitive.Corner /> </ScrollAreaPrimitive.Root> )); diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts index 8c76a2e8..5d446d50 100644 --- a/apps/web/src/constants/timeline-constants.ts +++ b/apps/web/src/constants/timeline-constants.ts @@ -80,11 +80,11 @@ export const TIMELINE_CONSTANTS = { // FPS presets for project settings export const FPS_PRESETS = [ - { value: "24", label: "24 fps (Film)" }, - { value: "25", label: "25 fps (PAL)" }, - { value: "30", label: "30 fps (NTSC)" }, - { value: "60", label: "60 fps (High)" }, - { value: "120", label: "120 fps (Slow-mo)" }, + { value: "24", label: "24 fps" }, + { value: "25", label: "25 fps" }, + { value: "30", label: "30 fps" }, + { value: "60", label: "60 fps" }, + { value: "120", label: "120 fps" }, ] as const; // Frame snapping utilities diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts index ce32c9f0..4f5e0af0 100644 --- a/apps/web/src/hooks/use-timeline-zoom.ts +++ b/apps/web/src/hooks/use-timeline-zoom.ts @@ -24,6 +24,12 @@ export function useTimelineZoom({ const delta = e.deltaY > 0 ? -0.15 : 0.15;
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
}
+ // For horizontal scrolling (when shift is held or horizontal wheel movement),
+ // let the event bubble up to allow ScrollArea to handle it
+ else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
+ // Don't prevent default - let ScrollArea handle horizontal scrolling
+ return;
+ }
// Otherwise, allow normal scrolling
}, []);
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 01ad1635..8a4abc6a 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -161,6 +161,7 @@ interface TimelineStore { // Computed values getTotalDuration: () => number; + getProjectThumbnail: (projectId: string) => Promise<string | null>; // History actions undo: () => void; @@ -549,6 +550,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { : track ) ); + + get().selectElement(trackId, newElement.id); }, removeElementFromTrack: (trackId, elementId) => { @@ -1176,6 +1179,42 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { return Math.max(...trackEndTimes, 0); }, + getProjectThumbnail: async (projectId) => { + try { + const tracks = await storageService.loadTimeline(projectId); + const mediaItems = await storageService.loadAllMediaItems(projectId); + + if (!tracks || !mediaItems.length) return null; + + const firstMediaElement = tracks + .flatMap((track) => track.elements) + .filter((element) => element.type === "media") + .sort((a, b) => a.startTime - b.startTime)[0]; + + if (!firstMediaElement) return null; + + const mediaItem = mediaItems.find( + (item) => item.id === firstMediaElement.mediaId + ); + if (!mediaItem) return null; + + 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 null; + } catch (error) { + console.error("Failed to get project thumbnail:", error); + return null; + } + }, + redo: () => { const { redoStack } = get(); if (redoStack.length === 0) return; @@ -1317,8 +1356,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { findOrCreateTrack: (trackType) => { // Always create new text track to allow multiple text elements + // Insert text tracks at the top if (trackType === "text") { - return get().addTrack(trackType); + return get().insertTrackAt(trackType, 0); } const existingTrack = get()._tracks.find((t) => t.type === trackType); @@ -1356,7 +1396,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { }, addTextAtTime: (item, currentTime = 0) => { - const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements + const targetTrackId = get().insertTrackAt("text", 0); // Always create new text track at the top get().addElementToTrack(targetTrackId, { type: "text", @@ -1399,7 +1439,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { }, addTextToNewTrack: (item) => { - const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements + const targetTrackId = get().insertTrackAt("text", 0); // Always create new text track at the top get().addElementToTrack(targetTrackId, { type: "text", diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index 17dbb5d1..f8a79d03 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -89,13 +89,19 @@ export interface TimelineTrack { export function sortTracksByOrder(tracks: TimelineTrack[]): TimelineTrack[] { return [...tracks].sort((a, b) => { + // Text tracks always go to the top + if (a.type === "text" && b.type !== "text") return -1; + if (b.type === "text" && a.type !== "text") return 1; + // Audio tracks always go to bottom if (a.type === "audio" && b.type !== "audio") return 1; if (b.type === "audio" && a.type !== "audio") return -1; - // Main track goes above audio but below other tracks - if (a.isMain && !b.isMain && b.type !== "audio") return 1; - if (b.isMain && !a.isMain && a.type !== "audio") return -1; + // Main track goes above audio but below text tracks + if (a.isMain && !b.isMain && b.type !== "audio" && b.type !== "text") + return 1; + if (b.isMain && !a.isMain && a.type !== "audio" && a.type !== "text") + return -1; // Within same category, maintain creation order return 0; |
