This commit is contained in:
Maze Winther
2025-12-25 21:15:36 +01:00
parent 2da84e7132
commit d5ca991501
102 changed files with 16028 additions and 6675 deletions
+265 -378
View File
@@ -1,25 +1,44 @@
"use client";
import { useEffect, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { MediaPanel } from "@/components/editor/media-panel";
import { AssetsPanel } from "@/components/editor/assets-panel";
import { PropertiesPanel } from "@/components/editor/properties-panel";
import { Timeline } from "@/components/editor/timeline";
import { PreviewPanel } from "@/components/editor/preview-panel";
import { EditorHeader } from "@/components/editor/editor-header";
import { usePanelStore } from "@/stores/panel-store";
import { useProjectStore } from "@/stores/project-store";
import { EditorProvider } from "@/components/providers/editor-provider";
import { usePlaybackControls } from "@/hooks/use-playback-controls";
import { Onboarding } from "@/components/editor/onboarding";
import { useProjectInitialization } from "@/hooks/use-project-initialization";
export default function Editor() {
const params = useParams();
const projectId = params.project_id as string;
useProjectInitialization({ projectId });
return (
<EditorProvider>
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
<EditorHeader />
<div className="min-h-0 min-w-0 flex-1">
<EditorLayout />
</div>
<Onboarding />
</div>
</EditorProvider>
);
}
function EditorLayout({}: {}) {
const {
activePreset,
resetCounter,
toolsPanel,
previewPanel,
mainContent,
@@ -29,269 +48,54 @@ export default function Editor() {
setMainContent,
setTimeline,
propertiesPanel,
setPropertiesPanel,
activePreset,
resetCounter,
setPropertiesPanel,
} = usePanelStore();
const {
activeProject,
loadProject,
createNewProject,
isInvalidProjectId,
markProjectIdAsInvalid,
} = useProjectStore();
const params = useParams();
const router = useRouter();
const projectId = params.project_id as string;
const handledProjectIds = useRef<Set<string>>(new Set());
const isInitializingRef = useRef<boolean>(false);
return activePreset === "media" ? (
<ResizablePanelGroup
key={`media-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<AssetsPanel />
</ResizablePanel>
usePlaybackControls();
<ResizableHandle withHandle />
useEffect(() => {
let isCancelled = false;
const initProject = async () => {
if (!projectId) {
return;
}
// Prevent duplicate initialization
if (isInitializingRef.current) {
return;
}
// Check if project is already loaded
if (activeProject?.id === projectId) {
return;
}
// Check global invalid tracking first (most important for preventing duplicates)
if (isInvalidProjectId(projectId)) {
return;
}
// Check if we've already handled this project ID locally
if (handledProjectIds.current.has(projectId)) {
return;
}
// Mark as initializing to prevent race conditions
isInitializingRef.current = true;
handledProjectIds.current.add(projectId);
try {
await loadProject(projectId);
// Check if component was unmounted during async operation
if (isCancelled) {
return;
}
// Project loaded successfully
isInitializingRef.current = false;
} catch (error) {
// Check if component was unmounted during async operation
if (isCancelled) {
return;
}
// More specific error handling - only create new project for actual "not found" errors
const isProjectNotFound =
error instanceof Error &&
(error.message.includes("not found") ||
error.message.includes("does not exist") ||
error.message.includes("Project not found"));
if (isProjectNotFound) {
// Mark this project ID as invalid globally BEFORE creating project
markProjectIdAsInvalid(projectId);
try {
const newProjectId = await createNewProject("Untitled Project");
// Check again if component was unmounted
if (isCancelled) {
return;
}
router.replace(`/editor/${newProjectId}`);
} catch (createError) {
console.error("Failed to create new project:", createError);
}
} else {
// For other errors (storage issues, corruption, etc.), don't create new project
console.error(
"Project loading failed with recoverable error:",
error,
);
// Remove from handled set so user can retry
handledProjectIds.current.delete(projectId);
}
isInitializingRef.current = false;
}
};
initProject();
// Cleanup function to cancel async operations
return () => {
isCancelled = true;
isInitializingRef.current = false;
};
}, [
projectId,
loadProject,
createNewProject,
router,
isInvalidProjectId,
markProjectIdAsInvalid,
]);
return (
<EditorProvider>
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
<EditorHeader />
<div className="min-h-0 min-w-0 flex-1">
{activePreset === "media" ? (
<ResizablePanel
defaultSize={100 - toolsPanel}
minSize={60}
className="min-h-0 min-w-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
key={`media-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={100 - toolsPanel}
minSize={60}
className="min-h-0 min-w-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
) : activePreset === "inspector" ? (
<ResizablePanelGroup
key={`inspector-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
defaultSize={100 - propertiesPanel}
defaultSize={previewPanel}
minSize={30}
onResize={(size) => setPropertiesPanel(100 - size)}
className="min-h-0 min-w-0"
onResize={setPreviewPanel}
className="min-h-0 min-w-0 flex-1"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
<PreviewPanel />
</ResizablePanel>
<ResizableHandle withHandle />
@@ -301,74 +105,62 @@ export default function Editor() {
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-h-0 min-w-0"
className="min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
) : activePreset === "vertical-preview" ? (
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
) : activePreset === "inspector" ? (
<ResizablePanelGroup
key={`inspector-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
defaultSize={100 - propertiesPanel}
minSize={30}
onResize={(size) => setPropertiesPanel(100 - size)}
className="min-h-0 min-w-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
key={`vertical-preview-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={100 - previewPanel}
minSize={30}
onResize={(size) => setPreviewPanel(100 - size)}
className="min-h-0 min-w-0"
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
<AssetsPanel />
</ResizablePanel>
<ResizableHandle withHandle />
@@ -377,83 +169,178 @@ export default function Editor() {
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-h-0 min-w-0"
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
</ResizablePanelGroup>
) : (
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-h-0 min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
) : activePreset === "vertical-preview" ? (
<ResizablePanelGroup
key={`vertical-preview-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
defaultSize={100 - previewPanel}
minSize={30}
onResize={(size) => setPreviewPanel(100 - size)}
className="min-h-0 min-w-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
key={`default-${activePreset}-${resetCounter}`}
direction="vertical"
className="h-full w-full gap-[0.18rem]"
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
{/* Main content area */}
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem] px-3"
>
{/* Tools Panel */}
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
{/* Preview Area */}
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0 rounded-sm"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
<AssetsPanel />
</ResizablePanel>
<ResizableHandle withHandle />
{/* Timeline */}
<ResizablePanel
defaultSize={timeline}
defaultSize={propertiesPanel}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0 px-3 pb-3"
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0"
>
<Timeline />
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
<Onboarding />
</div>
</EditorProvider>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-h-0 min-w-0"
>
<PreviewPanel />
</ResizablePanel>
</ResizablePanelGroup>
) : (
<ResizablePanelGroup
key={`default-${activePreset}-${resetCounter}`}
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem] px-3"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<AssetsPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0 rounded-sm"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0 px-3 pb-3"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
);
}
+48 -49
View File
@@ -53,15 +53,16 @@ export default function ProjectsPage() {
Record<string, string | null>
>({});
const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>(
new Set()
new Set(),
);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
new Set()
new Set(),
);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortOption, setSortOption] = useState("createdAt-desc");
const { getProjectThumbnail } = useTimelineStore();
const router = useRouter();
const getProjectThumbnail = useCallback(
@@ -73,9 +74,7 @@ export default function ProjectsPage() {
setLoadingThumbnails((prev) => new Set(prev).add(projectId));
try {
const thumbnail = await useTimelineStore
.getState()
.getProjectThumbnail(projectId);
const thumbnail = await getProjectThumbnail(projectId);
setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail }));
return thumbnail;
} finally {
@@ -86,7 +85,7 @@ export default function ProjectsPage() {
});
}
},
[]
[],
);
const handleCreateProject = async () => {
@@ -120,7 +119,7 @@ export default function ProjectsPage() {
const handleBulkDelete = async () => {
await Promise.all(
Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
Array.from(selectedProjects).map((projectId) => deleteProject(projectId)),
);
setSelectedProjects(new Set());
setIsSelectionMode(false);
@@ -136,11 +135,11 @@ export default function ProjectsPage() {
selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
return (
<div className="min-h-screen bg-background">
<div className="pt-6 px-6 flex items-center justify-between w-full h-16">
<div className="bg-background min-h-screen">
<div className="flex h-16 w-full items-center justify-between px-6 pt-6">
<Link
href="/"
className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
className="hover:text-muted-foreground flex items-center gap-1 transition-colors"
>
<ChevronLeft className="size-5! shrink-0" />
<span className="text-sm font-medium">Back</span>
@@ -172,17 +171,17 @@ export default function ProjectsPage() {
)}
</div>
</div>
<main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
<main className="mx-auto max-w-6xl px-6 pb-6 pt-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">
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
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">
<span className="text-primary ml-2">
{selectedProjects.size} selected
</span>
)}
@@ -221,7 +220,7 @@ export default function ProjectsPage() {
</div>
<div className="mb-4 flex items-center justify-between gap-4">
<div className="flex-1 max-w-72">
<div className="max-w-72 flex-1">
<Input
placeholder="Search projects..."
value={searchQuery}
@@ -237,7 +236,7 @@ export default function ProjectsPage() {
<Button
size="icon"
variant="secondary"
className="justify-center items-center w-9 h-9"
className="h-9 w-9 items-center justify-center"
>
<ArrowDown01
strokeWidth={1.5}
@@ -253,7 +252,7 @@ export default function ProjectsPage() {
setSortOption(
sortOption.endsWith("asc")
? "createdAt-desc"
: "createdAt-asc"
: "createdAt-asc",
);
} else {
setSortOption("createdAt-asc");
@@ -270,7 +269,7 @@ export default function ProjectsPage() {
setSortOption(
sortOption.endsWith("asc")
? "name-desc"
: "name-asc"
: "name-asc",
);
} else {
setSortOption("name-asc");
@@ -305,32 +304,32 @@ export default function ProjectsPage() {
handleSelectAll(!allSelected);
}
}}
className="w-full hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex"
className="bg-muted/30 mb-6 flex w-full items-center gap-2 rounded-lg border p-4 hover:cursor-pointer"
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">
<span className="text-muted-foreground text-sm">
({selectedProjects.size} of {sortedProjects.length} selected)
</span>
</button>
)}
{isLoading || !isInitialized ? (
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }, (_, index) => (
<div
key={`skeleton-${index}-${Date.now()}`}
className="overflow-hidden bg-background border-none p-0"
className="bg-background overflow-hidden 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" />
<Skeleton className="bg-muted/50 aspect-square w-full" />
<div className="flex flex-col gap-1 px-0 pt-5">
<Skeleton className="bg-muted/50 h-4 w-3/4" />
<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" />
<Skeleton className="bg-muted/50 h-4 w-4" />
<Skeleton className="bg-muted/50 h-4 w-24" />
</div>
</div>
</div>
@@ -344,7 +343,7 @@ export default function ProjectsPage() {
onClearSearch={() => setSearchQuery("")}
/>
) : (
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{sortedProjects.map((project) => (
<ProjectCard
key={project.id}
@@ -442,25 +441,25 @@ function ProjectCard({
const cardContent = (
<Card
className={`overflow-hidden bg-background border-none p-0 transition-all ${
isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
className={`bg-background overflow-hidden border-none p-0 transition-all ${
isSelectionMode && isSelected ? "ring-primary ring-2" : ""
}`}
>
<div
className={`relative aspect-square bg-muted transition-opacity ${
className={`bg-muted relative aspect-square 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-full bg-background/80 backdrop-blur-xs border flex items-center justify-center">
<div className="absolute left-3 top-3 z-10">
<div className="bg-background/80 backdrop-blur-xs flex h-5 w-5 items-center justify-center rounded-full border">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
onSelect?.(project.id, checked as boolean)
}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4"
className="h-4 w-4"
/>
</div>
</div>
@@ -468,8 +467,8 @@ function ProjectCard({
<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 className="bg-muted/50 flex h-full w-full items-center justify-center">
<Loader2 className="text-muted-foreground h-12 w-12 animate-spin" />
</div>
) : dynamicThumbnail ? (
<Image
@@ -479,16 +478,16 @@ function ProjectCard({
className="object-cover"
/>
) : (
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
<Video className="h-12 w-12 shrink-0 text-muted-foreground" />
<div className="bg-muted/50 flex h-full w-full items-center justify-center">
<Video className="text-muted-foreground h-12 w-12 shrink-0" />
</div>
)}
</div>
</div>
<CardContent className="px-0 pt-5 flex flex-col gap-1">
<CardContent className="flex flex-col gap-1 px-0 pt-5">
<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">
<h3 className="group-hover:text-foreground/90 line-clamp-2 text-sm font-medium leading-snug transition-colors">
{project.name}
</h3>
{!isSelectionMode && (
@@ -500,7 +499,7 @@ function ProjectCard({
<Button
variant="text"
size="sm"
className={`size-6 p-0 transition-all shrink-0 ml-2 ${
className={`ml-2 size-6 shrink-0 p-0 transition-all ${
isDropdownOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
@@ -554,7 +553,7 @@ function ProjectCard({
</div>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<div className="text-muted-foreground flex items-center gap-1.5 text-sm">
<Calendar className="size-4!" />
<span>Created {formatDate(project.createdAt)}</span>
</div>
@@ -570,12 +569,12 @@ function ProjectCard({
type="button"
onClick={handleCardClick}
onKeyDown={handleCardKeyDown}
className="block group cursor-pointer w-full text-left"
className="group block w-full cursor-pointer text-left"
>
{cardContent}
</button>
) : (
<Link href={`/editor/${project.id}`} className="block group">
<Link href={`/editor/${project.id}`} className="group block">
{cardContent}
</Link>
)}
@@ -606,10 +605,10 @@ function CreateButton({ onClick }: { onClick?: () => void }) {
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 className="bg-muted/30 mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<Video className="text-muted-foreground h-8 w-8" />
</div>
<h3 className="text-lg font-medium mb-2">No projects yet</h3>
<h3 className="mb-2 text-lg font-medium">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.
@@ -631,10 +630,10 @@ function NoResults({
}) {
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 className="bg-muted/30 mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<Search className="text-muted-foreground h-8 w-8" />
</div>
<h3 className="text-lg font-medium mb-2">No results found</h3>
<h3 className="mb-2 text-lg font-medium">No results found</h3>
<p className="text-muted-foreground mb-6 max-w-md">
Your search for "{searchQuery}" did not return any results.
</p>
@@ -2,7 +2,7 @@
import { TabBar } from "./tabbar";
import { MediaView } from "./views/media";
import { useMediaPanelStore, Tab } from "./store";
import { useAssetsPanelStore, Tab } from "../../../stores/assets-panel-store";
import { TextView } from "./views/text";
import { SoundsView } from "./views/sounds";
import { StickersView } from "./views/stickers";
@@ -10,8 +10,8 @@ import { Separator } from "@/components/ui/separator";
import { SettingsView } from "./views/settings";
import { Captions } from "./views/captions";
export function MediaPanel() {
const { activeTab } = useMediaPanelStore();
export function AssetsPanel() {
const { activeTab } = useAssetsPanelStore();
const viewMap: Record<Tab, React.ReactNode> = {
media: <MediaView />,
@@ -19,23 +19,23 @@ export function MediaPanel() {
text: <TextView />,
stickers: <StickersView />,
effects: (
<div className="p-4 text-muted-foreground">
<div className="text-muted-foreground p-4">
Effects view coming soon...
</div>
),
transitions: (
<div className="p-4 text-muted-foreground">
<div className="text-muted-foreground p-4">
Transitions view coming soon...
</div>
),
captions: <Captions />,
filters: (
<div className="p-4 text-muted-foreground">
<div className="text-muted-foreground p-4">
Filters view coming soon...
</div>
),
adjustment: (
<div className="p-4 text-muted-foreground">
<div className="text-muted-foreground p-4">
Adjustment view coming soon...
</div>
),
@@ -43,7 +43,7 @@ export function MediaPanel() {
};
return (
<div className="h-full flex bg-panel">
<div className="bg-panel flex h-full">
<TabBar />
<Separator orientation="vertical" />
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
@@ -1,7 +1,11 @@
"use client";
import { cn } from "@/lib/utils";
import { Tab, tabs, useMediaPanelStore } from "./store";
import {
Tab,
tabs,
useAssetsPanelStore,
} from "../../../stores/assets-panel-store";
import {
Tooltip,
TooltipContent,
@@ -10,7 +14,7 @@ import {
import { useEffect, useRef, useState } from "react";
export function TabBar() {
const { activeTab, setActiveTab } = useMediaPanelStore();
const { activeTab, setActiveTab } = useAssetsPanelStore();
const scrollRef = useRef<HTMLDivElement>(null);
const [showTopFade, setShowTopFade] = useState(false);
const [showBottomFade, setShowBottomFade] = useState(false);
@@ -30,7 +34,7 @@ export function TabBar() {
checkScrollPosition();
element.addEventListener("scroll", checkScrollPosition);
const resizeObserver = new ResizeObserver(checkScrollPosition);
resizeObserver.observe(element);
@@ -41,20 +45,20 @@ export function TabBar() {
}, []);
return (
<div className="flex relative">
<div
<div className="relative flex">
<div
ref={scrollRef}
className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-y-auto scrollbar-hidden relative w-full py-4"
className="scrollbar-hidden relative flex h-full w-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4"
>
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
return (
<div
className={cn(
"flex z-[100] flex-col gap-0.5 items-center cursor-pointer",
"z-[100] flex cursor-pointer flex-col items-center gap-0.5",
activeTab === tabKey
? "text-primary !opacity-100"
: "text-muted-foreground"
: "text-muted-foreground",
)}
onClick={() => setActiveTab(tabKey)}
key={tabKey}
@@ -69,7 +73,7 @@ export function TabBar() {
variant="sidebar"
sideOffset={8}
>
<div className="dark:text-base-gray-950 text-black text-sm font-medium leading-none dark:text-white">
<div className="dark:text-base-gray-950 text-sm font-medium leading-none text-black dark:text-white">
{tab.label}
</div>
</TooltipContent>
@@ -78,22 +82,28 @@ export function TabBar() {
);
})}
</div>
<FadeOverlay direction="top" show={showTopFade} />
<FadeOverlay direction="bottom" show={showBottomFade} />
</div>
);
}
function FadeOverlay({ direction, show }: { direction: "top" | "bottom", show: boolean }) {
function FadeOverlay({
direction,
show,
}: {
direction: "top" | "bottom";
show: boolean;
}) {
return (
<div
<div
className={cn(
"absolute left-0 right-0 h-6 pointer-events-none z-[101] transition-opacity duration-200",
"pointer-events-none absolute left-0 right-0 z-[101] h-6 transition-opacity duration-200",
direction === "top" && show
? "top-0 bg-gradient-to-b from-panel to-transparent"
: "bottom-0 bg-gradient-to-t from-panel to-transparent"
? "from-panel top-0 bg-gradient-to-b to-transparent"
: "from-panel bottom-0 bg-gradient-to-t to-transparent",
)}
/>
);
}
}
@@ -1,6 +1,6 @@
"use client";
import { useDragDrop } from "@/hooks/use-drag-drop";
import { useFileUpload } from "@opencut/hooks/use-file-upload";
import { processMediaFiles } from "@/lib/media-processing-utils";
import { useMediaStore } from "@/stores/media-store";
import { MediaFile } from "@/types/media";
@@ -14,11 +14,11 @@ import {
Music,
Video,
} from "lucide-react";
import { useRef, useState, useMemo } from "react";
import { useHighlightScroll } from "@/hooks/use-highlight-scroll";
import { useState, useMemo } from "react";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
import { MediaDragOverlay } from "@/components/editor/assets-panel/drag-overlay";
import {
ContextMenu,
ContextMenuContent,
@@ -41,7 +41,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { usePanelStore } from "@/stores/panel-store";
import { useMediaPanelStore } from "../store";
import { useAssetsPanelStore } from "../../../../stores/assets-panel-store";
function MediaItemWithContextMenu({
item,
@@ -72,15 +72,14 @@ export function MediaView() {
const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { mediaViewMode, setMediaViewMode } = usePanelStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
"name",
);
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const { highlightMediaId, clearHighlight } = useMediaPanelStore();
const { highlightedId, registerElement } = useHighlightScroll(
const { highlightMediaId, clearHighlight } = useAssetsPanelStore();
const { highlightedId, registerElement } = useRevealItem(
highlightMediaId,
clearHighlight,
);
@@ -95,9 +94,10 @@ export function MediaView() {
setIsProcessing(true);
setProgress(0);
try {
const processedItems = await processMediaFiles(files, (p) =>
setProgress(p),
);
const processedItems = await processMediaFiles({
files: files as FileList,
onProgress: (p: { progress: number }) => setProgress(p.progress),
});
for (const item of processedItems) {
await addMediaFile(activeProject.id, item);
}
@@ -110,17 +110,12 @@ export function MediaView() {
}
};
const { isDragOver, dragProps } = useDragDrop({
// When files are dropped, process them
onDrop: processFiles,
});
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) processFiles(e.target.files);
e.target.value = ""; // Reset input
};
const { isDragOver, dragProps, openFilePicker, fileInputProps } =
useFileUpload({
accept: "image/*,video/*,audio/*",
multiple: true,
onFilesSelected: processFiles,
});
const handleRemove = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
@@ -260,27 +255,19 @@ export function MediaView() {
return (
<>
{/* Hidden file input for uploading media */}
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,audio/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
{/* native file picker, visually hidden */}
<input {...fileInputProps} />
<div
className={`relative flex h-full flex-col gap-1 transition-colors ${isDragOver ? "bg-accent/30" : ""}`}
{...dragProps}
>
<div className="bg-panel p-3 pb-2">
{/* Search and filter controls */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="lg"
onClick={handleFileSelect}
onClick={openFilePicker}
disabled={isProcessing}
className="!bg-background h-9 flex-1 items-center justify-center px-4 opacity-100 transition-opacity hover:opacity-75"
>
@@ -427,7 +414,7 @@ export function MediaView() {
isVisible={true}
isProcessing={isProcessing}
progress={progress}
onClick={handleFileSelect}
onClick={openFilePicker}
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
/>
) : mediaViewMode === "grid" ? (
@@ -2,10 +2,6 @@
import { useEffect, useState, useMemo } from "react";
import { useStickersStore } from "@/stores/stickers-store";
import { useMediaStore } from "@/stores/media-store";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import {
Loader2,
Grid3X3,
@@ -35,9 +31,7 @@ import {
POPULAR_COLLECTIONS,
} from "@/lib/iconify-api";
import { cn } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import Image from "next/image";
import type { MediaFile } from "@/types/media";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { InputWithBack } from "@/components/ui/input-with-back";
import { StickerCategory } from "@/stores/stickers-store";
@@ -1,6 +1,6 @@
"use client";
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import type { TimelineTrack } from "@/types/timeline";
import { useState, useEffect } from "react";
@@ -63,7 +63,7 @@ export function SnapIndicator({
return (
<div
className="absolute pointer-events-none z-90"
className="z-90 pointer-events-none absolute"
style={{
left: `${leftPosition}px`,
top: 0,
@@ -71,7 +71,7 @@ export function SnapIndicator({
width: "2px",
}}
>
<div className={"w-0.5 h-full bg-primary/40 opacity-80"} />
<div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
</div>
);
}
@@ -10,7 +10,7 @@ import {
} from "../../ui/context-menu";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
import { useState, useRef, useEffect, useCallback } from "react";
import { TimelineTrackContent } from "./timeline-track";
import {
@@ -20,7 +20,7 @@ import {
import { SelectionBox } from "../selection-box";
import { useSelectionBox } from "@/hooks/use-selection-box";
import { SnapIndicator } from "../snap-indicator";
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
import type { TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
@@ -30,8 +30,8 @@ import {
} from "@/lib/timeline";
import { TimelineToolbar } from "./timeline-toolbar";
import { useScrollSync } from "@/hooks/use-scroll-sync";
import { useTimelineInteractions } from "@/hooks/use-timeline-interactions";
import { useTimelineDragDrop } from "@/hooks/use-timeline-drag-drop";
import { useTimelineInteractions } from "@/hooks/timeline/use-timeline-interactions";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
import { TimelineRuler } from "./timeline-ruler";
export function Timeline() {
@@ -45,10 +45,7 @@ export function Timeline() {
dragState,
} = useTimelineStore();
const { currentTime, duration, seek, setDuration } = usePlaybackStore();
const { addElementToNewTrack } = useTimelineStore();
const { dragProps } = useTimelineDragDrop({
addElementToNewTrack,
});
const timelineRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
const [isInTimeline, setIsInTimeline] = useState(false);
@@ -59,6 +56,10 @@ export function Timeline() {
isInTimeline,
});
const { dragProps } = useTimelineDragDrop({
zoomLevel,
});
// Dynamic timeline width calculation based on playhead position and duration
const dynamicTimelineWidth = Math.max(
(duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
@@ -10,13 +10,14 @@ import {
Eye,
Volume2,
VolumeX,
ArrowUpDown,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "../audio-waveform";
import { TimelineElementProps } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import { useTimelineElementResize } from "@/hooks/timeline/use-timeline-element-resize";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { getTrackElementClasses, getTrackHeight } from "@/lib/timeline";
import {
@@ -26,6 +27,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "../../ui/context-menu";
import { useAssetsPanelStore } from "../../../stores/assets-panel-store";
export function TimelineElement({
element,
@@ -36,6 +38,7 @@ export function TimelineElement({
onElementClick,
}: TimelineElementProps) {
const { mediaFiles } = useMediaStore();
const { requestRevealMedia } = useAssetsPanelStore();
const {
dragState,
copySelected,
@@ -45,8 +48,6 @@ export function TimelineElement({
toggleSelectedHidden,
toggleSelectedMuted,
duplicateElement,
revealElementInMedia,
replaceElementWithFile,
getContextMenuState,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@@ -129,24 +130,11 @@ export function TimelineElement({
}
};
const handleReplaceClip = (e: React.MouseEvent) => {
e.stopPropagation();
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*,audio/*,image/*";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
await replaceElementWithFile(track.id, element.id, file);
}
};
input.click();
};
const handleRevealInMedia = (e: React.MouseEvent) => {
e.stopPropagation();
revealElementInMedia(element.id);
if (element.type === "media") {
requestRevealMedia(element.mediaId);
}
};
const renderElementContent = () => {
@@ -354,15 +342,20 @@ export function TimelineElement({
</ContextMenuItem>
)}
<ContextMenuItem disabled>
<ArrowUpDown className="mr-2 h-4 w-4" />
Move to track (Coming soon)
</ContextMenuItem>
{!isMultipleSelected && element.type === "media" && (
<>
<ContextMenuItem onClick={handleRevealInMedia}>
<Search className="mr-2 h-4 w-4" />
Reveal in media
</ContextMenuItem>
<ContextMenuItem onClick={handleReplaceClip}>
<ContextMenuItem disabled>
<RefreshCw className="mr-2 h-4 w-4" />
Replace clip
Replace clip (Coming soon)
</ContextMenuItem>
</>
)}
@@ -3,7 +3,7 @@
import { useRef, useState, useEffect } from "react";
import { TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
interface TimelinePlayheadProps {
currentTime: number;
@@ -91,12 +91,12 @@ export function TimelinePlayhead({
const leftBoundary = trackLabelsWidth;
const rightBoundary = Math.min(
trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content
trackLabelsWidth + viewportWidth // Don't go beyond viewport
trackLabelsWidth + viewportWidth, // Don't go beyond viewport
);
const leftPosition = Math.max(
leftBoundary,
Math.min(rightBoundary, rawLeftPosition)
Math.min(rightBoundary, rawLeftPosition),
);
// Debug logging when playhead might go outside
@@ -115,14 +115,14 @@ export function TimelinePlayhead({
timelineContentWidth,
viewportWidth,
zoomLevel,
})
}),
);
}
return (
<div
ref={playheadRef}
className="absolute pointer-events-auto z-40"
className="pointer-events-auto absolute z-40"
style={{
left: `${leftPosition}px`,
top: 0,
@@ -133,12 +133,12 @@ export function TimelinePlayhead({
>
{/* The playhead line spanning full height */}
<div
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
className={`absolute left-0 h-full w-0.5 cursor-col-resize ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
/>
{/* Playhead dot indicator at the top (in ruler area) */}
<div
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
className={`shadow-xs absolute left-1/2 top-1 h-3 w-3 -translate-x-1/2 transform rounded-full border-2 ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
/>
</div>
);
@@ -58,7 +58,6 @@ export function TimelineToolbar({
splitSelected,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
snappingEnabled,
toggleSnapping,
rippleEditingEnabled,
@@ -140,20 +139,6 @@ export function TimelineToolbar({
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 handleZoom = ({ direction }: { direction: "in" | "out" }) => {
const newZoomLevel =
direction === "in"
@@ -211,7 +196,11 @@ export function TimelineToolbar({
/
</div>
<div className="text-muted-foreground text-center font-mono text-xs">
{formatTimeCode(duration, "HH:MM:SS:FF")}
{formatTimeCode({ timeInSeconds: duration })}
{formatTimeCode({
timeInSeconds: duration,
format: "HH:MM:SS:FF",
})}
</div>
</div>
{tracks.length === 0 && (
@@ -278,11 +267,11 @@ export function TimelineToolbar({
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSeparateAudio}>
<Button variant="text" size="icon" disabled>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
<TooltipContent>Separate audio (Coming soon)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
@@ -2,25 +2,18 @@
import { useRef, useState, useEffect } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { toast } from "sonner";
import { processMediaFiles } from "@/lib/media-processing-utils";
import { TimelineElement } from "./timeline-element";
import { TimelineTrack } from "@/types/timeline";
import { getMainTrack, canElementGoOnTrack } from "@/lib/timeline/track-utils";
import { usePlaybackStore } from "@/stores/playback-store";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import type {
TimelineElement as TimelineElementType,
DragData,
TrackType,
} from "@/types/timeline";
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
import { useProjectStore } from "@/stores/project-store";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
import { useProjectStore } from "@/stores/project-store";
import { snapTimeToFrame } from "@/lib/time-utils";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
export function TimelineTrackContent({
track,
@@ -35,14 +28,10 @@ export function TimelineTrackContent({
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
}) {
const { mediaFiles } = useMediaStore();
const {
tracks,
addTrack,
moveElementToTrack,
updateElementStartTime,
updateElementStartTimeWithRipple,
addElementToTrack,
selectedElements,
selectElement,
dragState,
@@ -50,78 +39,18 @@ export function TimelineTrackContent({
updateDragTime,
endDrag: endDragAction,
clearSelectedElements,
insertTrackAt,
snappingEnabled,
rippleEditingEnabled,
} = useTimelineStore();
const { currentTime, duration } = usePlaybackStore();
const { duration } = usePlaybackStore();
// Initialize snapping hook
const { snapElementEdge } = useTimelineSnapping({
snapThreshold: 10,
enableElementSnapping: snappingEnabled,
enablePlayheadSnapping: snappingEnabled,
const { isDragOver, wouldOverlap, dragProps } = useTimelineDragDrop({
track,
zoomLevel,
onSnapPointChange,
});
// Helper function for drop snapping that tries both edges
const getDropSnappedTime = (
dropTime: number,
elementDuration: number,
excludeElementId?: string,
) => {
// Always apply frame snapping first
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
let finalTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
// Additionally apply element snapping if enabled
if (snappingEnabled) {
// Try snapping both start and end edges for drops
const startSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
true, // snap to start edge
);
const endSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
false, // snap to end edge
);
// Choose the snap result with the smaller distance (closer snap)
let bestSnapResult = startSnapResult;
if (
endSnapResult.snapPoint &&
(!startSnapResult.snapPoint ||
endSnapResult.snapDistance < startSnapResult.snapDistance)
) {
bestSnapResult = endSnapResult;
}
// Only use element snapping if it found a snap point, otherwise keep frame-snapped time
if (bestSnapResult.snapPoint) {
finalTime = bestSnapResult.snappedTime;
}
}
return finalTime;
};
const timelineRef = useRef<HTMLDivElement>(null);
const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null);
const [wouldOverlap, setWouldOverlap] = useState(false);
const dragCounterRef = useRef(0);
const [mouseDownLocation, setMouseDownLocation] = useState<{
x: number;
y: number;
@@ -159,70 +88,12 @@ export function TimelineTrackContent({
);
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
// Always apply frame snapping first
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
let finalTime = snapTimeToFrame({ time: adjustedTime, fps: projectFps });
let snapPoint = null;
// Additionally apply element snapping if enabled
if (snappingEnabled) {
// Find the element being dragged to get its duration
let elementDuration = 5; // fallback duration
if (dragState.elementId && dragState.trackId) {
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const element = sourceTrack?.elements.find(
(e) => e.id === dragState.elementId,
);
if (element) {
elementDuration =
element.duration - element.trimStart - element.trimEnd;
}
}
// Try snapping both start and end edges
const startSnapResult = snapElementEdge(
adjustedTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
dragState.elementId || undefined,
true, // snap to start edge
);
const endSnapResult = snapElementEdge(
adjustedTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
dragState.elementId || undefined,
false, // snap to end edge
);
// Choose the snap result with the smaller distance (closer snap)
let bestSnapResult = startSnapResult;
if (
endSnapResult.snapPoint &&
(!startSnapResult.snapPoint ||
endSnapResult.snapDistance < startSnapResult.snapDistance)
) {
bestSnapResult = endSnapResult;
}
// Only use element snapping if it found a snap point, otherwise keep frame-snapped time
if (bestSnapResult.snapPoint) {
finalTime = bestSnapResult.snappedTime;
snapPoint = bestSnapResult.snapPoint;
}
// Notify parent component about snap point change
onSnapPointChange?.(snapPoint);
} else {
// Clear snap point when element snapping is disabled
onSnapPointChange?.(null);
}
const finalTime = snapTimeToFrame({
time: adjustedTime,
fps: projectFps,
});
updateDragTime(finalTime);
};
@@ -309,26 +180,7 @@ export function TimelineTrackContent({
);
}
} else {
moveElementToTrack(
dragState.trackId,
track.id,
dragState.elementId,
);
requestAnimationFrame(() => {
if (rippleEditingEnabled) {
updateElementStartTimeWithRipple(
track.id,
dragState.elementId!,
finalTime,
);
} else {
updateElementStartTime(
track.id,
dragState.elementId!,
finalTime,
);
}
});
toast.info("Moving elements between tracks is coming soon!");
}
}
}
@@ -399,7 +251,6 @@ export function TimelineTrackContent({
track.id,
updateDragTime,
updateElementStartTime,
moveElementToTrack,
endDragAction,
selectedElements,
selectElement,
@@ -495,620 +346,6 @@ export function TimelineTrackContent({
// If element is already selected, keep it selected (do nothing)
};
const handleTrackDragOver = (e: React.DragEvent) => {
e.preventDefault();
// Handle both timeline elements and media items
const hasTimelineElement = e.dataTransfer.types.includes(
"application/x-timeline-element",
);
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
if (!hasTimelineElement && !hasMediaItem) return;
// Calculate drop position for overlap checking
const trackContainer = e.currentTarget.querySelector(
".track-elements-container",
) as HTMLElement;
let dropTime = 0;
if (trackContainer) {
const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left);
dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
}
// Check for potential overlaps and show appropriate feedback
let wouldOverlap = false;
if (hasMediaItem) {
try {
const mediaItemData = e.dataTransfer.getData(
"application/x-media-item",
);
if (mediaItemData) {
const dragData: DragData = JSON.parse(mediaItemData);
if (dragData.type === "text") {
// Text elements have default duration of 5 seconds
const newElementDuration = 5;
const snappedTime = getDropSnappedTime(
dropTime,
newElementDuration,
);
const newElementEnd = snappedTime + newElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return snappedTime < existingEnd && newElementEnd > existingStart;
});
} else {
// Media elements
const mediaItem = mediaFiles.find(
(item) => item.id === dragData.id,
);
if (mediaItem) {
const newElementDuration = mediaItem.duration || 5;
const snappedTime = getDropSnappedTime(
dropTime,
newElementDuration,
);
const newElementEnd = snappedTime + newElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return (
snappedTime < existingEnd && newElementEnd > existingStart
);
});
}
}
}
} catch (error) {
// Continue with default behavior
}
} else if (hasTimelineElement) {
try {
const timelineElementData = e.dataTransfer.getData(
"application/x-timeline-element",
);
if (timelineElementData) {
const { elementId, trackId: fromTrackId } =
JSON.parse(timelineElementData);
const sourceTrack = tracks.find(
(t: TimelineTrack) => t.id === fromTrackId,
);
const movingElement = sourceTrack?.elements.find(
(c: any) => c.id === elementId,
);
if (movingElement) {
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const snappedTime = getDropSnappedTime(
dropTime,
movingElementDuration,
elementId,
);
const movingElementEnd = snappedTime + movingElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
if (fromTrackId === track.id && existingElement.id === elementId)
return false;
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return (
snappedTime < existingEnd && movingElementEnd > existingStart
);
});
}
}
} catch (error) {
// Continue with default behavior
}
}
if (wouldOverlap) {
e.dataTransfer.dropEffect = "none";
setWouldOverlap(true);
// Use default duration for position indicator
setDropPosition(getDropSnappedTime(dropTime, 5));
return;
}
e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
setWouldOverlap(false);
// Use default duration for position indicator
setDropPosition(getDropSnappedTime(dropTime, 5));
};
const handleTrackDragEnter = (e: React.DragEvent) => {
e.preventDefault();
const hasTimelineElement = e.dataTransfer.types.includes(
"application/x-timeline-element",
);
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
if (!hasTimelineElement && !hasMediaItem) return;
dragCounterRef.current++;
setIsDropping(true);
};
const handleTrackDragLeave = (e: React.DragEvent) => {
e.preventDefault();
const hasTimelineElement = e.dataTransfer.types.includes(
"application/x-timeline-element",
);
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
if (!hasTimelineElement && !hasMediaItem) return;
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDropping(false);
setWouldOverlap(false);
setDropPosition(null);
}
};
const handleTrackDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Debug logging
console.log(
JSON.stringify({
message: "Drop event started in timeline track",
dataTransferTypes: Array.from(e.dataTransfer.types),
trackId: track.id,
trackType: track.type,
}),
);
// Reset all drag states
dragCounterRef.current = 0;
setIsDropping(false);
setWouldOverlap(false);
const hasTimelineElement = e.dataTransfer.types.includes(
"application/x-timeline-element",
);
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
const hasFiles = e.dataTransfer.files?.length > 0;
if (!hasTimelineElement && !hasMediaItem && !hasFiles) return;
const trackContainer = e.currentTarget.querySelector(
".track-elements-container",
) as HTMLElement;
if (!trackContainer) return;
const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left);
const mouseY = e.clientY - rect.top; // Get Y position relative to this track
const newStartTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
const snappedTime = snapTimeToFrame({
time: newStartTime,
fps: projectFps,
});
// Calculate drop position relative to tracks
const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
// Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
let dropPosition: "above" | "on" | "below";
if (mouseY < 20) {
dropPosition = "above";
} else if (mouseY > 40) {
dropPosition = "below";
} else {
dropPosition = "on";
}
try {
if (hasTimelineElement) {
// Handle timeline element movement
const timelineElementData = e.dataTransfer.getData(
"application/x-timeline-element",
);
if (!timelineElementData) return;
const {
elementId,
trackId: fromTrackId,
clickOffsetTime = 0,
} = JSON.parse(timelineElementData);
// Find the element being moved
const sourceTrack = tracks.find(
(t: TimelineTrack) => t.id === fromTrackId,
);
const movingElement = sourceTrack?.elements.find(
(c: TimelineElementType) => c.id === elementId,
);
if (!movingElement) {
toast.error("Element not found");
return;
}
// Check for overlaps with existing elements (excluding the moving element itself)
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
// Adjust position based on where user clicked on the element
const adjustedStartTime = newStartTime - clickOffsetTime;
const snappedStartTime = getDropSnappedTime(
adjustedStartTime,
movingElementDuration,
elementId,
);
const finalStartTime = Math.max(0, snappedStartTime);
const movingElementEnd = finalStartTime + movingElementDuration;
const hasOverlap = track.elements.some((existingElement) => {
// Skip the element being moved if it's on the same track
if (fromTrackId === track.id && existingElement.id === elementId)
return false;
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
// Check if elements overlap
return (
finalStartTime < existingEnd && movingElementEnd > existingStart
);
});
if (hasOverlap) {
toast.error(
"Cannot move element here - it would overlap with existing elements",
);
return;
}
if (fromTrackId === track.id) {
// Moving within same track
if (rippleEditingEnabled) {
updateElementStartTimeWithRipple(
track.id,
elementId,
finalStartTime,
);
} else {
updateElementStartTime(track.id, elementId, finalStartTime);
}
} else {
// Moving to different track
moveElementToTrack(fromTrackId, track.id, elementId);
requestAnimationFrame(() => {
if (rippleEditingEnabled) {
updateElementStartTimeWithRipple(
track.id,
elementId,
finalStartTime,
);
} else {
updateElementStartTime(track.id, elementId, finalStartTime);
}
});
}
} else if (hasMediaItem) {
// Handle media item drop
const mediaItemData = e.dataTransfer.getData(
"application/x-media-item",
);
if (!mediaItemData) return;
const dragData: DragData = JSON.parse(mediaItemData);
if (dragData.type === "text") {
let targetTrackId = track.id;
let targetTrack = track;
// Handle position-aware track creation for text
if (track.type !== "text" || dropPosition !== "on") {
// Text tracks should go above the main track
const mainTrack = getMainTrack({ tracks });
let insertIndex: number;
if (dropPosition === "above") {
insertIndex = currentTrackIndex;
} else if (dropPosition === "below") {
insertIndex = currentTrackIndex + 1;
} else {
// dropPosition === "on" but track is not text type
// Insert above main track if main track exists, otherwise at top
if (mainTrack) {
const mainTrackIndex = tracks.findIndex(
(t) => t.id === mainTrack.id,
);
insertIndex = mainTrackIndex;
} else {
insertIndex = 0; // Top of timeline
}
}
targetTrackId = insertTrackAt("text", insertIndex);
// Get the updated tracks array after creating the new track
const updatedTracks = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracks.find(
(t) => t.id === targetTrackId,
);
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
// Check for overlaps with existing elements in target track
const newElementDuration = 5; // Default text duration
const textSnappedTime = getDropSnappedTime(
newStartTime,
newElementDuration,
);
const newElementEnd = textSnappedTime + newElementDuration;
const hasOverlap = targetTrack.elements.some((existingElement) => {
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
// Check if elements overlap
return (
textSnappedTime < existingEnd && newElementEnd > existingStart
);
});
if (hasOverlap) {
toast.error(
"Cannot place element here - it would overlap with existing elements",
);
return;
}
addElementToTrack(targetTrackId, {
...DEFAULT_TEXT_ELEMENT,
name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
startTime: textSnappedTime,
});
} else {
// Handle media items
const mediaItem = mediaFiles.find((item) => item.id === dragData.id);
if (!mediaItem) {
toast.error("Media item not found");
return;
}
let targetTrackId = track.id;
// Check if track type is compatible
const isVideoOrImage =
dragData.type === "video" || dragData.type === "image";
const isAudio = dragData.type === "audio";
const isCompatible = isVideoOrImage
? canElementGoOnTrack({
elementType: "media",
trackType: track.type,
})
: isAudio
? canElementGoOnTrack({
elementType: "media",
trackType: track.type,
})
: false;
let targetTrack = tracks.find((t) => t.id === targetTrackId);
// Handle position-aware track creation for media elements
if (!isCompatible || dropPosition !== "on") {
if (isVideoOrImage) {
// For video/image, check if we need a main track or additional media track
const mainTrack = getMainTrack({ tracks });
if (!mainTrack) {
// No main track exists, create it
targetTrackId = addTrack("media");
const updatedTracks = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracks.find(
(t) => t.id === targetTrackId,
);
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
} else if (
mainTrack.elements.length === 0 &&
dropPosition === "on"
) {
// Main track exists and is empty, use it
targetTrackId = mainTrack.id;
targetTrack = mainTrack;
} else {
// Create new media track
let insertIndex: number;
if (dropPosition === "above") {
insertIndex = currentTrackIndex;
} else if (dropPosition === "below") {
insertIndex = currentTrackIndex + 1;
} else {
// Insert above main track
const mainTrackIndex = tracks.findIndex(
(t) => t.id === mainTrack.id,
);
insertIndex = mainTrackIndex;
}
targetTrackId = insertTrackAt("media", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracks.find(
(t) => t.id === targetTrackId,
);
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
} else if (isAudio) {
// Audio tracks go at the bottom
const mainTrack = getMainTrack({ tracks });
let insertIndex: number;
if (dropPosition === "above") {
insertIndex = currentTrackIndex;
} else if (dropPosition === "below") {
insertIndex = currentTrackIndex + 1;
} else {
// Insert after main track (bottom area)
if (mainTrack) {
const mainTrackIndex = tracks.findIndex(
(t) => t.id === mainTrack.id,
);
insertIndex = mainTrackIndex + 1;
} else {
insertIndex = tracks.length; // Bottom of timeline
}
}
targetTrackId = insertTrackAt("audio", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracks.find(
(t) => t.id === targetTrackId,
);
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
}
if (!targetTrack) return;
// Check for overlaps with existing elements in target track
const newElementDuration = mediaItem.duration || 5;
const mediaSnappedTime = getDropSnappedTime(
newStartTime,
newElementDuration,
);
const newElementEnd = mediaSnappedTime + newElementDuration;
const hasOverlap = targetTrack.elements.some((existingElement) => {
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
// Check if elements overlap
return (
mediaSnappedTime < existingEnd && newElementEnd > existingStart
);
});
if (hasOverlap) {
toast.error(
"Cannot place element here - it would overlap with existing elements",
);
return;
}
addElementToTrack(targetTrackId, {
type: "media",
mediaId: mediaItem.id,
name: mediaItem.name,
duration: mediaItem.duration || 5,
startTime: mediaSnappedTime,
trimStart: 0,
trimEnd: 0,
});
}
} else if (hasFiles) {
// External file drops
const { activeProject } = useProjectStore.getState();
const { addMediaFile } = useMediaStore.getState();
const { addElementToTrack } = useTimelineStore.getState();
if (!activeProject) {
toast.error("No active project");
return;
}
// Process and add files to new timeline tracks at playhead position
processMediaFiles(e.dataTransfer.files)
.then(async (processedItems) => {
for (const processedItem of processedItems) {
await addMediaFile(activeProject.id, processedItem);
const currentMediaFiles = mediaFiles;
const addedItem = currentMediaFiles.find(
(item) =>
item.name === processedItem.name &&
item.url === processedItem.url,
);
if (addedItem) {
const trackType: TrackType =
addedItem.type === "audio" ? "audio" : "media";
const targetTrackId = insertTrackAt(trackType, 0);
addElementToTrack(targetTrackId, {
type: "media",
mediaId: addedItem.id,
name: addedItem.name,
duration: addedItem.duration || 5,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
});
}
}
})
.catch((error) => {
console.error("Error processing external files:", error);
toast.error("Failed to process dropped files");
});
}
} catch (error) {
console.error("Error handling drop:", error);
toast.error("Failed to add media to track");
}
};
return (
<div
className="hover:bg-muted/20 h-full w-full"
@@ -1118,10 +355,7 @@ export function TimelineTrackContent({
clearSelectedElements();
}
}}
onDragOver={handleTrackDragOver}
onDragEnter={handleTrackDragEnter}
onDragLeave={handleTrackDragLeave}
onDrop={handleTrackDrop}
{...dragProps}
>
<div
ref={timelineRef}
@@ -1130,14 +364,14 @@ export function TimelineTrackContent({
{track.elements.length === 0 ? (
<div
className={`text-muted-foreground flex h-full w-full items-center justify-center rounded-sm border-2 border-dashed text-xs transition-colors ${
isDropping
isDragOver
? wouldOverlap
? "border-red-500 bg-red-500/10 text-red-600"
: "border-blue-500 bg-blue-500/10 text-blue-600"
: "border-muted/30"
}`}
>
{isDropping
{isDragOver
? wouldOverlap
? "Cannot drop - would overlap"
: "Drop element here"
@@ -1150,40 +384,6 @@ export function TimelineTrackContent({
(c) => c.trackId === track.id && c.elementId === element.id,
);
const handleElementSplit = () => {
const { currentTime } = usePlaybackStore();
const { splitSelected } = useTimelineStore();
const splitTime = currentTime;
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
splitSelected(splitTime, track.id, element.id);
} else {
toast.error("Playhead must be within element to split");
}
};
const handleElementDuplicate = () => {
const { addElementToTrack } = useTimelineStore.getState();
const { id, ...elementWithoutId } = element;
addElementToTrack(track.id, {
...elementWithoutId,
name: element.name + " (copy)",
startTime:
element.startTime +
(element.duration - element.trimStart - element.trimEnd) +
0.1,
});
};
const handleElementDelete = () => {
const { deleteSelected } = useTimelineStore.getState();
deleteSelected(track.id, element.id);
};
return (
<TimelineElement
key={element.id}
@@ -152,18 +152,6 @@ export class TimelineManager {
throw new Error("Not implemented");
}
moveElementToTrack({
fromTrackId,
toTrackId,
elementId,
}: {
fromTrackId: string;
toTrackId: string;
elementId: string;
}): void {
throw new Error("Not implemented");
}
updateElementTrim({
trackId,
elementId,
@@ -236,28 +224,6 @@ export class TimelineManager {
throw new Error("Not implemented");
}
separateAudio({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): string | null {
throw new Error("Not implemented");
}
replaceElementMedia({
trackId,
elementId,
newFile,
}: {
trackId: string;
elementId: string;
newFile: File;
}): Promise<{ success: boolean; error?: string }> {
throw new Error("Not implemented");
}
updateElementStartTimeWithRipple({
trackId,
elementId,
@@ -394,18 +360,6 @@ export class TimelineManager {
throw new Error("Not implemented");
}
async replaceElementWithFile({
trackId,
elementId,
file,
}: {
trackId: string;
elementId: string;
file: File;
}): Promise<void> {
throw new Error("Not implemented");
}
getContextMenuState({
trackId,
elementId,
@@ -0,0 +1,414 @@
import { useState, useRef, useCallback } from "react";
import { useMediaStore } from "@/stores/media-store";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { processMediaFiles } from "@/lib/media-processing-utils";
import { toast } from "sonner";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/editor-constants";
import { snapTimeToFrame } from "@/lib/time-utils";
import { getMainTrack, canElementGoOnTrack } from "@/lib/timeline/track-utils";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import {
useTimelineSnapping,
SnapPoint,
} from "@/hooks/timeline/use-timeline-snapping";
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
interface UseTimelineDragDropProps {
track?: TimelineTrack;
zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
export function useTimelineDragDrop({
track,
zoomLevel,
}: UseTimelineDragDropProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [wouldOverlap, setWouldOverlap] = useState(false);
const [dropPositionIndicator, setDropPositionIndicator] = useState<
number | null
>(null);
const { mediaFiles, addMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { currentTime } = usePlaybackStore();
const {
tracks,
addElementToTrack,
insertTrackAt,
addTrack,
snappingEnabled,
} = useTimelineStore();
const dragCounterRef = useRef(0);
const { snapElementEdge } = useTimelineSnapping({
snapThreshold: 10,
enableElementSnapping: snappingEnabled,
enablePlayheadSnapping: snappingEnabled,
});
const getDropSnappedTime = useCallback(
(dropTime: number, elementDuration: number, excludeElementId?: string) => {
const projectFps = activeProject?.fps || DEFAULT_FPS;
let finalTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
if (snappingEnabled) {
const startSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
true,
);
const endSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
false,
);
let bestSnapResult = startSnapResult;
if (
endSnapResult.snapPoint &&
(!startSnapResult.snapPoint ||
endSnapResult.snapDistance < startSnapResult.snapDistance)
) {
bestSnapResult = endSnapResult;
}
if (bestSnapResult.snapPoint) {
finalTime = bestSnapResult.snappedTime;
}
}
return finalTime;
},
[
activeProject?.fps,
snappingEnabled,
snapElementEdge,
tracks,
currentTime,
zoomLevel,
],
);
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
const hasFiles = e.dataTransfer.types.includes("Files");
if (!hasMediaItem && !hasFiles) return;
dragCounterRef.current++;
if (!isDragOver) setIsDragOver(true);
},
[isDragOver],
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
if (!hasMediaItem) return;
if (track) {
const trackContainer =
(e.currentTarget as HTMLElement).closest(
".track-elements-container",
) ||
(e.currentTarget as HTMLElement).querySelector(
".track-elements-container",
) ||
(e.currentTarget as HTMLElement);
const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left);
const dropTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
let overlap = false;
try {
const mediaItemData = e.dataTransfer.getData(
"application/x-media-item",
);
if (mediaItemData) {
const dragData: DragData = JSON.parse(mediaItemData);
const duration =
dragData.type === "text"
? 5
: mediaFiles.find((m) => m.id === dragData.id)?.duration || 5;
const snappedTime = getDropSnappedTime(dropTime, duration);
const endTime = snappedTime + duration;
overlap = track.elements.some((el) => {
const elEnd =
el.startTime + (el.duration - el.trimStart - el.trimEnd);
return snappedTime < elEnd && endTime > el.startTime;
});
}
} catch (f) {}
setWouldOverlap(overlap);
setDropPositionIndicator(getDropSnappedTime(dropTime, 5));
e.dataTransfer.dropEffect = overlap ? "none" : "copy";
}
},
[track, zoomLevel, mediaFiles, getDropSnappedTime],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current--;
if (dragCounterRef.current <= 0) {
dragCounterRef.current = 0;
setIsDragOver(false);
setWouldOverlap(false);
setDropPositionIndicator(null);
}
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
setWouldOverlap(false);
setDropPositionIndicator(null);
dragCounterRef.current = 0;
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item",
);
const hasFiles = e.dataTransfer.files?.length > 0;
if (!hasMediaItem && !hasFiles) return;
const trackContainer =
(e.currentTarget as HTMLElement).closest(".track-elements-container") ||
(e.currentTarget as HTMLElement).querySelector(
".track-elements-container",
) ||
(e.currentTarget as HTMLElement);
if (!trackContainer) return;
const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left);
const mouseY = e.clientY - rect.top;
const dropTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
const projectFps = activeProject?.fps || DEFAULT_FPS;
const snappedTime = snapTimeToFrame({ time: dropTime, fps: projectFps });
let dropPos: "above" | "on" | "below" = "on";
if (track) {
if (mouseY < 20) dropPos = "above";
else if (mouseY > 40) dropPos = "below";
}
try {
if (hasMediaItem) {
const mediaItemData = e.dataTransfer.getData(
"application/x-media-item",
);
if (!mediaItemData) return;
const dragData: DragData = JSON.parse(mediaItemData);
if (dragData.type === "text") {
let targetTrackId = track?.id;
let targetTrack = track;
if (!track || track.type !== "text" || dropPos !== "on") {
const mainTrack = getMainTrack({ tracks });
let insertIndex = 0;
if (track) {
const currentIdx = tracks.findIndex((t) => t.id === track.id);
insertIndex = dropPos === "above" ? currentIdx : currentIdx + 1;
} else if (mainTrack) {
insertIndex = tracks.findIndex((t) => t.id === mainTrack.id);
}
targetTrackId = insertTrackAt("text", insertIndex);
targetTrack = useTimelineStore
.getState()
.tracks.find((t) => t.id === targetTrackId);
}
if (!targetTrack || !targetTrackId) return;
const duration = 5;
const finalStart = getDropSnappedTime(dropTime, duration);
const finalEnd = finalStart + duration;
if (
targetTrack.elements.some(
(el) =>
finalStart <
el.startTime + el.duration - el.trimStart - el.trimEnd &&
finalEnd > el.startTime,
)
) {
toast.error("Cannot place element here - overlap detected");
return;
}
addElementToTrack(targetTrackId, {
...DEFAULT_TEXT_ELEMENT,
name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
startTime: finalStart,
});
} else {
const mediaItem = mediaFiles.find((m) => m.id === dragData.id);
if (!mediaItem) return;
let targetTrackId = track?.id;
const isVideoOrImage =
dragData.type === "video" || dragData.type === "image";
const isAudio = dragData.type === "audio";
const isCompatible = track
? isVideoOrImage
? canElementGoOnTrack({
elementType: "media",
trackType: track.type,
})
: isAudio
? canElementGoOnTrack({
elementType: "media",
trackType: track.type,
})
: false
: false;
let targetTrack = track;
if (!track || !isCompatible || dropPos !== "on") {
if (isVideoOrImage) {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack) {
targetTrackId = addTrack("media");
} else if (
mainTrack.elements.length === 0 &&
(!track || dropPos === "on")
) {
targetTrackId = mainTrack.id;
} else {
let idx = track
? tracks.findIndex((t) => t.id === track.id)
: 0;
if (track) idx = dropPos === "above" ? idx : idx + 1;
else idx = tracks.findIndex((t) => t.id === mainTrack.id);
targetTrackId = insertTrackAt("media", idx);
}
} else if (isAudio) {
let idx = track
? tracks.findIndex((t) => t.id === track.id)
: tracks.length;
if (track) idx = dropPos === "above" ? idx : idx + 1;
targetTrackId = insertTrackAt("audio", idx);
}
targetTrack = useTimelineStore
.getState()
.tracks.find((t) => t.id === targetTrackId);
}
if (!targetTrack || !targetTrackId) return;
const duration = mediaItem.duration || 5;
const finalStart = getDropSnappedTime(dropTime, duration);
const finalEnd = finalStart + duration;
if (
targetTrack.elements.some(
(el) =>
finalStart <
el.startTime + el.duration - el.trimStart - el.trimEnd &&
finalEnd > el.startTime,
)
) {
toast.error("Cannot place element here - overlap detected");
return;
}
addElementToTrack(targetTrackId, {
type: "media",
mediaId: mediaItem.id,
name: mediaItem.name,
duration,
startTime: finalStart,
trimStart: 0,
trimEnd: 0,
});
}
} else if (hasFiles) {
if (!activeProject) return;
const processedItems = await processMediaFiles({
files: Array.from(e.dataTransfer.files),
});
for (const item of processedItems) {
await addMediaFile(activeProject.id, item);
const added = useMediaStore
.getState()
.mediaFiles.find(
(m) => m.name === item.name && m.url === item.url,
);
if (added) {
const type: TrackType =
added.type === "audio" ? "audio" : "media";
const tid = insertTrackAt(type, 0);
addElementToTrack(tid, {
type: "media",
mediaId: added.id,
name: added.name,
duration: added.duration || 5,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
});
}
}
}
} catch (err) {
console.error(err);
toast.error("Failed to process drop");
}
},
[
track,
zoomLevel,
activeProject,
tracks,
mediaFiles,
currentTime,
getDropSnappedTime,
addElementToTrack,
insertTrackAt,
addTrack,
addMediaFile,
],
);
return {
isDragOver,
wouldOverlap,
dropPositionIndicator,
dragProps: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
};
}
-85
View File
@@ -1,85 +0,0 @@
import { useState, useRef } from "react";
interface UseDragDropOptions {
onDrop?: (files: FileList) => void;
}
// Helper function to check if drag contains files from external sources (not internal app drags)
const containsFiles = (dataTransfer: DataTransfer): boolean => {
// Check if this is an internal app drag (media item)
if (dataTransfer.types.includes("application/x-media-item")) {
return false;
}
// Only show overlay for external file drags
return dataTransfer.types.includes("Files");
};
export function useDragDrop(options: UseDragDropOptions = {}) {
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
// Only handle external file drags, not internal app element drags
if (!containsFiles(e.dataTransfer)) {
return;
}
dragCounterRef.current += 1;
if (!isDragOver) {
setIsDragOver(true);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
// Only handle file drags
if (!containsFiles(e.dataTransfer)) {
return;
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
// Only handle file drags
if (!containsFiles(e.dataTransfer)) {
return;
}
dragCounterRef.current -= 1;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
dragCounterRef.current = 0;
// Only handle file drops
if (
options.onDrop &&
e.dataTransfer.files &&
containsFiles(e.dataTransfer)
) {
options.onDrop(e.dataTransfer.files);
}
};
const dragProps = {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
};
return {
isDragOver,
dragProps,
};
}
-109
View File
@@ -1,109 +0,0 @@
import { useEffect, useCallback } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { toast } from "sonner";
export const usePlaybackControls = () => {
const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
const {
selectedElements,
tracks,
splitSelected,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
} = useTimelineStore();
const handleSplitSelectedElement = useCallback(() => {
if (selectedElements.length !== 1) {
toast.error("Select exactly one element to split");
return;
}
const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.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;
}
splitSelected(currentTime, trackId, elementId);
}, [selectedElements, tracks, currentTime, splitSelected]);
const handleSplitAndKeepLeftCallback = useCallback(() => {
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((e) => e.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);
}, [selectedElements, tracks, currentTime, splitAndKeepLeft]);
const handleSplitAndKeepRightCallback = useCallback(() => {
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((e) => e.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);
}, [selectedElements, tracks, currentTime, splitAndKeepRight]);
const handleSeparateAudioCallback = useCallback(() => {
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);
}, [selectedElements, tracks, separateAudio]);
};
@@ -0,0 +1,103 @@
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useProjectStore } from "@/stores/project-store";
export function useProjectInitialization({ projectId }: { projectId: string }) {
const {
activeProject,
loadProject,
createNewProject,
isInvalidProjectId,
markProjectIdAsInvalid,
} = useProjectStore();
const router = useRouter();
const handledProjectIds = useRef<Set<string>>(new Set());
const isInitializingRef = useRef<boolean>(false);
useEffect(() => {
let isCancelled = false;
const initProject = async () => {
if (!projectId) {
return;
}
if (isInitializingRef.current) {
return;
}
if (activeProject?.id === projectId) {
return;
}
if (isInvalidProjectId(projectId)) {
return;
}
if (handledProjectIds.current.has(projectId)) {
return;
}
isInitializingRef.current = true;
handledProjectIds.current.add(projectId);
try {
await loadProject(projectId);
if (isCancelled) {
return;
}
isInitializingRef.current = false;
} catch (error) {
if (isCancelled) {
return;
}
const isProjectNotFound =
error instanceof Error &&
(error.message.includes("not found") ||
error.message.includes("does not exist") ||
error.message.includes("Project not found"));
if (isProjectNotFound) {
markProjectIdAsInvalid(projectId);
try {
const newProjectId = await createNewProject("Untitled Project");
if (isCancelled) {
return;
}
router.replace(`/editor/${newProjectId}`);
} catch (createError) {
console.error("Failed to create new project:", createError);
}
} else {
console.error(
"Project loading failed with recoverable error:",
error,
);
handledProjectIds.current.delete(projectId);
}
isInitializingRef.current = false;
}
};
initProject();
return () => {
isCancelled = true;
isInitializingRef.current = false;
};
}, [
projectId,
loadProject,
createNewProject,
router,
isInvalidProjectId,
markProjectIdAsInvalid,
]);
}
@@ -1,9 +1,9 @@
import { useEffect, useState, useRef } from "react";
export function useHighlightScroll(
export function useRevealItem(
highlightId: string | null,
onClearHighlight: () => void,
highlightDuration = 1000
highlightDuration = 1000,
) {
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
@@ -1,141 +0,0 @@
import { useState, useRef, useCallback } from "react";
import { useMediaStore } from "@/stores/media-store";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { processMediaFiles } from "@/lib/media-processing-utils";
import { toast } from "sonner";
import type { DragData } from "@/types/timeline";
interface UseTimelineDragDropProps {
addElementToNewTrack: (data: any) => void;
}
export function useTimelineDragDrop({ addElementToNewTrack }: UseTimelineDragDropProps) {
const [isDragOver, setIsDragOver] = useState(false);
const { mediaFiles, addMediaFile } = useMediaStore();
const { activeProject } = useProjectStore();
const { currentTime } = usePlaybackStore();
const dragCounterRef = useRef(0);
const handleInternalMediaDrop = useCallback(async (dragData: DragData) => {
if (dragData.type === "text") {
addElementToNewTrack(dragData);
} else {
const mediaItem = mediaFiles.find((item: any) => item.id === dragData.id);
if (!mediaItem) {
toast.error("Media item not found");
return;
}
addElementToNewTrack(mediaItem);
}
}, [mediaFiles, addElementToNewTrack]);
const handleExternalFileDrop = useCallback(async (files: FileList) => {
if (!activeProject) {
toast.error("No active project");
return;
}
try {
const processedItems = await processMediaFiles({
files,
});
for (const processedItem of processedItems) {
await addMediaFile(activeProject.id, processedItem);
const addedItem = mediaFiles.find(
(item) =>
item.name === processedItem.name && item.url === processedItem.url,
);
if (addedItem) {
const trackType: "audio" | "media" =
addedItem.type === "audio" ? "audio" : "media";
const targetTrackId = useTimelineStore
.getState()
.insertTrackAt(trackType, 0);
useTimelineStore.getState().addElementToTrack(targetTrackId, {
type: "media",
mediaId: addedItem.id,
name: addedItem.name,
duration: addedItem.duration || 5,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
});
}
}
} catch (error) {
console.error("Error processing external files:", error);
toast.error("Failed to process dropped files");
}
}, [activeProject, mediaFiles, addMediaFile, currentTime]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
return;
}
dragCounterRef.current++;
if (!isDragOver) setIsDragOver(true);
}, [isDragOver]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
return;
}
dragCounterRef.current--;
if (dragCounterRef.current === 0) setIsDragOver(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
dragCounterRef.current = 0;
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
return;
}
try {
const itemData = e.dataTransfer.getData("application/x-media-item");
if (itemData) {
const dragData: DragData = JSON.parse(itemData);
await handleInternalMediaDrop(dragData);
return;
}
if (e.dataTransfer.files?.length > 0) {
await handleExternalFileDrop(e.dataTransfer.files);
}
} catch (error) {
console.error("Error parsing dropped item data:", error);
toast.error("Failed to add item to timeline");
}
}, [handleInternalMediaDrop, handleExternalFileDrop]);
return {
isDragOver,
dragProps: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
};
}
@@ -68,7 +68,7 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
},
};
interface MediaPanelStore {
interface AssetsPanelStore {
activeTab: Tab;
setActiveTab: (tab: Tab) => void;
highlightMediaId: string | null;
@@ -76,7 +76,7 @@ interface MediaPanelStore {
clearHighlight: () => void;
}
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
export const useAssetsPanelStore = create<AssetsPanelStore>((set) => ({
activeTab: "media",
setActiveTab: (tab) => set({ activeTab: tab }),
highlightMediaId: null,
-1
View File
@@ -60,7 +60,6 @@ export const getImageDimensions = (
});
};
// Helper function to generate video thumbnail and get dimensions
export const generateVideoThumbnail = (
file: File
): Promise<{ thumbnailUrl: string; width: number; height: number }> => {
+2 -291
View File
@@ -13,8 +13,8 @@ import {
ensureMainTrack,
validateElementTrackCompatibility,
} from "@/lib/timeline/track-utils";
import { useMediaStore } from "./media-store";
import { MediaFile, MediaType } from "@/types/media";
import { generateVideoThumbnail, useMediaStore } from "./media-store";
import { MediaFile } from "@/types/media";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
import { useSceneStore } from "./scene-store";
@@ -23,7 +23,6 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { usePlaybackStore } from "./playback-store";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
originalName: string,
@@ -103,11 +102,6 @@ interface TimelineStore {
removeTrackWithRipple: (trackId: string) => void;
addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
moveElementToTrack: (
fromTrackId: string,
toTrackId: string,
elementId: string,
) => void;
updateElementTrim: (
trackId: string,
elementId: string,
@@ -138,14 +132,6 @@ interface TimelineStore {
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<{ success: boolean; error?: string }>;
// Ripple editing functions
updateElementStartTimeWithRipple: (
@@ -199,12 +185,6 @@ interface TimelineStore {
toggleSelectedHidden: (trackId?: string, elementId?: string) => void;
toggleSelectedMuted: (trackId?: string, elementId?: string) => void;
duplicateElement: (trackId: string, elementId: string) => void;
revealElementInMedia: (elementId: string) => void;
replaceElementWithFile: (
trackId: string,
elementId: string,
file: File,
) => Promise<void>;
getContextMenuState: (
trackId: string,
elementId: string,
@@ -690,49 +670,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
updateTracksAndSave(updatedTracks);
},
moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
get().pushHistory();
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,
);
if (!elementToMove || !toTrack) return;
const validation = validateElementTrackCompatibility({
element: elementToMove,
track: toTrack,
});
if (!validation.isValid) {
console.error(validation.errorMessage);
return;
}
const newTracks = get()
._tracks.map((track) => {
if (track.id === fromTrackId) {
return {
...track,
elements: track.elements.filter(
(element) => element.id !== elementId,
),
};
}
if (track.id === toTrackId) {
return {
...track,
elements: [...track.elements, elementToMove],
};
}
return track;
})
.filter((track) => track.elements.length > 0);
updateTracksAndSave(newTracks);
},
updateElementTrim: (
trackId,
elementId,
@@ -988,190 +925,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
// 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 || track?.type !== "media") return null;
get().pushHistory();
const existingAudioTrack = _tracks.find((t) => t.type === "audio");
const audioElementId = generateUUID();
if (existingAudioTrack) {
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === existingAudioTrack.id
? {
...track,
elements: [
...track.elements,
{
...element,
id: audioElementId,
name: getElementNameWithSuffix(element.name, "audio"),
},
],
}
: track,
),
);
} else {
const newAudioTrack: TimelineTrack = {
id: generateUUID(),
name: "Audio Track",
type: "audio",
elements: [
{
...element,
id: audioElementId,
name: getElementNameWithSuffix(element.name, "audio"),
},
],
muted: false,
};
updateTracksAndSave([...get()._tracks, newAudioTrack]);
}
return audioElementId;
},
// Replace media for an element
replaceElementMedia: async (
trackId: string,
elementId: string,
newFile: File,
): Promise<{ success: boolean; error?: string }> => {
const { _tracks } = get();
const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId);
if (!element) {
return { success: false, error: "Timeline element not found" };
}
if (element.type !== "media") {
return {
success: false,
error: "Replace is only available for media clips",
};
}
try {
const mediaStore = useMediaStore.getState();
const projectStore = useProjectStore.getState();
if (!projectStore.activeProject) {
return { success: false, error: "No active project found" };
}
const {
getFileType,
getImageDimensions,
generateVideoThumbnail,
getMediaDuration,
} = await import("./media-store");
const fileType = getFileType(newFile);
if (!fileType) {
return {
success: false,
error:
"Unsupported file type. Please select a video, audio, or image file.",
};
}
const mediaData: Omit<MediaFile, "id"> = {
name: newFile.name,
type: fileType as MediaType,
file: newFile,
url: URL.createObjectURL(newFile),
};
try {
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);
}
} catch (error) {
return {
success: false,
error: `Failed to process ${fileType} file: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
try {
await mediaStore.addMediaFile(
projectStore.activeProject.id,
mediaData,
);
} catch (error) {
return {
success: false,
error: `Failed to add media to project: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
const newMediaItem = mediaStore.mediaFiles.find(
(item) => item.file === newFile,
);
if (!newMediaItem) {
return {
success: false,
error: "Failed to create media item in project. Please try again.",
};
}
get().pushHistory();
updateTracksAndSave(
_tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((c) =>
c.id === elementId
? {
...c,
mediaId: newMediaItem.id,
name: newMediaItem.name,
duration: newMediaItem.duration || c.duration,
}
: c,
),
}
: track,
),
);
return { success: true };
} catch (error) {
console.error("Failed to replace element media:", error);
return {
success: false,
error: `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
},
getTotalDuration: () => {
const { _tracks } = get();
if (_tracks.length === 0) return 0;
@@ -1226,9 +979,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
if (!mediaFile) return null;
if (mediaFile.type === "video" && mediaFile.file) {
const { generateVideoThumbnail } = await import(
"@/stores/media-store"
);
const { thumbnailUrl } = await generateVideoThumbnail(mediaFile.file);
return thumbnailUrl;
}
@@ -1778,45 +1528,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
} as CreateTimelineElement);
},
revealElementInMedia: (elementId) => {
const {
useMediaPanelStore,
} = require("../components/editor/media-panel/store");
const { requestRevealMedia } = useMediaPanelStore.getState();
const { _tracks } = get();
const element = _tracks
.flatMap((track) => track.elements)
.find((el) => el.id === elementId);
if (element?.type === "media") {
requestRevealMedia(element.mediaId);
}
},
replaceElementWithFile: async (trackId, elementId, file) => {
try {
const result = await get().replaceElementMedia(
trackId,
elementId,
file,
);
if (result.success) {
const { toast } = await import("sonner");
toast.success("Clip replaced successfully");
} else {
const { toast } = await import("sonner");
toast.error(result.error || "Failed to replace clip");
}
} catch (error) {
console.error("Unexpected error replacing clip:", error);
const { toast } = await import("sonner");
toast.error(
`Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
},
getContextMenuState: (trackId, elementId) => {
const { selectedElements, _tracks } = get();
const { currentTime } = usePlaybackStore.getState();