diff options
| author | Hyteq <DerpsDer.es@gmail.com> | 2025-07-20 10:48:40 +0300 |
|---|---|---|
| committer | Hyteq <DerpsDer.es@gmail.com> | 2025-07-20 10:48:40 +0300 |
| commit | eab8ec3ba81b86fdd4ade920d999c28fb34785b1 (patch) | |
| tree | 619fc6930f50fa36d6f1f94ee81fb8bf6d4bedef /apps/web/src/components/editor | |
| parent | d040ca53cb4029c9c58bd7e6b6dfd1eed2694b31 (diff) | |
| download | opencut-eab8ec3ba81b86fdd4ade920d999c28fb34785b1.tar.gz | |
format, cleanup, deps
Diffstat (limited to 'apps/web/src/components/editor')
22 files changed, 4230 insertions, 4227 deletions
diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index 75a5ae06..cae2359b 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -1,115 +1,117 @@ -import React, { useEffect, useRef, useState } from 'react'; -import WaveSurfer from 'wavesurfer.js'; +import React, { useEffect, useRef, useState } from "react"; +import WaveSurfer from "wavesurfer.js"; interface AudioWaveformProps { - audioUrl: string; - height?: number; - className?: string; + audioUrl: string; + height?: number; + className?: string; } -const AudioWaveform: React.FC<AudioWaveformProps> = ({ - audioUrl, - height = 32, - className = '' +const AudioWaveform: React.FC<AudioWaveformProps> = ({ + audioUrl, + height = 32, + className = "", }) => { - const waveformRef = useRef<HTMLDivElement>(null); - const wavesurfer = useRef<WaveSurfer | null>(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(false); + const waveformRef = useRef<HTMLDivElement>(null); + const wavesurfer = useRef<WaveSurfer | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); - useEffect(() => { - let mounted = true; + useEffect(() => { + let mounted = true; - const initWaveSurfer = async () => { - if (!waveformRef.current || !audioUrl) return; + const initWaveSurfer = async () => { + if (!waveformRef.current || !audioUrl) return; - try { - // Clean up any existing instance - if (wavesurfer.current) { - try { - wavesurfer.current.destroy(); - } catch (e) { - // Silently ignore destroy errors - } - wavesurfer.current = null; - } + try { + // Clean up any existing instance + if (wavesurfer.current) { + try { + wavesurfer.current.destroy(); + } catch (e) { + // Silently ignore destroy errors + } + wavesurfer.current = null; + } - wavesurfer.current = WaveSurfer.create({ - container: waveformRef.current, - waveColor: 'rgba(255, 255, 255, 0.6)', - progressColor: 'rgba(255, 255, 255, 0.9)', - cursorColor: 'transparent', - barWidth: 2, - barGap: 1, - height: height, - normalize: true, - interact: false, - }); + wavesurfer.current = WaveSurfer.create({ + container: waveformRef.current, + waveColor: "rgba(255, 255, 255, 0.6)", + progressColor: "rgba(255, 255, 255, 0.9)", + cursorColor: "transparent", + barWidth: 2, + barGap: 1, + height: height, + normalize: true, + interact: false, + }); - // Event listeners - wavesurfer.current.on('ready', () => { - if (mounted) { - setIsLoading(false); - setError(false); - } - }); + // Event listeners + wavesurfer.current.on("ready", () => { + if (mounted) { + setIsLoading(false); + setError(false); + } + }); - wavesurfer.current.on('error', (err) => { - console.error('WaveSurfer error:', err); - if (mounted) { - setError(true); - setIsLoading(false); - } - }); + wavesurfer.current.on("error", (err) => { + console.error("WaveSurfer error:", err); + if (mounted) { + setError(true); + setIsLoading(false); + } + }); - await wavesurfer.current.load(audioUrl); + await wavesurfer.current.load(audioUrl); + } catch (err) { + console.error("Failed to initialize WaveSurfer:", err); + if (mounted) { + setError(true); + setIsLoading(false); + } + } + }; - } catch (err) { - console.error('Failed to initialize WaveSurfer:', err); - if (mounted) { - setError(true); - setIsLoading(false); - } - } - }; + initWaveSurfer(); - initWaveSurfer(); + return () => { + mounted = false; + if (wavesurfer.current) { + try { + wavesurfer.current.destroy(); + } catch (e) { + // Silently ignore destroy errors + } + wavesurfer.current = null; + } + }; + }, [audioUrl, height]); - return () => { - mounted = false; - if (wavesurfer.current) { - try { - wavesurfer.current.destroy(); - } catch (e) { - // Silently ignore destroy errors - } - wavesurfer.current = null; - } - }; - }, [audioUrl, height]); + if (error) { + return ( + <div + className={`flex items-center justify-center ${className}`} + style={{ height }} + > + <span className="text-xs text-foreground/60">Audio unavailable</span> + </div> + ); + } - if (error) { - return ( - <div className={`flex items-center justify-center ${className}`} style={{ height }}> - <span className="text-xs text-foreground/60">Audio unavailable</span> - </div> - ); - } - - return ( - <div className={`relative ${className}`}> - {isLoading && ( - <div className="absolute inset-0 flex items-center justify-center"> - <span className="text-xs text-foreground/60">Loading...</span> - </div> - )} - <div - ref={waveformRef} - className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`} - style={{ height }} - /> - </div> - ); + return ( + <div className={`relative ${className}`}> + {isLoading && ( + <div className="absolute inset-0 flex items-center justify-center"> + <span className="text-xs text-foreground/60">Loading...</span> + </div> + )} + <div + ref={waveformRef} + className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`} + style={{ height }} + /> + </div> + ); }; -export default AudioWaveform;
\ No newline at end of file +export default AudioWaveform; diff --git a/apps/web/src/components/editor/media-panel/drag-overlay.tsx b/apps/web/src/components/editor/media-panel/drag-overlay.tsx index 3f3820db..0c75d146 100644 --- a/apps/web/src/components/editor/media-panel/drag-overlay.tsx +++ b/apps/web/src/components/editor/media-panel/drag-overlay.tsx @@ -1,57 +1,57 @@ -import { Upload, Plus, Image } from "lucide-react";
-import { Button } from "@/components/ui/button";
-
-interface MediaDragOverlayProps {
- isVisible: boolean;
- isProcessing?: boolean;
- progress?: number;
- onClick?: () => void;
- isEmptyState?: boolean;
-}
-
-export function MediaDragOverlay({
- isVisible,
- isProcessing = false,
- progress = 0,
- onClick,
- isEmptyState = false,
-}: MediaDragOverlayProps) {
- if (!isVisible) return null;
-
- const handleClick = (e: React.MouseEvent) => {
- if (isProcessing || !onClick) return;
- e.preventDefault();
- e.stopPropagation();
- onClick();
- };
-
- return (
- <div
- className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
- onClick={handleClick}
- >
- <div className="flex items-center justify-center">
- <Upload className="h-10 w-10 text-foreground" />
- </div>
-
- <div className="space-y-2">
- <p className="text-xs text-muted-foreground max-w-sm">
- {isProcessing
- ? `Processing your files (${progress}%)`
- : "Drag and drop videos, photos, and audio files here"}
- </p>
- </div>
-
- {isProcessing && (
- <div className="w-full max-w-xs">
- <div className="w-full bg-muted/50 rounded-full h-2">
- <div
- className="bg-primary h-2 rounded-full transition-all duration-300"
- style={{ width: `${progress}%` }}
- />
- </div>
- </div>
- )}
- </div>
- );
-}
+import { Upload, Plus, Image } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface MediaDragOverlayProps { + isVisible: boolean; + isProcessing?: boolean; + progress?: number; + onClick?: () => void; + isEmptyState?: boolean; +} + +export function MediaDragOverlay({ + isVisible, + isProcessing = false, + progress = 0, + onClick, + isEmptyState = false, +}: MediaDragOverlayProps) { + if (!isVisible) return null; + + const handleClick = (e: React.MouseEvent) => { + if (isProcessing || !onClick) return; + e.preventDefault(); + e.stopPropagation(); + onClick(); + }; + + return ( + <div + className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8" + onClick={handleClick} + > + <div className="flex items-center justify-center"> + <Upload className="h-10 w-10 text-foreground" /> + </div> + + <div className="space-y-2"> + <p className="text-xs text-muted-foreground max-w-sm"> + {isProcessing + ? `Processing your files (${progress}%)` + : "Drag and drop videos, photos, and audio files here"} + </p> + </div> + + {isProcessing && ( + <div className="w-full max-w-xs"> + <div className="w-full bg-muted/50 rounded-full h-2"> + <div + className="bg-primary h-2 rounded-full transition-all duration-300" + style={{ width: `${progress}%` }} + /> + </div> + </div> + )} + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 9f7322c7..50fee601 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -1,55 +1,55 @@ -"use client";
-
-import { TabBar } from "./tabbar";
-import { MediaView } from "./views/media";
-import { useMediaPanelStore, Tab } from "./store";
-import { TextView } from "./views/text";
-
-export function MediaPanel() {
- const { activeTab } = useMediaPanelStore();
-
- const viewMap: Record<Tab, React.ReactNode> = {
- media: <MediaView />,
- audio: (
- <div className="p-4 text-muted-foreground">Audio view coming soon...</div>
- ),
- text: <TextView />,
- stickers: (
- <div className="p-4 text-muted-foreground">
- Stickers view coming soon...
- </div>
- ),
- effects: (
- <div className="p-4 text-muted-foreground">
- Effects view coming soon...
- </div>
- ),
- transitions: (
- <div className="p-4 text-muted-foreground">
- Transitions view coming soon...
- </div>
- ),
- captions: (
- <div className="p-4 text-muted-foreground">
- Captions view coming soon...
- </div>
- ),
- filters: (
- <div className="p-4 text-muted-foreground">
- Filters view coming soon...
- </div>
- ),
- adjustment: (
- <div className="p-4 text-muted-foreground">
- Adjustment view coming soon...
- </div>
- ),
- };
-
- return (
- <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
- <TabBar />
- <div className="flex-1">{viewMap[activeTab]}</div>
- </div>
- );
-}
+"use client"; + +import { TabBar } from "./tabbar"; +import { MediaView } from "./views/media"; +import { useMediaPanelStore, Tab } from "./store"; +import { TextView } from "./views/text"; + +export function MediaPanel() { + const { activeTab } = useMediaPanelStore(); + + const viewMap: Record<Tab, React.ReactNode> = { + media: <MediaView />, + audio: ( + <div className="p-4 text-muted-foreground">Audio view coming soon...</div> + ), + text: <TextView />, + stickers: ( + <div className="p-4 text-muted-foreground"> + Stickers view coming soon... + </div> + ), + effects: ( + <div className="p-4 text-muted-foreground"> + Effects view coming soon... + </div> + ), + transitions: ( + <div className="p-4 text-muted-foreground"> + Transitions view coming soon... + </div> + ), + captions: ( + <div className="p-4 text-muted-foreground"> + Captions view coming soon... + </div> + ), + filters: ( + <div className="p-4 text-muted-foreground"> + Filters view coming soon... + </div> + ), + adjustment: ( + <div className="p-4 text-muted-foreground"> + Adjustment view coming soon... + </div> + ), + }; + + return ( + <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden"> + <TabBar /> + <div className="flex-1">{viewMap[activeTab]}</div> + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts index 9668f285..0150c39a 100644 --- a/apps/web/src/components/editor/media-panel/store.ts +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -1,73 +1,73 @@ -import {
- CaptionsIcon,
- ArrowLeftRightIcon,
- SparklesIcon,
- StickerIcon,
- MusicIcon,
- VideoIcon,
- BlendIcon,
- SlidersHorizontalIcon,
- LucideIcon,
- TypeIcon,
-} from "lucide-react";
-import { create } from "zustand";
-
-export type Tab =
- | "media"
- | "audio"
- | "text"
- | "stickers"
- | "effects"
- | "transitions"
- | "captions"
- | "filters"
- | "adjustment";
-
-export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
- media: {
- icon: VideoIcon,
- label: "Media",
- },
- audio: {
- icon: MusicIcon,
- label: "Audio",
- },
- text: {
- icon: TypeIcon,
- label: "Text",
- },
- stickers: {
- icon: StickerIcon,
- label: "Stickers",
- },
- effects: {
- icon: SparklesIcon,
- label: "Effects",
- },
- transitions: {
- icon: ArrowLeftRightIcon,
- label: "Transitions",
- },
- captions: {
- icon: CaptionsIcon,
- label: "Captions",
- },
- filters: {
- icon: BlendIcon,
- label: "Filters",
- },
- adjustment: {
- icon: SlidersHorizontalIcon,
- label: "Adjustment",
- },
-};
-
-interface MediaPanelStore {
- activeTab: Tab;
- setActiveTab: (tab: Tab) => void;
-}
-
-export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
- activeTab: "media",
- setActiveTab: (tab) => set({ activeTab: tab }),
-}));
+import { + CaptionsIcon, + ArrowLeftRightIcon, + SparklesIcon, + StickerIcon, + MusicIcon, + VideoIcon, + BlendIcon, + SlidersHorizontalIcon, + LucideIcon, + TypeIcon, +} from "lucide-react"; +import { create } from "zustand"; + +export type Tab = + | "media" + | "audio" + | "text" + | "stickers" + | "effects" + | "transitions" + | "captions" + | "filters" + | "adjustment"; + +export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { + media: { + icon: VideoIcon, + label: "Media", + }, + audio: { + icon: MusicIcon, + label: "Audio", + }, + text: { + icon: TypeIcon, + label: "Text", + }, + stickers: { + icon: StickerIcon, + label: "Stickers", + }, + effects: { + icon: SparklesIcon, + label: "Effects", + }, + transitions: { + icon: ArrowLeftRightIcon, + label: "Transitions", + }, + captions: { + icon: CaptionsIcon, + label: "Captions", + }, + filters: { + icon: BlendIcon, + label: "Filters", + }, + adjustment: { + icon: SlidersHorizontalIcon, + label: "Adjustment", + }, +}; + +interface MediaPanelStore { + activeTab: Tab; + setActiveTab: (tab: Tab) => void; +} + +export const useMediaPanelStore = create<MediaPanelStore>((set) => ({ + activeTab: "media", + setActiveTab: (tab) => set({ activeTab: tab }), +})); diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index b05bee7e..50793243 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -1,124 +1,124 @@ -"use client";
-
-import { cn } from "@/lib/utils";
-import { Tab, tabs, useMediaPanelStore } from "./store";
-import { Button } from "@/components/ui/button";
-import { ChevronRight, ChevronLeft } from "lucide-react";
-import { useRef, useState, useEffect } from "react";
-
-export function TabBar() {
- const { activeTab, setActiveTab } = useMediaPanelStore();
- const scrollContainerRef = useRef<HTMLDivElement>(null);
- const [isAtEnd, setIsAtEnd] = useState(false);
- const [isAtStart, setIsAtStart] = useState(true);
-
- const scrollToEnd = () => {
- if (scrollContainerRef.current) {
- scrollContainerRef.current.scrollTo({
- left: scrollContainerRef.current.scrollWidth,
- });
- setIsAtEnd(true);
- setIsAtStart(false);
- }
- };
-
- const scrollToStart = () => {
- if (scrollContainerRef.current) {
- scrollContainerRef.current.scrollTo({
- left: 0,
- });
- setIsAtStart(true);
- setIsAtEnd(false);
- }
- };
-
- const checkScrollPosition = () => {
- if (scrollContainerRef.current) {
- const { scrollLeft, scrollWidth, clientWidth } =
- scrollContainerRef.current;
- const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
- const isAtStartNow = scrollLeft <= 1;
- setIsAtEnd(isAtEndNow);
- setIsAtStart(isAtStartNow);
- }
- };
-
- // We're using useEffect because we need to sync with external DOM scroll events
- useEffect(() => {
- const container = scrollContainerRef.current;
- if (!container) return;
-
- checkScrollPosition();
- container.addEventListener("scroll", checkScrollPosition);
-
- const resizeObserver = new ResizeObserver(checkScrollPosition);
- resizeObserver.observe(container);
-
- return () => {
- container.removeEventListener("scroll", checkScrollPosition);
- resizeObserver.disconnect();
- };
- }, []);
-
- return (
- <div className="flex">
- <ScrollButton
- direction="left"
- onClick={scrollToStart}
- isVisible={!isAtStart}
- />
- <div
- ref={scrollContainerRef}
- className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
- >
- {(Object.keys(tabs) as Tab[]).map((tabKey) => {
- const tab = tabs[tabKey];
- return (
- <div
- className={cn(
- "flex flex-col gap-0.5 items-center cursor-pointer",
- activeTab === tabKey ? "text-primary" : "text-muted-foreground"
- )}
- onClick={() => setActiveTab(tabKey)}
- key={tabKey}
- >
- <tab.icon className="!size-[1.1rem]" />
- <span className="text-[0.65rem]">{tab.label}</span>
- </div>
- );
- })}
- </div>
- <ScrollButton
- direction="right"
- onClick={scrollToEnd}
- isVisible={!isAtEnd}
- />
- </div>
- );
-}
-
-function ScrollButton({
- direction,
- onClick,
- isVisible,
-}: {
- direction: "left" | "right";
- onClick: () => void;
- isVisible: boolean;
-}) {
- if (!isVisible) return null;
-
- const Icon = direction === "left" ? ChevronLeft : ChevronRight;
-
- return (
- <div className="bg-panel-accent w-12 h-full flex items-center justify-center">
- <Button
- size="icon"
- className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
- onClick={onClick}
- >
- <Icon className="!size-4 text-foreground" />
- </Button>
- </div>
- );
-}
+"use client"; + +import { cn } from "@/lib/utils"; +import { Tab, tabs, useMediaPanelStore } from "./store"; +import { Button } from "@/components/ui/button"; +import { ChevronRight, ChevronLeft } from "lucide-react"; +import { useRef, useState, useEffect } from "react"; + +export function TabBar() { + const { activeTab, setActiveTab } = useMediaPanelStore(); + const scrollContainerRef = useRef<HTMLDivElement>(null); + const [isAtEnd, setIsAtEnd] = useState(false); + const [isAtStart, setIsAtStart] = useState(true); + + const scrollToEnd = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + left: scrollContainerRef.current.scrollWidth, + }); + setIsAtEnd(true); + setIsAtStart(false); + } + }; + + const scrollToStart = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + left: 0, + }); + setIsAtStart(true); + setIsAtEnd(false); + } + }; + + const checkScrollPosition = () => { + if (scrollContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = + scrollContainerRef.current; + const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1; + const isAtStartNow = scrollLeft <= 1; + setIsAtEnd(isAtEndNow); + setIsAtStart(isAtStartNow); + } + }; + + // We're using useEffect because we need to sync with external DOM scroll events + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + checkScrollPosition(); + container.addEventListener("scroll", checkScrollPosition); + + const resizeObserver = new ResizeObserver(checkScrollPosition); + resizeObserver.observe(container); + + return () => { + container.removeEventListener("scroll", checkScrollPosition); + resizeObserver.disconnect(); + }; + }, []); + + return ( + <div className="flex"> + <ScrollButton + direction="left" + onClick={scrollToStart} + isVisible={!isAtStart} + /> + <div + ref={scrollContainerRef} + className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full" + > + {(Object.keys(tabs) as Tab[]).map((tabKey) => { + const tab = tabs[tabKey]; + return ( + <div + className={cn( + "flex flex-col gap-0.5 items-center cursor-pointer", + activeTab === tabKey ? "text-primary" : "text-muted-foreground", + )} + onClick={() => setActiveTab(tabKey)} + key={tabKey} + > + <tab.icon className="!size-[1.1rem]" /> + <span className="text-[0.65rem]">{tab.label}</span> + </div> + ); + })} + </div> + <ScrollButton + direction="right" + onClick={scrollToEnd} + isVisible={!isAtEnd} + /> + </div> + ); +} + +function ScrollButton({ + direction, + onClick, + isVisible, +}: { + direction: "left" | "right"; + onClick: () => void; + isVisible: boolean; +}) { + if (!isVisible) return null; + + const Icon = direction === "left" ? ChevronLeft : ChevronRight; + + return ( + <div className="bg-panel-accent w-12 h-full flex items-center justify-center"> + <Button + size="icon" + className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10" + onClick={onClick} + > + <Icon className="!size-4 text-foreground" /> + </Button> + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 631fe39a..712dd922 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -1,298 +1,298 @@ -"use client";
-
-import { useDragDrop } from "@/hooks/use-drag-drop";
-import { processMediaFiles } from "@/lib/media-processing";
-import { useMediaStore, type MediaItem } from "@/stores/media-store";
-import { Image, Loader2, Music, Plus, Video } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-import { toast } from "sonner";
-import { Button } from "@/components/ui/button";
-import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { Input } from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { DraggableMediaItem } from "@/components/ui/draggable-item";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-
-export function MediaView() {
- const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
- const { activeProject } = useProjectStore();
- const fileInputRef = useRef<HTMLInputElement>(null);
- const [isProcessing, setIsProcessing] = useState(false);
- const [progress, setProgress] = useState(0);
- const [searchQuery, setSearchQuery] = useState("");
- const [mediaFilter, setMediaFilter] = useState("all");
-
- const processFiles = async (files: FileList | File[]) => {
- if (!files || files.length === 0) return;
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- setIsProcessing(true);
- setProgress(0);
- try {
- // Process files (extract metadata, generate thumbnails, etc.)
- const processedItems = await processMediaFiles(files, (p) =>
- setProgress(p)
- );
- // Add each processed media item to the store
- for (const item of processedItems) {
- await addMediaItem(activeProject.id, item);
- }
- } catch (error) {
- // Show error toast if processing fails
- console.error("Error processing files:", error);
- toast.error("Failed to process files");
- } finally {
- setIsProcessing(false);
- setProgress(0);
- }
- };
-
- 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>) => {
- // When files are selected via file picker, process them
- if (e.target.files) processFiles(e.target.files);
- e.target.value = ""; // Reset input
- };
-
- const handleRemove = async (e: React.MouseEvent, id: string) => {
- // Remove a media item from the store
- e.stopPropagation();
-
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- // Media store now handles cascade deletion automatically
- await removeMediaItem(activeProject.id, id);
- };
-
- const formatDuration = (duration: number) => {
- // Format seconds as mm:ss
- const min = Math.floor(duration / 60);
- const sec = Math.floor(duration % 60);
- return `${min}:${sec.toString().padStart(2, "0")}`;
- };
-
- const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
-
- useEffect(() => {
- const filtered = mediaItems.filter((item) => {
- if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
- return false;
- }
-
- if (
- searchQuery &&
- !item.name.toLowerCase().includes(searchQuery.toLowerCase())
- ) {
- return false;
- }
-
- return true;
- });
-
- setFilteredMediaItems(filtered);
- }, [mediaItems, mediaFilter, searchQuery]);
-
- const renderPreview = (item: MediaItem) => {
- // Render a preview for each media type (image, video, audio, unknown)
- if (item.type === "image") {
- return (
- <div className="w-full h-full flex items-center justify-center">
- <img
- src={item.url}
- alt={item.name}
- className="max-w-full max-h-full object-contain"
- loading="lazy"
- />
- </div>
- );
- }
-
- if (item.type === "video") {
- if (item.thumbnailUrl) {
- return (
- <div className="relative w-full h-full">
- <img
- src={item.thumbnailUrl}
- alt={item.name}
- className="w-full h-full object-cover rounded"
- loading="lazy"
- />
- <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
- <Video className="h-6 w-6 text-white drop-shadow-md" />
- </div>
- {item.duration && (
- <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
- {formatDuration(item.duration)}
- </div>
- )}
- </div>
- );
- }
- return (
- <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
- <Video className="h-6 w-6 mb-1" />
- <span className="text-xs">Video</span>
- {item.duration && (
- <span className="text-xs opacity-70">
- {formatDuration(item.duration)}
- </span>
- )}
- </div>
- );
- }
-
- if (item.type === "audio") {
- return (
- <div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
- <Music className="h-6 w-6 mb-1" />
- <span className="text-xs">Audio</span>
- {item.duration && (
- <span className="text-xs opacity-70">
- {formatDuration(item.duration)}
- </span>
- )}
- </div>
- );
- }
-
- return (
- <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
- <Image className="h-6 w-6" />
- <span className="text-xs mt-1">Unknown</span>
- </div>
- );
- };
-
- return (
- <>
- {/* Hidden file input for uploading media */}
- <input
- ref={fileInputRef}
- type="file"
- accept="image/*,video/*,audio/*"
- multiple
- className="hidden"
- onChange={handleFileChange}
- />
-
- <div
- className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
- {...dragProps}
- >
- <div className="p-3 pb-2">
- {/* Search and filter controls */}
- <div className="flex gap-2">
- <Select value={mediaFilter} onValueChange={setMediaFilter}>
- <SelectTrigger className="w-[80px] h-full text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent className="">
- <SelectItem value="all">All</SelectItem>
- <SelectItem value="video">Video</SelectItem>
- <SelectItem value="audio">Audio</SelectItem>
- <SelectItem value="image">Image</SelectItem>
- </SelectContent>
- </Select>
- <Input
- type="text"
- placeholder="Search media..."
- className="min-w-[60px] flex-1 h-full text-xs"
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
- <Button
- variant="outline"
- size="lg"
- onClick={handleFileSelect}
- disabled={isProcessing}
- className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
- >
- {isProcessing ? (
- <Loader2 className="h-4 w-4 animate-spin" />
- ) : (
- <Plus className="h-4 w-4" />
- )}
- </Button>
- </div>
- </div>
-
- <div className="flex-1 overflow-y-auto p-3 pt-0">
- {isDragOver || filteredMediaItems.length === 0 ? (
- <MediaDragOverlay
- isVisible={true}
- isProcessing={isProcessing}
- progress={progress}
- onClick={handleFileSelect}
- isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
- />
- ) : (
- <div
- className="grid gap-2"
- style={{
- gridTemplateColumns: "repeat(auto-fill, 160px)",
- }}
- >
- {/* Render each media item as a draggable button */}
- {filteredMediaItems.map((item) => (
- <ContextMenu key={item.id}>
- <ContextMenuTrigger>
- <DraggableMediaItem
- name={item.name}
- preview={renderPreview(item)}
- dragData={{
- id: item.id,
- type: item.type,
- name: item.name,
- }}
- showPlusOnDrag={false}
- onAddToTimeline={(currentTime) =>
- useTimelineStore
- .getState()
- .addMediaAtTime(item, currentTime)
- }
- rounded={false}
- />
- </ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem>Export clips</ContextMenuItem>
- <ContextMenuItem
- variant="destructive"
- onClick={(e) => handleRemove(e, item.id)}
- >
- Delete
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- ))}
- </div>
- )}
- </div>
- </div>
- </>
- );
-}
+"use client"; + +import { useDragDrop } from "@/hooks/use-drag-drop"; +import { processMediaFiles } from "@/lib/media-processing"; +import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { Image, Loader2, Music, Plus, Video } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; + +export function MediaView() { + const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); + const { activeProject } = useProjectStore(); + const fileInputRef = useRef<HTMLInputElement>(null); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const [mediaFilter, setMediaFilter] = useState("all"); + + const processFiles = async (files: FileList | File[]) => { + if (!files || files.length === 0) return; + if (!activeProject) { + toast.error("No active project"); + return; + } + + setIsProcessing(true); + setProgress(0); + try { + // Process files (extract metadata, generate thumbnails, etc.) + const processedItems = await processMediaFiles(files, (p) => + setProgress(p), + ); + // Add each processed media item to the store + for (const item of processedItems) { + await addMediaItem(activeProject.id, item); + } + } catch (error) { + // Show error toast if processing fails + console.error("Error processing files:", error); + toast.error("Failed to process files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + }; + + 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>) => { + // When files are selected via file picker, process them + if (e.target.files) processFiles(e.target.files); + e.target.value = ""; // Reset input + }; + + const handleRemove = async (e: React.MouseEvent, id: string) => { + // Remove a media item from the store + e.stopPropagation(); + + if (!activeProject) { + toast.error("No active project"); + return; + } + + // Media store now handles cascade deletion automatically + await removeMediaItem(activeProject.id, id); + }; + + const formatDuration = (duration: number) => { + // Format seconds as mm:ss + const min = Math.floor(duration / 60); + const sec = Math.floor(duration % 60); + return `${min}:${sec.toString().padStart(2, "0")}`; + }; + + const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); + + useEffect(() => { + const filtered = mediaItems.filter((item) => { + if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { + return false; + } + + if ( + searchQuery && + !item.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) { + return false; + } + + return true; + }); + + setFilteredMediaItems(filtered); + }, [mediaItems, mediaFilter, searchQuery]); + + const renderPreview = (item: MediaItem) => { + // Render a preview for each media type (image, video, audio, unknown) + if (item.type === "image") { + return ( + <div className="w-full h-full flex items-center justify-center"> + <img + src={item.url} + alt={item.name} + className="max-w-full max-h-full object-contain" + loading="lazy" + /> + </div> + ); + } + + if (item.type === "video") { + if (item.thumbnailUrl) { + return ( + <div className="relative w-full h-full"> + <img + src={item.thumbnailUrl} + alt={item.name} + className="w-full h-full object-cover rounded" + loading="lazy" + /> + <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded"> + <Video className="h-6 w-6 text-white drop-shadow-md" /> + </div> + {item.duration && ( + <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded"> + {formatDuration(item.duration)} + </div> + )} + </div> + ); + } + return ( + <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> + <Video className="h-6 w-6 mb-1" /> + <span className="text-xs">Video</span> + {item.duration && ( + <span className="text-xs opacity-70"> + {formatDuration(item.duration)} + </span> + )} + </div> + ); + } + + if (item.type === "audio") { + return ( + <div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20"> + <Music className="h-6 w-6 mb-1" /> + <span className="text-xs">Audio</span> + {item.duration && ( + <span className="text-xs opacity-70"> + {formatDuration(item.duration)} + </span> + )} + </div> + ); + } + + return ( + <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> + <Image className="h-6 w-6" /> + <span className="text-xs mt-1">Unknown</span> + </div> + ); + }; + + return ( + <> + {/* Hidden file input for uploading media */} + <input + ref={fileInputRef} + type="file" + accept="image/*,video/*,audio/*" + multiple + className="hidden" + onChange={handleFileChange} + /> + + <div + className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`} + {...dragProps} + > + <div className="p-3 pb-2"> + {/* Search and filter controls */} + <div className="flex gap-2"> + <Select value={mediaFilter} onValueChange={setMediaFilter}> + <SelectTrigger className="w-[80px] h-full text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent className=""> + <SelectItem value="all">All</SelectItem> + <SelectItem value="video">Video</SelectItem> + <SelectItem value="audio">Audio</SelectItem> + <SelectItem value="image">Image</SelectItem> + </SelectContent> + </Select> + <Input + type="text" + placeholder="Search media..." + className="min-w-[60px] flex-1 h-full text-xs" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + <Button + variant="outline" + size="lg" + onClick={handleFileSelect} + disabled={isProcessing} + className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center" + > + {isProcessing ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Plus className="h-4 w-4" /> + )} + </Button> + </div> + </div> + + <div className="flex-1 overflow-y-auto p-3 pt-0"> + {isDragOver || filteredMediaItems.length === 0 ? ( + <MediaDragOverlay + isVisible={true} + isProcessing={isProcessing} + progress={progress} + onClick={handleFileSelect} + isEmptyState={filteredMediaItems.length === 0 && !isDragOver} + /> + ) : ( + <div + className="grid gap-2" + style={{ + gridTemplateColumns: "repeat(auto-fill, 160px)", + }} + > + {/* Render each media item as a draggable button */} + {filteredMediaItems.map((item) => ( + <ContextMenu key={item.id}> + <ContextMenuTrigger> + <DraggableMediaItem + name={item.name} + preview={renderPreview(item)} + dragData={{ + id: item.id, + type: item.type, + name: item.name, + }} + showPlusOnDrag={false} + onAddToTimeline={(currentTime) => + useTimelineStore + .getState() + .addMediaAtTime(item, currentTime) + } + rounded={false} + /> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem>Export clips</ContextMenuItem> + <ContextMenuItem + variant="destructive" + onClick={(e) => handleRemove(e, item.id)} + > + Delete + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ))} + </div> + )} + </div> + </div> + </> + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 6d0d310b..b47edd8d 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,53 +1,53 @@ -import { DraggableMediaItem } from "@/components/ui/draggable-item";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { type TextElement } from "@/types/timeline";
-
-let textData: TextElement = {
- id: "default-text",
- type: "text",
- name: "Default text",
- content: "Default text",
- fontSize: 48,
- fontFamily: "Arial",
- color: "#ffffff",
- backgroundColor: "transparent",
- textAlign: "center" as const,
- fontWeight: "normal" as const,
- fontStyle: "normal" as const,
- textDecoration: "none" as const,
- x: 0,
- y: 0,
- rotation: 0,
- opacity: 1,
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
-};
-
-export function TextView() {
- return (
- <div className="p-4">
- <DraggableMediaItem
- name="Default text"
- preview={
- <div className="flex items-center justify-center w-full h-full bg-accent rounded">
- <span className="text-xs select-none">Default text</span>
- </div>
- }
- dragData={{
- id: textData.id,
- type: textData.type,
- name: textData.name,
- content: textData.content,
- }}
- aspectRatio={1}
- onAddToTimeline={(currentTime) =>
- useTimelineStore.getState().addTextAtTime(textData, currentTime)
- }
- showLabel={false}
- />
- </div>
- );
-}
+import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { type TextElement } from "@/types/timeline"; + +let textData: TextElement = { + id: "default-text", + type: "text", + name: "Default text", + content: "Default text", + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center" as const, + fontWeight: "normal" as const, + fontStyle: "normal" as const, + textDecoration: "none" as const, + x: 0, + y: 0, + rotation: 0, + opacity: 1, + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, +}; + +export function TextView() { + return ( + <div className="p-4"> + <DraggableMediaItem + name="Default text" + preview={ + <div className="flex items-center justify-center w-full h-full bg-accent rounded"> + <span className="text-xs select-none">Default text</span> + </div> + } + dragData={{ + id: textData.id, + type: textData.type, + name: textData.name, + content: textData.content, + }} + aspectRatio={1} + onAddToTimeline={(currentTime) => + useTimelineStore.getState().addTextAtTime(textData, currentTime) + } + showLabel={false} + /> + </div> + ); +} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 2ecee391..ea66de74 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -10,11 +10,11 @@ import { VideoPlayer } from "@/components/ui/video-player"; import { AudioPlayer } from "@/components/ui/audio-player"; import { Button } from "@/components/ui/button"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSeparator, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Play, Pause, Expand } from "lucide-react"; import { useState, useRef, useEffect } from "react"; @@ -25,473 +25,473 @@ import { BackgroundSettings } from "../background-settings"; import { useProjectStore } from "@/stores/project-store"; interface ActiveElement { - element: TimelineElement; - track: TimelineTrack; - mediaItem: MediaItem | null; + element: TimelineElement; + track: TimelineTrack; + mediaItem: MediaItem | null; } export function PreviewPanel() { - const { tracks } = useTimelineStore(); - const { mediaItems } = useMediaStore(); - const { currentTime } = usePlaybackStore(); - const { canvasSize } = useEditorStore(); - const previewRef = useRef<HTMLDivElement>(null); - const containerRef = useRef<HTMLDivElement>(null); - const [previewDimensions, setPreviewDimensions] = useState({ - width: 0, - height: 0, - }); - const { activeProject } = useProjectStore(); + const { tracks } = useTimelineStore(); + const { mediaItems } = useMediaStore(); + const { currentTime } = usePlaybackStore(); + const { canvasSize } = useEditorStore(); + const previewRef = useRef<HTMLDivElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const [previewDimensions, setPreviewDimensions] = useState({ + width: 0, + height: 0, + }); + const { activeProject } = useProjectStore(); - // Calculate optimal preview size that fits in container while maintaining aspect ratio - useEffect(() => { - const updatePreviewSize = () => { - if (!containerRef.current) return; + // Calculate optimal preview size that fits in container while maintaining aspect ratio + useEffect(() => { + const updatePreviewSize = () => { + if (!containerRef.current) return; - const container = containerRef.current.getBoundingClientRect(); - const computedStyle = getComputedStyle(containerRef.current); + const container = containerRef.current.getBoundingClientRect(); + const computedStyle = getComputedStyle(containerRef.current); - // Get padding values - const paddingTop = parseFloat(computedStyle.paddingTop); - const paddingBottom = parseFloat(computedStyle.paddingBottom); - const paddingLeft = parseFloat(computedStyle.paddingLeft); - const paddingRight = parseFloat(computedStyle.paddingRight); + // Get padding values + const paddingTop = parseFloat(computedStyle.paddingTop); + const paddingBottom = parseFloat(computedStyle.paddingBottom); + const paddingLeft = parseFloat(computedStyle.paddingLeft); + const paddingRight = parseFloat(computedStyle.paddingRight); - // Get gap value (gap-4 = 1rem = 16px) - const gap = parseFloat(computedStyle.gap) || 16; + // Get gap value (gap-4 = 1rem = 16px) + const gap = parseFloat(computedStyle.gap) || 16; - // Get toolbar height if it exists - const toolbar = containerRef.current.querySelector("[data-toolbar]"); - const toolbarHeight = toolbar - ? toolbar.getBoundingClientRect().height - : 0; + // Get toolbar height if it exists + const toolbar = containerRef.current.querySelector("[data-toolbar]"); + const toolbarHeight = toolbar + ? toolbar.getBoundingClientRect().height + : 0; - // Calculate available space after accounting for padding, gap, and toolbar - const availableWidth = container.width - paddingLeft - paddingRight; - const availableHeight = - container.height - - paddingTop - - paddingBottom - - toolbarHeight - - (toolbarHeight > 0 ? gap : 0); + // Calculate available space after accounting for padding, gap, and toolbar + const availableWidth = container.width - paddingLeft - paddingRight; + const availableHeight = + container.height - + paddingTop - + paddingBottom - + toolbarHeight - + (toolbarHeight > 0 ? gap : 0); - const targetRatio = canvasSize.width / canvasSize.height; - const containerRatio = availableWidth / availableHeight; + const targetRatio = canvasSize.width / canvasSize.height; + const containerRatio = availableWidth / availableHeight; - let width, height; + let width, height; - if (containerRatio > targetRatio) { - // Container is wider - constrain by height - height = availableHeight; - width = height * targetRatio; - } else { - // Container is taller - constrain by width - width = availableWidth; - height = width / targetRatio; - } + if (containerRatio > targetRatio) { + // Container is wider - constrain by height + height = availableHeight; + width = height * targetRatio; + } else { + // Container is taller - constrain by width + width = availableWidth; + height = width / targetRatio; + } - setPreviewDimensions({ width, height }); - }; + setPreviewDimensions({ width, height }); + }; - updatePreviewSize(); + updatePreviewSize(); - const resizeObserver = new ResizeObserver(updatePreviewSize); - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } + const resizeObserver = new ResizeObserver(updatePreviewSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } - return () => resizeObserver.disconnect(); - }, [canvasSize.width, canvasSize.height]); + return () => resizeObserver.disconnect(); + }, [canvasSize.width, canvasSize.height]); - // Get active elements at current time - const getActiveElements = (): ActiveElement[] => { - const activeElements: ActiveElement[] = []; + // Get active elements at current time + const getActiveElements = (): ActiveElement[] => { + const activeElements: ActiveElement[] = []; - tracks.forEach((track) => { - track.elements.forEach((element) => { - const elementStart = element.startTime; - const elementEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + tracks.forEach((track) => { + track.elements.forEach((element) => { + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (currentTime >= elementStart && currentTime < elementEnd) { - let mediaItem = null; + if (currentTime >= elementStart && currentTime < elementEnd) { + let mediaItem = null; - // Only get media item for media elements - if (element.type === "media") { - mediaItem = - element.mediaId === "test" - ? null // Test elements don't have a real media item - : mediaItems.find((item) => item.id === element.mediaId) || - null; - } + // Only get media item for media elements + if (element.type === "media") { + mediaItem = + element.mediaId === "test" + ? null // Test elements don't have a real media item + : mediaItems.find((item) => item.id === element.mediaId) || + null; + } - activeElements.push({ element, track, mediaItem }); - } - }); - }); + activeElements.push({ element, track, mediaItem }); + } + }); + }); - return activeElements; - }; + return activeElements; + }; - const activeElements = getActiveElements(); + const activeElements = getActiveElements(); - // Check if there are any elements in the timeline at all - const hasAnyElements = tracks.some((track) => track.elements.length > 0); + // Check if there are any elements in the timeline at all + const hasAnyElements = tracks.some((track) => track.elements.length > 0); - // Get media elements for blur background (video/image only) - const getBlurBackgroundElements = (): ActiveElement[] => { - return activeElements.filter( - ({ element, mediaItem }) => - element.type === "media" && - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") && - element.mediaId !== "test" // Exclude test elements - ); - }; + // Get media elements for blur background (video/image only) + const getBlurBackgroundElements = (): ActiveElement[] => { + return activeElements.filter( + ({ element, mediaItem }) => + element.type === "media" && + mediaItem && + (mediaItem.type === "video" || mediaItem.type === "image") && + element.mediaId !== "test", // Exclude test elements + ); + }; - const blurBackgroundElements = getBlurBackgroundElements(); + const blurBackgroundElements = getBlurBackgroundElements(); - // Render blur background layer - const renderBlurBackground = () => { - if ( - !activeProject?.backgroundType || - activeProject.backgroundType !== "blur" || - blurBackgroundElements.length === 0 - ) { - return null; - } + // Render blur background layer + const renderBlurBackground = () => { + if ( + !activeProject?.backgroundType || + activeProject.backgroundType !== "blur" || + blurBackgroundElements.length === 0 + ) { + return null; + } - // Use the first media element for background (could be enhanced to use primary/focused element) - const backgroundElement = blurBackgroundElements[0]; - const { element, mediaItem } = backgroundElement; + // Use the first media element for background (could be enhanced to use primary/focused element) + const backgroundElement = blurBackgroundElements[0]; + const { element, mediaItem } = backgroundElement; - if (!mediaItem) return null; + if (!mediaItem) return null; - const blurIntensity = activeProject.blurIntensity || 8; + const blurIntensity = activeProject.blurIntensity || 8; - if (mediaItem.type === "video") { - return ( - <div - key={`blur-${element.id}`} - className="absolute inset-0 overflow-hidden" - style={{ - filter: `blur(${blurIntensity}px)`, - transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts - transformOrigin: "center", - }} - > - <VideoPlayer - src={mediaItem.url!} - poster={mediaItem.thumbnailUrl} - clipStartTime={element.startTime} - trimStart={element.trimStart} - trimEnd={element.trimEnd} - clipDuration={element.duration} - className="w-full h-full object-cover" - /> - </div> - ); - } + if (mediaItem.type === "video") { + return ( + <div + key={`blur-${element.id}`} + className="absolute inset-0 overflow-hidden" + style={{ + filter: `blur(${blurIntensity}px)`, + transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts + transformOrigin: "center", + }} + > + <VideoPlayer + src={mediaItem.url!} + poster={mediaItem.thumbnailUrl} + clipStartTime={element.startTime} + trimStart={element.trimStart} + trimEnd={element.trimEnd} + clipDuration={element.duration} + className="w-full h-full object-cover" + /> + </div> + ); + } - if (mediaItem.type === "image") { - return ( - <div - key={`blur-${element.id}`} - className="absolute inset-0 overflow-hidden" - style={{ - filter: `blur(${blurIntensity}px)`, - transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts - transformOrigin: "center", - }} - > - <img - src={mediaItem.url!} - alt={mediaItem.name} - className="w-full h-full object-cover" - draggable={false} - /> - </div> - ); - } + if (mediaItem.type === "image") { + return ( + <div + key={`blur-${element.id}`} + className="absolute inset-0 overflow-hidden" + style={{ + filter: `blur(${blurIntensity}px)`, + transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts + transformOrigin: "center", + }} + > + <img + src={mediaItem.url!} + alt={mediaItem.name} + className="w-full h-full object-cover" + draggable={false} + /> + </div> + ); + } - return null; - }; + return null; + }; - // Render an element - const renderElement = (elementData: ActiveElement, index: number) => { - const { element, mediaItem } = elementData; + // Render an element + const renderElement = (elementData: ActiveElement, index: number) => { + const { element, mediaItem } = elementData; - // Text elements - if (element.type === "text") { - const fontClassName = - FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || ""; + // Text elements + if (element.type === "text") { + const fontClassName = + FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || ""; - const scaleRatio = previewDimensions.width / canvasSize.width; + const scaleRatio = previewDimensions.width / canvasSize.width; - return ( - <div - key={element.id} - className="absolute flex items-center justify-center" - style={{ - left: `${50 + (element.x / canvasSize.width) * 100}%`, - top: `${50 + (element.y / canvasSize.height) * 100}%`, - transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, - opacity: element.opacity, - zIndex: 100 + index, // Text elements on top - }} - > - <div - className={fontClassName} - style={{ - fontSize: `${element.fontSize}px`, - color: element.color, - backgroundColor: element.backgroundColor, - textAlign: element.textAlign, - fontWeight: element.fontWeight, - fontStyle: element.fontStyle, - textDecoration: element.textDecoration, - padding: "4px 8px", - borderRadius: "2px", - whiteSpace: "nowrap", - // Fallback for system fonts that don't have classes - ...(fontClassName === "" && { fontFamily: element.fontFamily }), - }} - > - {element.content} - </div> - </div> - ); - } + return ( + <div + key={element.id} + className="absolute flex items-center justify-center" + style={{ + left: `${50 + (element.x / canvasSize.width) * 100}%`, + top: `${50 + (element.y / canvasSize.height) * 100}%`, + transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, + opacity: element.opacity, + zIndex: 100 + index, // Text elements on top + }} + > + <div + className={fontClassName} + style={{ + fontSize: `${element.fontSize}px`, + color: element.color, + backgroundColor: element.backgroundColor, + textAlign: element.textAlign, + fontWeight: element.fontWeight, + fontStyle: element.fontStyle, + textDecoration: element.textDecoration, + padding: "4px 8px", + borderRadius: "2px", + whiteSpace: "nowrap", + // Fallback for system fonts that don't have classes + ...(fontClassName === "" && { fontFamily: element.fontFamily }), + }} + > + {element.content} + </div> + </div> + ); + } - // Media elements - if (element.type === "media") { - // Test elements - if (!mediaItem || element.mediaId === "test") { - return ( - <div - key={element.id} - className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" - > - <div className="text-center"> - <div className="text-2xl mb-2">🎬</div> - <p className="text-xs text-white">{element.name}</p> - </div> - </div> - ); - } + // Media elements + if (element.type === "media") { + // Test elements + if (!mediaItem || element.mediaId === "test") { + return ( + <div + key={element.id} + className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" + > + <div className="text-center"> + <div className="text-2xl mb-2">🎬</div> + <p className="text-xs text-white">{element.name}</p> + </div> + </div> + ); + } - // Video elements - if (mediaItem.type === "video") { - return ( - <div - key={element.id} - className="absolute inset-0 flex items-center justify-center" - > - <VideoPlayer - src={mediaItem.url!} - poster={mediaItem.thumbnailUrl} - clipStartTime={element.startTime} - trimStart={element.trimStart} - trimEnd={element.trimEnd} - clipDuration={element.duration} - /> - </div> - ); - } + // Video elements + if (mediaItem.type === "video") { + return ( + <div + key={element.id} + className="absolute inset-0 flex items-center justify-center" + > + <VideoPlayer + src={mediaItem.url!} + poster={mediaItem.thumbnailUrl} + clipStartTime={element.startTime} + trimStart={element.trimStart} + trimEnd={element.trimEnd} + clipDuration={element.duration} + /> + </div> + ); + } - // Image elements - if (mediaItem.type === "image") { - return ( - <div - key={element.id} - className="absolute inset-0 flex items-center justify-center" - > - <img - src={mediaItem.url!} - alt={mediaItem.name} - className="max-w-full max-h-full object-contain" - draggable={false} - /> - </div> - ); - } + // Image elements + if (mediaItem.type === "image") { + return ( + <div + key={element.id} + className="absolute inset-0 flex items-center justify-center" + > + <img + src={mediaItem.url!} + alt={mediaItem.name} + className="max-w-full max-h-full object-contain" + draggable={false} + /> + </div> + ); + } - // Audio elements (no visual representation) - if (mediaItem.type === "audio") { - return ( - <div key={element.id} className="absolute inset-0"> - <AudioPlayer - src={mediaItem.url!} - clipStartTime={element.startTime} - trimStart={element.trimStart} - trimEnd={element.trimEnd} - clipDuration={element.duration} - trackMuted={elementData.track.muted} - /> - </div> - ); - } - } + // Audio elements (no visual representation) + if (mediaItem.type === "audio") { + return ( + <div key={element.id} className="absolute inset-0"> + <AudioPlayer + src={mediaItem.url!} + clipStartTime={element.startTime} + trimStart={element.trimStart} + trimEnd={element.trimEnd} + clipDuration={element.duration} + trackMuted={elementData.track.muted} + /> + </div> + ); + } + } - return null; - }; + return null; + }; - return ( - <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm"> - <div - ref={containerRef} - className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" - > - <div className="flex-1"></div> - {hasAnyElements ? ( - <div - ref={previewRef} - className="relative overflow-hidden border" - style={{ - width: previewDimensions.width, - height: previewDimensions.height, - backgroundColor: - activeProject?.backgroundType === "blur" - ? "transparent" - : activeProject?.backgroundColor || "#000000", - }} - > - {renderBlurBackground()} - {activeElements.length === 0 ? ( - <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> - No elements at current time - </div> - ) : ( - activeElements.map((elementData, index) => - renderElement(elementData, index) - ) - )} - {/* Show message when blur is selected but no media available */} - {activeProject?.backgroundType === "blur" && - blurBackgroundElements.length === 0 && - activeElements.length > 0 && ( - <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> - Add a video or image to use blur background - </div> - )} - </div> - ) : null} + return ( + <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm"> + <div + ref={containerRef} + className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" + > + <div className="flex-1"></div> + {hasAnyElements ? ( + <div + ref={previewRef} + className="relative overflow-hidden border" + style={{ + width: previewDimensions.width, + height: previewDimensions.height, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "transparent" + : activeProject?.backgroundColor || "#000000", + }} + > + {renderBlurBackground()} + {activeElements.length === 0 ? ( + <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> + No elements at current time + </div> + ) : ( + activeElements.map((elementData, index) => + renderElement(elementData, index), + ) + )} + {/* Show message when blur is selected but no media available */} + {activeProject?.backgroundType === "blur" && + blurBackgroundElements.length === 0 && + activeElements.length > 0 && ( + <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> + Add a video or image to use blur background + </div> + )} + </div> + ) : null} - <div className="flex-1"></div> + <div className="flex-1"></div> - <PreviewToolbar hasAnyElements={hasAnyElements} /> - </div> - </div> - ); + <PreviewToolbar hasAnyElements={hasAnyElements} /> + </div> + </div> + ); } function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { - const { isPlaying, toggle, currentTime } = usePlaybackStore(); - const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); - const { getTotalDuration } = useTimelineStore(); - const { activeProject } = useProjectStore(); - const { - currentPreset, - isOriginal, - getOriginalAspectRatio, - getDisplayName, - canvasPresets, - } = useAspectRatio(); + const { isPlaying, toggle, currentTime } = usePlaybackStore(); + const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); + const { getTotalDuration } = useTimelineStore(); + const { activeProject } = useProjectStore(); + const { + currentPreset, + isOriginal, + getOriginalAspectRatio, + getDisplayName, + canvasPresets, + } = useAspectRatio(); - const handlePresetSelect = (preset: { width: number; height: number }) => { - setCanvasSize({ width: preset.width, height: preset.height }); - }; + const handlePresetSelect = (preset: { width: number; height: number }) => { + setCanvasSize({ width: preset.width, height: preset.height }); + }; - const handleOriginalSelect = () => { - const aspectRatio = getOriginalAspectRatio(); - setCanvasSizeToOriginal(aspectRatio); - }; + const handleOriginalSelect = () => { + const aspectRatio = getOriginalAspectRatio(); + setCanvasSizeToOriginal(aspectRatio); + }; - return ( - <div - data-toolbar - className="flex items-end justify-between gap-2 p-1 pt-2 w-full" - > - <div> - <p - className={cn( - "text-[0.75rem] text-muted-foreground flex items-center gap-1", - !hasAnyElements && "opacity-50" - )} - > - <span className="text-primary tabular-nums"> - {formatTimeCode( - currentTime, - "HH:MM:SS:FF", - activeProject?.fps || 30 - )} - </span> - <span className="opacity-50">/</span> - <span className="tabular-nums"> - {formatTimeCode( - getTotalDuration(), - "HH:MM:SS:FF", - activeProject?.fps || 30 - )} - </span> - </p> - </div> - <Button - variant="text" - size="icon" - onClick={toggle} - disabled={!hasAnyElements} - className="h-auto p-0" - > - {isPlaying ? ( - <Pause className="h-3 w-3" /> - ) : ( - <Play className="h-3 w-3" /> - )} - </Button> - <div className="flex items-center gap-3"> - <BackgroundSettings /> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - size="sm" - className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light" - disabled={!hasAnyElements} - > - {getDisplayName()} - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onClick={handleOriginalSelect} - className={cn("text-xs", isOriginal && "font-semibold")} - > - Original - </DropdownMenuItem> - <DropdownMenuSeparator /> - {canvasPresets.map((preset) => ( - <DropdownMenuItem - key={preset.name} - onClick={() => handlePresetSelect(preset)} - className={cn( - "text-xs", - currentPreset?.name === preset.name && "font-semibold" - )} - > - {preset.name} - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - <Button - variant="text" - size="icon" - className="!size-4 text-muted-foreground" - > - <Expand className="!size-4" /> - </Button> - </div> - </div> - ); + return ( + <div + data-toolbar + className="flex items-end justify-between gap-2 p-1 pt-2 w-full" + > + <div> + <p + className={cn( + "text-[0.75rem] text-muted-foreground flex items-center gap-1", + !hasAnyElements && "opacity-50", + )} + > + <span className="text-primary tabular-nums"> + {formatTimeCode( + currentTime, + "HH:MM:SS:FF", + activeProject?.fps || 30, + )} + </span> + <span className="opacity-50">/</span> + <span className="tabular-nums"> + {formatTimeCode( + getTotalDuration(), + "HH:MM:SS:FF", + activeProject?.fps || 30, + )} + </span> + </p> + </div> + <Button + variant="text" + size="icon" + onClick={toggle} + disabled={!hasAnyElements} + className="h-auto p-0" + > + {isPlaying ? ( + <Pause className="h-3 w-3" /> + ) : ( + <Play className="h-3 w-3" /> + )} + </Button> + <div className="flex items-center gap-3"> + <BackgroundSettings /> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + size="sm" + className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light" + disabled={!hasAnyElements} + > + {getDisplayName()} + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={handleOriginalSelect} + className={cn("text-xs", isOriginal && "font-semibold")} + > + Original + </DropdownMenuItem> + <DropdownMenuSeparator /> + {canvasPresets.map((preset) => ( + <DropdownMenuItem + key={preset.name} + onClick={() => handlePresetSelect(preset)} + className={cn( + "text-xs", + currentPreset?.name === preset.name && "font-semibold", + )} + > + {preset.name} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + <Button + variant="text" + size="icon" + className="!size-4 text-muted-foreground" + > + <Expand className="!size-4" /> + </Button> + </div> + </div> + ); } diff --git a/apps/web/src/components/editor/properties-panel/audio-properties.tsx b/apps/web/src/components/editor/properties-panel/audio-properties.tsx index 384f0c5e..5e2a781f 100644 --- a/apps/web/src/components/editor/properties-panel/audio-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/audio-properties.tsx @@ -1,5 +1,5 @@ -import { MediaElement } from "@/types/timeline";
-
-export function AudioProperties({ element }: { element: MediaElement }) {
- return <div className="space-y-4 p-5">Audio properties</div>;
-}
\ No newline at end of file +import { MediaElement } from "@/types/timeline"; + +export function AudioProperties({ element }: { element: MediaElement }) { + return <div className="space-y-4 p-5">Audio properties</div>; +} diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index dc0dd4b2..938ad900 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -10,104 +10,104 @@ import { AudioProperties } from "./audio-properties"; import { MediaProperties } from "./media-properties"; import { TextProperties } from "./text-properties"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "../../ui/select"; import { FPS_PRESETS } from "@/constants/timeline-constants"; export function PropertiesPanel() { - const { activeProject, updateProjectFps } = useProjectStore(); - const { getDisplayName, canvasSize } = useAspectRatio(); - const { selectedElements, tracks } = useTimelineStore(); - const { mediaItems } = useMediaStore(); + const { activeProject, updateProjectFps } = useProjectStore(); + const { getDisplayName, canvasSize } = useAspectRatio(); + const { selectedElements, tracks } = useTimelineStore(); + const { mediaItems } = useMediaStore(); - const handleFpsChange = (value: string) => { - const fps = parseFloat(value); - if (!isNaN(fps) && fps > 0) { - updateProjectFps(fps); - } - }; + const handleFpsChange = (value: string) => { + const fps = parseFloat(value); + if (!isNaN(fps) && fps > 0) { + updateProjectFps(fps); + } + }; - const emptyView = ( - <div className="space-y-4 p-5"> - {/* Media Properties */} - <div className="flex flex-col gap-3"> - <PropertyItem label="Name:" value={activeProject?.name || ""} /> - <PropertyItem label="Aspect ratio:" value={getDisplayName()} /> - <PropertyItem - label="Resolution:" - value={`${canvasSize.width} × ${canvasSize.height}`} - /> - <div className="flex justify-between items-center"> - <Label className="text-xs text-muted-foreground">Frame rate:</Label> - <Select - value={(activeProject?.fps || 30).toString()} - onValueChange={handleFpsChange} - > - <SelectTrigger className="w-32 h-6 text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {FPS_PRESETS.map(({ value, label }) => ( - <SelectItem key={value} value={value} className="text-xs"> - {label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - </div> - ); + const emptyView = ( + <div className="space-y-4 p-5"> + {/* Media Properties */} + <div className="flex flex-col gap-3"> + <PropertyItem label="Name:" value={activeProject?.name || ""} /> + <PropertyItem label="Aspect ratio:" value={getDisplayName()} /> + <PropertyItem + label="Resolution:" + value={`${canvasSize.width} × ${canvasSize.height}`} + /> + <div className="flex justify-between items-center"> + <Label className="text-xs text-muted-foreground">Frame rate:</Label> + <Select + value={(activeProject?.fps || 30).toString()} + onValueChange={handleFpsChange} + > + <SelectTrigger className="w-32 h-6 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map(({ value, label }) => ( + <SelectItem key={value} value={value} className="text-xs"> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + </div> + ); - return ( - <ScrollArea className="h-full bg-panel rounded-sm"> - {selectedElements.length > 0 - ? selectedElements.map(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); + return ( + <ScrollArea className="h-full bg-panel rounded-sm"> + {selectedElements.length > 0 + ? selectedElements.map(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); - if (element?.type === "text") { - return ( - <div key={elementId}> - <TextProperties element={element} trackId={trackId} /> - </div> - ); - } - if (element?.type === "media") { - const mediaItem = mediaItems.find( - (item) => item.id === element.mediaId - ); + if (element?.type === "text") { + return ( + <div key={elementId}> + <TextProperties element={element} trackId={trackId} /> + </div> + ); + } + if (element?.type === "media") { + const mediaItem = mediaItems.find( + (item) => item.id === element.mediaId, + ); - if (mediaItem?.type === "audio") { - return ( - <div key={elementId}> - <AudioProperties element={element} /> - </div> - ); - } + if (mediaItem?.type === "audio") { + return ( + <div key={elementId}> + <AudioProperties element={element} /> + </div> + ); + } - return ( - <div key={elementId}> - <MediaProperties element={element} /> - </div> - ); - } - return null; - }) - : emptyView} - </ScrollArea> - ); + return ( + <div key={elementId}> + <MediaProperties element={element} /> + </div> + ); + } + return null; + }) + : emptyView} + </ScrollArea> + ); } function PropertyItem({ label, value }: { label: string; value: string }) { - return ( - <div className="flex justify-between"> - <Label className="text-xs text-muted-foreground">{label}</Label> - <span className="text-xs text-right">{value}</span> - </div> - ); + return ( + <div className="flex justify-between"> + <Label className="text-xs text-muted-foreground">{label}</Label> + <span className="text-xs text-right">{value}</span> + </div> + ); } diff --git a/apps/web/src/components/editor/properties-panel/media-properties.tsx b/apps/web/src/components/editor/properties-panel/media-properties.tsx index c2c514cf..fe2d448e 100644 --- a/apps/web/src/components/editor/properties-panel/media-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/media-properties.tsx @@ -1,5 +1,5 @@ -import { MediaElement } from "@/types/timeline";
-
-export function MediaProperties({ element }: { element: MediaElement }) {
- return <div className="space-y-4 p-5">Media properties</div>;
-}
+import { MediaElement } from "@/types/timeline"; + +export function MediaProperties({ element }: { element: MediaElement }) { + return <div className="space-y-4 p-5">Media properties</div>; +} diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx index beb8c799..30f1b40c 100644 --- a/apps/web/src/components/editor/properties-panel/property-item.tsx +++ b/apps/web/src/components/editor/properties-panel/property-item.tsx @@ -1,47 +1,47 @@ -import { cn } from "@/lib/utils";
-
-interface PropertyItemProps {
- direction?: "row" | "column";
- children: React.ReactNode;
- className?: string;
-}
-
-export function PropertyItem({
- direction = "row",
- children,
- className,
-}: PropertyItemProps) {
- return (
- <div
- className={cn(
- "flex gap-2",
- direction === "row"
- ? "items-center justify-between gap-6"
- : "flex-col gap-1",
- className
- )}
- >
- {children}
- </div>
- );
-}
-
-export function PropertyItemLabel({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <label className={cn("text-xs", className)}>{children}</label>;
-}
-
-export function PropertyItemValue({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <div className={cn("flex-1", className)}>{children}</div>;
-}
+import { cn } from "@/lib/utils"; + +interface PropertyItemProps { + direction?: "row" | "column"; + children: React.ReactNode; + className?: string; +} + +export function PropertyItem({ + direction = "row", + children, + className, +}: PropertyItemProps) { + return ( + <div + className={cn( + "flex gap-2", + direction === "row" + ? "items-center justify-between gap-6" + : "flex-col gap-1", + className, + )} + > + {children} + </div> + ); +} + +export function PropertyItemLabel({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return <label className={cn("text-xs", className)}>{children}</label>; +} + +export function PropertyItemValue({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return <div className={cn("flex-1", className)}>{children}</div>; +} diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index e13dabc8..4d58752b 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -1,76 +1,76 @@ -import { Textarea } from "@/components/ui/textarea";
-import { FontPicker } from "@/components/ui/font-picker";
-import { FontFamily } from "@/constants/font-constants";
-import { TextElement } from "@/types/timeline";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { Slider } from "@/components/ui/slider";
-import { Input } from "@/components/ui/input";
-import {
- PropertyItem,
- PropertyItemLabel,
- PropertyItemValue,
-} from "./property-item";
-
-export function TextProperties({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const { updateTextElement } = useTimelineStore();
-
- return (
- <div className="space-y-6 p-5">
- <Textarea
- placeholder="Name"
- defaultValue={element.content}
- className="min-h-[4.5rem] resize-none bg-background/50"
- onChange={(e) =>
- updateTextElement(trackId, element.id, { content: e.target.value })
- }
- />
- <PropertyItem direction="row">
- <PropertyItemLabel>Font</PropertyItemLabel>
- <PropertyItemValue>
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value: FontFamily) =>
- updateTextElement(trackId, element.id, { fontFamily: value })
- }
- />
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Font size</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Slider
- defaultValue={[element.fontSize]}
- min={8}
- max={300}
- step={1}
- onValueChange={([value]) =>
- updateTextElement(trackId, element.id, { fontSize: value })
- }
- className="w-full"
- />
- <Input
- type="number"
- value={element.fontSize}
- onChange={(e) =>
- updateTextElement(trackId, element.id, {
- fontSize: parseInt(e.target.value),
- })
- }
- className="w-12 !text-xs h-7 rounded-sm text-center
- [appearance:textfield]
- [&::-webkit-outer-spin-button]:appearance-none
- [&::-webkit-inner-spin-button]:appearance-none"
- />
- </div>
- </PropertyItemValue>
- </PropertyItem>
- </div>
- );
-}
+import { Textarea } from "@/components/ui/textarea"; +import { FontPicker } from "@/components/ui/font-picker"; +import { FontFamily } from "@/constants/font-constants"; +import { TextElement } from "@/types/timeline"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { Slider } from "@/components/ui/slider"; +import { Input } from "@/components/ui/input"; +import { + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "./property-item"; + +export function TextProperties({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const { updateTextElement } = useTimelineStore(); + + return ( + <div className="space-y-6 p-5"> + <Textarea + placeholder="Name" + defaultValue={element.content} + className="min-h-[4.5rem] resize-none bg-background/50" + onChange={(e) => + updateTextElement(trackId, element.id, { content: e.target.value }) + } + /> + <PropertyItem direction="row"> + <PropertyItemLabel>Font</PropertyItemLabel> + <PropertyItemValue> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value: FontFamily) => + updateTextElement(trackId, element.id, { fontFamily: value }) + } + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Font size</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + defaultValue={[element.fontSize]} + min={8} + max={300} + step={1} + onValueChange={([value]) => + updateTextElement(trackId, element.id, { fontSize: value }) + } + className="w-full" + /> + <Input + type="number" + value={element.fontSize} + onChange={(e) => + updateTextElement(trackId, element.id, { + fontSize: parseInt(e.target.value), + }) + } + className="w-12 !text-xs h-7 rounded-sm text-center + [appearance:textfield] + [&::-webkit-outer-spin-button]:appearance-none + [&::-webkit-inner-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + </div> + ); +} diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx index 5c452f7c..69b753f4 100644 --- a/apps/web/src/components/editor/selection-box.tsx +++ b/apps/web/src/components/editor/selection-box.tsx @@ -1,58 +1,58 @@ -"use client";
-
-import { useEffect, useRef } from "react";
-
-interface SelectionBoxProps {
- startPos: { x: number; y: number } | null;
- currentPos: { x: number; y: number } | null;
- containerRef: React.RefObject<HTMLElement>;
- isActive: boolean;
-}
-
-export function SelectionBox({
- startPos,
- currentPos,
- containerRef,
- isActive,
-}: SelectionBoxProps) {
- const selectionBoxRef = useRef<HTMLDivElement>(null);
-
- useEffect(() => {
- if (!isActive || !startPos || !currentPos || !containerRef.current) return;
-
- const container = containerRef.current;
- const containerRect = container.getBoundingClientRect();
-
- // Calculate relative positions within the container
- const startX = startPos.x - containerRect.left;
- const startY = startPos.y - containerRect.top;
- const currentX = currentPos.x - containerRect.left;
- const currentY = currentPos.y - containerRect.top;
-
- // Calculate the selection rectangle bounds
- const left = Math.min(startX, currentX);
- const top = Math.min(startY, currentY);
- const width = Math.abs(currentX - startX);
- const height = Math.abs(currentY - startY);
-
- // Update the selection box position and size
- if (selectionBoxRef.current) {
- selectionBoxRef.current.style.left = `${left}px`;
- selectionBoxRef.current.style.top = `${top}px`;
- selectionBoxRef.current.style.width = `${width}px`;
- selectionBoxRef.current.style.height = `${height}px`;
- }
- }, [startPos, currentPos, isActive, containerRef]);
-
- if (!isActive || !startPos || !currentPos) return null;
-
- return (
- <div
- ref={selectionBoxRef}
- className="absolute pointer-events-none z-50"
- style={{
- backgroundColor: "hsl(var(--foreground) / 0.1)",
- }}
- />
- );
-}
+"use client"; + +import { useEffect, useRef } from "react"; + +interface SelectionBoxProps { + startPos: { x: number; y: number } | null; + currentPos: { x: number; y: number } | null; + containerRef: React.RefObject<HTMLElement>; + isActive: boolean; +} + +export function SelectionBox({ + startPos, + currentPos, + containerRef, + isActive, +}: SelectionBoxProps) { + const selectionBoxRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!isActive || !startPos || !currentPos || !containerRef.current) return; + + const container = containerRef.current; + const containerRect = container.getBoundingClientRect(); + + // Calculate relative positions within the container + const startX = startPos.x - containerRect.left; + const startY = startPos.y - containerRect.top; + const currentX = currentPos.x - containerRect.left; + const currentY = currentPos.y - containerRect.top; + + // Calculate the selection rectangle bounds + const left = Math.min(startX, currentX); + const top = Math.min(startY, currentY); + const width = Math.abs(currentX - startX); + const height = Math.abs(currentY - startY); + + // Update the selection box position and size + if (selectionBoxRef.current) { + selectionBoxRef.current.style.left = `${left}px`; + selectionBoxRef.current.style.top = `${top}px`; + selectionBoxRef.current.style.width = `${width}px`; + selectionBoxRef.current.style.height = `${height}px`; + } + }, [startPos, currentPos, isActive, containerRef]); + + if (!isActive || !startPos || !currentPos) return null; + + return ( + <div + ref={selectionBoxRef} + className="absolute pointer-events-none z-50" + style={{ + backgroundColor: "hsl(var(--foreground) / 0.1)", + }} + /> + ); +} diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx index 83a1f678..2bbe605d 100644 --- a/apps/web/src/components/editor/snap-indicator.tsx +++ b/apps/web/src/components/editor/snap-indicator.tsx @@ -5,50 +5,50 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import type { TimelineTrack } from "@/types/timeline"; interface SnapIndicatorProps { - snapPoint: SnapPoint | null; - zoomLevel: number; - isVisible: boolean; - tracks: TimelineTrack[]; - timelineRef: React.RefObject<HTMLDivElement>; - trackLabelsRef?: React.RefObject<HTMLDivElement>; + snapPoint: SnapPoint | null; + zoomLevel: number; + isVisible: boolean; + tracks: TimelineTrack[]; + timelineRef: React.RefObject<HTMLDivElement>; + trackLabelsRef?: React.RefObject<HTMLDivElement>; } export function SnapIndicator({ - snapPoint, - zoomLevel, - isVisible, - tracks, - timelineRef, - trackLabelsRef, + snapPoint, + zoomLevel, + isVisible, + tracks, + timelineRef, + trackLabelsRef, }: SnapIndicatorProps) { - if (!isVisible || !snapPoint) { - return null; - } + if (!isVisible || !snapPoint) { + return null; + } - const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; - const totalHeight = timelineContainerHeight - 8; // 8px padding from edges + const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; + const totalHeight = timelineContainerHeight - 8; // 8px padding from edges - // Get dynamic track labels width, fallback to 0 if no tracks or no ref - const trackLabelsWidth = - tracks.length > 0 && trackLabelsRef?.current - ? trackLabelsRef.current.offsetWidth - : 0; + // Get dynamic track labels width, fallback to 0 if no tracks or no ref + const trackLabelsWidth = + tracks.length > 0 && trackLabelsRef?.current + ? trackLabelsRef.current.offsetWidth + : 0; - const leftPosition = - trackLabelsWidth + - snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const leftPosition = + trackLabelsWidth + + snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - return ( - <div - className="absolute pointer-events-none z-[90]" - style={{ - left: `${leftPosition}px`, - top: 0, - height: `${totalHeight}px`, - width: "2px", - }} - > - <div className={`w-0.5 h-full bg-primary/40 opacity-80`} /> - </div> - ); + return ( + <div + className="absolute pointer-events-none z-[90]" + style={{ + left: `${leftPosition}px`, + top: 0, + height: `${totalHeight}px`, + width: "2px", + }} + > + <div className={`w-0.5 h-full bg-primary/40 opacity-80`} /> + </div> + ); } diff --git a/apps/web/src/components/editor/speed-control.tsx b/apps/web/src/components/editor/speed-control.tsx index 39281a9a..ba5a4cc6 100644 --- a/apps/web/src/components/editor/speed-control.tsx +++ b/apps/web/src/components/editor/speed-control.tsx @@ -4,43 +4,43 @@ import { Button } from "../ui/button"; import { usePlaybackStore } from "@/stores/playback-store"; const SPEED_PRESETS = [ - { label: "0.5x", value: 0.5 }, - { label: "1x", value: 1.0 }, - { label: "1.5x", value: 1.5 }, - { label: "2x", value: 2.0 }, + { label: "0.5x", value: 0.5 }, + { label: "1x", value: 1.0 }, + { label: "1.5x", value: 1.5 }, + { label: "2x", value: 2.0 }, ]; export function SpeedControl() { - const { speed, setSpeed } = usePlaybackStore(); + const { speed, setSpeed } = usePlaybackStore(); - return ( - <div className="space-y-4"> - <h3 className="text-sm font-medium">Playback Speed</h3> - <div className="space-y-4"> - <div className="flex gap-2"> - {SPEED_PRESETS.map((preset) => ( - <Button - key={preset.value} - variant={speed === preset.value ? "default" : "outline"} - className="flex-1" - onClick={() => setSpeed(preset.value)} - > - {preset.label} - </Button> - ))} - </div> - <div className="space-y-1"> - <Label>Custom ({speed.toFixed(1)}x)</Label> - <Slider - value={[speed]} - min={0.1} - max={2.0} - step={0.1} - onValueChange={(value) => setSpeed(value[0])} - className="mt-2" - /> - </div> - </div> - </div> - ); -}
\ No newline at end of file + return ( + <div className="space-y-4"> + <h3 className="text-sm font-medium">Playback Speed</h3> + <div className="space-y-4"> + <div className="flex gap-2"> + {SPEED_PRESETS.map((preset) => ( + <Button + key={preset.value} + variant={speed === preset.value ? "default" : "outline"} + className="flex-1" + onClick={() => setSpeed(preset.value)} + > + {preset.label} + </Button> + ))} + </div> + <div className="space-y-1"> + <Label>Custom ({speed.toFixed(1)}x)</Label> + <Slider + value={[speed]} + min={0.1} + max={2.0} + step={0.1} + onValueChange={(value) => setSpeed(value[0])} + className="mt-2" + /> + </div> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx index c0efc19e..e0386f1d 100644 --- a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx +++ b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler-wrapper.tsx @@ -6,19 +6,19 @@ import React, { forwardRef } from "react"; * Captures mouse events for scrubbing and ensures pointer events are properly enabled. */ const TimelineCanvasRulerWrapper = forwardRef< - HTMLDivElement, - TimelineCanvasRulerWrapperProps + HTMLDivElement, + TimelineCanvasRulerWrapperProps >(({ children, onMouseDown, className = "" }, ref) => { - return ( - <div - ref={ref} - className={`relative overflow-hidden h-5 w-full select-none cursor-pointer ${className}`} - onMouseDown={onMouseDown} - data-ruler-wrapper - > - {children} - </div> - ); + return ( + <div + ref={ref} + className={`relative overflow-hidden h-5 w-full select-none cursor-pointer ${className}`} + onMouseDown={onMouseDown} + data-ruler-wrapper + > + {children} + </div> + ); }); export default TimelineCanvasRulerWrapper; diff --git a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx index c40cdbda..c6447432 100644 --- a/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx +++ b/apps/web/src/components/editor/timeline-canvas/timeline-canvas-ruler.tsx @@ -7,72 +7,72 @@ import React, { useRef, useEffect } from "react"; * Should be wrapped by TimelineCanvasRulerWrapper for interaction handling. */ export default function TimelineCanvasRuler({ - zoomLevel, - duration, - width, - height = 20, + zoomLevel, + duration, + width, + height = 20, }: TimelineCanvasRulerProps) { - const canvasRef = useRef<HTMLCanvasElement>(null); + const canvasRef = useRef<HTMLCanvasElement>(null); - useEffect(() => { - const ctx = canvasRef.current?.getContext("2d"); - if (!ctx) return; + useEffect(() => { + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; - ctx.clearRect(0, 0, width, height); + ctx.clearRect(0, 0, width, height); - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const getTimeInterval = () => { - if (pixelsPerSecond >= 200) return 0.1; - if (pixelsPerSecond >= 100) return 0.5; - if (pixelsPerSecond >= 50) return 1; - if (pixelsPerSecond >= 25) return 2; - if (pixelsPerSecond >= 12) return 5; - if (pixelsPerSecond >= 6) return 10; - return 30; - }; + const getTimeInterval = () => { + if (pixelsPerSecond >= 200) return 0.1; + if (pixelsPerSecond >= 100) return 0.5; + if (pixelsPerSecond >= 50) return 1; + if (pixelsPerSecond >= 25) return 2; + if (pixelsPerSecond >= 12) return 5; + if (pixelsPerSecond >= 6) return 10; + return 30; + }; - const mainInterval = getTimeInterval(); - const tickPerMain = 5; - const subInterval = mainInterval / tickPerMain; + const mainInterval = getTimeInterval(); + const tickPerMain = 5; + const subInterval = mainInterval / tickPerMain; - const totalTicks = Math.ceil(duration / subInterval) + 1; + const totalTicks = Math.ceil(duration / subInterval) + 1; - for (let i = 0; i < totalTicks; i++) { - const time = i * subInterval; - const x = time * pixelsPerSecond; + for (let i = 0; i < totalTicks; i++) { + const time = i * subInterval; + const x = time * pixelsPerSecond; - const isMain = i % tickPerMain === 0; + const isMain = i % tickPerMain === 0; - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, isMain ? height * 0.3 : height * 0.1); - ctx.strokeStyle = isMain ? "#999" : "#ccc"; - ctx.lineWidth = isMain ? 1 : 0.5; - ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, isMain ? height * 0.3 : height * 0.1); + ctx.strokeStyle = isMain ? "#999" : "#ccc"; + ctx.lineWidth = isMain ? 1 : 0.5; + ctx.stroke(); - // Label - if (isMain) { - ctx.fillStyle = "#666"; - ctx.font = "10px sans-serif"; - const mins = Math.floor(time / 60); - const secs = Math.floor(time % 60); - let label = ""; - let xTranslate = 10; - if (mainInterval < 1) { - label = `${time.toFixed(1)}s`; - } else if (mins > 0) { - label = `${mins}:${secs.toString().padStart(2, "0")}`; - } else { - label = `${secs}s`; - xTranslate = 5; - } - ctx.fillText(label, x - xTranslate, height - 4); - } - } - }, [zoomLevel, duration, width, height]); + // Label + if (isMain) { + ctx.fillStyle = "#666"; + ctx.font = "10px sans-serif"; + const mins = Math.floor(time / 60); + const secs = Math.floor(time % 60); + let label = ""; + let xTranslate = 10; + if (mainInterval < 1) { + label = `${time.toFixed(1)}s`; + } else if (mins > 0) { + label = `${mins}:${secs.toString().padStart(2, "0")}`; + } else { + label = `${secs}s`; + xTranslate = 5; + } + ctx.fillText(label, x - xTranslate, height - 4); + } + } + }, [zoomLevel, duration, width, height]); - return ( - <canvas ref={canvasRef} width={width} height={height} className="block" /> - ); + return ( + <canvas ref={canvasRef} width={width} height={height} className="block" /> + ); } diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index 71b2cd26..1f157de1 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -1,470 +1,470 @@ -"use client";
-
-import { useState } from "react";
-import { Button } from "../ui/button";
-import {
- MoreVertical,
- Scissors,
- Trash2,
- SplitSquareHorizontal,
- Music,
- ChevronRight,
- ChevronLeft,
- Type,
- Copy,
- RefreshCw,
-} 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 { toast } from "sonner";
-import { TimelineElementProps, TrackType } from "@/types/timeline";
-import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
-import {
- getTrackElementClasses,
- TIMELINE_CONSTANTS,
- getTrackHeight,
-} from "@/constants/timeline-constants";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
-} from "../ui/dropdown-menu";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
-} from "../ui/context-menu";
-
-export function TimelineElement({
- element,
- track,
- zoomLevel,
- isSelected,
- onElementMouseDown,
- onElementClick,
-}: TimelineElementProps) {
- const { mediaItems } = useMediaStore();
- const {
- updateElementTrim,
- updateElementDuration,
- removeElementFromTrack,
- dragState,
- splitElement,
- splitAndKeepLeft,
- splitAndKeepRight,
- separateAudio,
- addElementToTrack,
- replaceElementMedia,
- } = useTimelineStore();
- const { currentTime } = usePlaybackStore();
-
- const [elementMenuOpen, setElementMenuOpen] = useState(false);
-
- const {
- resizing,
- isResizing,
- handleResizeStart,
- handleResizeMove,
- handleResizeEnd,
- } = useTimelineElementResize({
- element,
- track,
- zoomLevel,
- onUpdateTrim: updateElementTrim,
- onUpdateDuration: updateElementDuration,
- });
-
- const effectiveDuration =
- element.duration - element.trimStart - element.trimEnd;
- const elementWidth = Math.max(
- TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
- effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
- );
-
- // Use real-time position during drag, otherwise use stored position
- const isBeingDragged = dragState.elementId === element.id;
- const elementStartTime =
- isBeingDragged && dragState.isDragging
- ? dragState.currentTime
- : element.startTime;
- const elementLeft = elementStartTime * 50 * zoomLevel;
-
- const handleDeleteElement = () => {
- removeElementFromTrack(track.id, element.id);
- setElementMenuOpen(false);
- };
-
- const handleSplitElement = () => {
- 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 element to split");
- return;
- }
-
- const secondElementId = splitElement(track.id, element.id, currentTime);
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
- setElementMenuOpen(false);
- };
-
- const handleSplitAndKeepLeft = () => {
- 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 element");
- return;
- }
-
- splitAndKeepLeft(track.id, element.id, currentTime);
- setElementMenuOpen(false);
- };
-
- const handleSplitAndKeepRight = () => {
- 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 element");
- return;
- }
-
- splitAndKeepRight(track.id, element.id, currentTime);
- setElementMenuOpen(false);
- };
-
- const handleSeparateAudio = () => {
- if (element.type !== "media") {
- toast.error("Audio separation only available for media elements");
- return;
- }
-
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- if (!mediaItem || mediaItem.type !== "video") {
- toast.error("Audio separation only available for video elements");
- return;
- }
-
- const audioElementId = separateAudio(track.id, element.id);
- if (!audioElementId) {
- toast.error("Failed to separate audio");
- }
- setElementMenuOpen(false);
- };
-
- const canSplitAtPlayhead = () => {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- return currentTime > effectiveStart && currentTime < effectiveEnd;
- };
-
- const canSeparateAudio = () => {
- if (element.type !== "media") return false;
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- return mediaItem?.type === "video" && track.type === "media";
- };
-
- const handleElementSplitContext = () => {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const secondElementId = splitElement(track.id, element.id, currentTime);
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
- } else {
- toast.error("Playhead must be within element to split");
- }
- };
-
- const handleElementDuplicateContext = () => {
- const { id, ...elementWithoutId } = element;
- addElementToTrack(track.id, {
- ...elementWithoutId,
- name: element.name + " (copy)",
- startTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1,
- });
- };
-
- const handleElementDeleteContext = () => {
- removeElementFromTrack(track.id, element.id);
- };
-
- const handleReplaceClip = () => {
- if (element.type !== "media") {
- toast.error("Replace is only available for media clips");
- return;
- }
-
- // Create a file input to select replacement media
- 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) return;
-
- try {
- const success = await replaceElementMedia(track.id, element.id, file);
- if (success) {
- toast.success("Clip replaced successfully");
- } else {
- toast.error("Failed to replace clip");
- }
- } catch (error) {
- toast.error("Failed to replace clip");
- console.log(
- JSON.stringify({ error: "Failed to replace clip", details: error })
- );
- }
- };
- input.click();
- };
-
- const renderElementContent = () => {
- if (element.type === "text") {
- return (
- <div className="w-full h-full flex items-center justify-start pl-2">
- <span className="text-xs text-foreground/80 truncate">
- {element.content}
- </span>
- </div>
- );
- }
-
- // Render media element ->
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- if (!mediaItem) {
- return (
- <span className="text-xs text-foreground/80 truncate">
- {element.name}
- </span>
- );
- }
-
- const TILE_ASPECT_RATIO = 16 / 9;
-
- if (mediaItem.type === "image") {
- // Calculate tile size based on 16:9 aspect ratio
- const trackHeight = getTrackHeight(track.type);
- const tileHeight = trackHeight - 8; // Account for padding
- const tileWidth = tileHeight * TILE_ASPECT_RATIO;
-
- return (
- <div className="w-full h-full flex items-center justify-center">
- <div className="bg-[#004D52] py-3 w-full h-full relative">
- {/* Background with tiled images */}
- <div
- className="absolute top-3 bottom-3 left-0 right-0"
- style={{
- backgroundImage: mediaItem.url
- ? `url(${mediaItem.url})`
- : "none",
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${tileHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- }}
- aria-label={`Tiled background of ${mediaItem.name}`}
- />
- {/* Overlay with vertical borders */}
- <div
- className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
- style={{
- backgroundImage: `repeating-linear-gradient(
- to right,
- transparent 0px,
- transparent ${tileWidth - 1}px,
- rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
- rgba(255, 255, 255, 0.6) ${tileWidth}px
- )`,
- backgroundPosition: "left center",
- }}
- />
- </div>
- </div>
- );
- }
-
- const VIDEO_TILE_PADDING = 16;
- const OVERLAY_SPACE_MULTIPLIER = 1.5;
-
- if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
- const trackHeight = getTrackHeight(track.type);
- const tileHeight = trackHeight - VIDEO_TILE_PADDING;
- const tileWidth = tileHeight * TILE_ASPECT_RATIO;
-
- return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="flex-1 h-full relative overflow-hidden">
- {/* Background with tiled thumbnails */}
- <div
- className="absolute inset-0"
- style={{
- backgroundImage: mediaItem.thumbnailUrl
- ? `url(${mediaItem.thumbnailUrl})`
- : "none",
- backgroundRepeat: "repeat-x",
- backgroundSize: `${tileWidth}px ${tileHeight}px`,
- backgroundPosition: "left center",
- pointerEvents: "none",
- }}
- aria-label={`Tiled thumbnail of ${mediaItem.name}`}
- />
- {/* Overlay with vertical borders */}
- <div
- className="absolute inset-0 pointer-events-none"
- style={{
- backgroundImage: `repeating-linear-gradient(
- to right,
- transparent 0px,
- transparent ${tileWidth - 1}px,
- rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
- rgba(255, 255, 255, 0.6) ${tileWidth}px
- )`,
- backgroundPosition: "left center",
- }}
- />
- </div>
- {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
- <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
- {element.name}
- </div>
- ) : (
- <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
- {element.name}
- </span>
- )}
- </div>
- );
- }
-
- // Render audio element ->
- if (mediaItem.type === "audio") {
- return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="flex-1 min-w-0">
- <AudioWaveform
- audioUrl={mediaItem.url || ""}
- height={24}
- className="w-full"
- />
- </div>
- </div>
- );
- }
-
- return (
- <span className="text-xs text-foreground/80 truncate">
- {element.name}
- </span>
- );
- };
-
- const handleElementMouseDown = (e: React.MouseEvent) => {
- if (onElementMouseDown) {
- onElementMouseDown(e, element);
- }
- };
-
- return (
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div
- className={`absolute top-0 h-full select-none timeline-element ${
- isBeingDragged ? "z-50" : "z-10"
- }`}
- style={{
- left: `${elementLeft}px`,
- width: `${elementWidth}px`,
- }}
- data-element-id={element.id}
- data-track-id={track.id}
- onMouseMove={resizing ? handleResizeMove : undefined}
- onMouseUp={resizing ? handleResizeEnd : undefined}
- onMouseLeave={resizing ? handleResizeEnd : undefined}
- >
- <div
- className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
- track.type
- )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
- isBeingDragged ? "z-50" : "z-10"
- }`}
- onClick={(e) => onElementClick && onElementClick(e, element)}
- onMouseDown={handleElementMouseDown}
- onContextMenu={(e) =>
- onElementMouseDown && onElementMouseDown(e, element)
- }
- >
- <div className="absolute inset-0 flex items-center h-full">
- {renderElementContent()}
- </div>
-
- {isSelected && (
- <>
- <div
- className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
- onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
- />
- <div
- className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
- onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
- />
- </>
- )}
- </div>
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem onClick={handleElementSplitContext}>
- <Scissors className="h-4 w-4 mr-2" />
- Split at playhead
- </ContextMenuItem>
- <ContextMenuItem onClick={handleElementDuplicateContext}>
- <Copy className="h-4 w-4 mr-2" />
- Duplicate {element.type === "text" ? "text" : "clip"}
- </ContextMenuItem>
- {element.type === "media" && (
- <ContextMenuItem onClick={handleReplaceClip}>
- <RefreshCw className="h-4 w-4 mr-2" />
- Replace clip
- </ContextMenuItem>
- )}
- <ContextMenuSeparator />
- <ContextMenuItem
- onClick={handleElementDeleteContext}
- className="text-destructive focus:text-destructive"
- >
- <Trash2 className="h-4 w-4 mr-2" />
- Delete {element.type === "text" ? "text" : "clip"}
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- );
-}
+"use client"; + +import { useState } from "react"; +import { Button } from "../ui/button"; +import { + MoreVertical, + Scissors, + Trash2, + SplitSquareHorizontal, + Music, + ChevronRight, + ChevronLeft, + Type, + Copy, + RefreshCw, +} 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 { toast } from "sonner"; +import { TimelineElementProps, TrackType } from "@/types/timeline"; +import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; +import { + getTrackElementClasses, + TIMELINE_CONSTANTS, + getTrackHeight, +} from "@/constants/timeline-constants"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "../ui/dropdown-menu"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../ui/context-menu"; + +export function TimelineElement({ + element, + track, + zoomLevel, + isSelected, + onElementMouseDown, + onElementClick, +}: TimelineElementProps) { + const { mediaItems } = useMediaStore(); + const { + updateElementTrim, + updateElementDuration, + removeElementFromTrack, + dragState, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + addElementToTrack, + replaceElementMedia, + } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); + + const [elementMenuOpen, setElementMenuOpen] = useState(false); + + const { + resizing, + isResizing, + handleResizeStart, + handleResizeMove, + handleResizeEnd, + } = useTimelineElementResize({ + element, + track, + zoomLevel, + onUpdateTrim: updateElementTrim, + onUpdateDuration: updateElementDuration, + }); + + const effectiveDuration = + element.duration - element.trimStart - element.trimEnd; + const elementWidth = Math.max( + TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH, + effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, + ); + + // Use real-time position during drag, otherwise use stored position + const isBeingDragged = dragState.elementId === element.id; + const elementStartTime = + isBeingDragged && dragState.isDragging + ? dragState.currentTime + : element.startTime; + const elementLeft = elementStartTime * 50 * zoomLevel; + + const handleDeleteElement = () => { + removeElementFromTrack(track.id, element.id); + setElementMenuOpen(false); + }; + + const handleSplitElement = () => { + 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 element to split"); + return; + } + + const secondElementId = splitElement(track.id, element.id, currentTime); + if (!secondElementId) { + toast.error("Failed to split element"); + } + setElementMenuOpen(false); + }; + + const handleSplitAndKeepLeft = () => { + 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 element"); + return; + } + + splitAndKeepLeft(track.id, element.id, currentTime); + setElementMenuOpen(false); + }; + + const handleSplitAndKeepRight = () => { + 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 element"); + return; + } + + splitAndKeepRight(track.id, element.id, currentTime); + setElementMenuOpen(false); + }; + + const handleSeparateAudio = () => { + if (element.type !== "media") { + toast.error("Audio separation only available for media elements"); + return; + } + + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if (!mediaItem || mediaItem.type !== "video") { + toast.error("Audio separation only available for video elements"); + return; + } + + const audioElementId = separateAudio(track.id, element.id); + if (!audioElementId) { + toast.error("Failed to separate audio"); + } + setElementMenuOpen(false); + }; + + const canSplitAtPlayhead = () => { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + return currentTime > effectiveStart && currentTime < effectiveEnd; + }; + + const canSeparateAudio = () => { + if (element.type !== "media") return false; + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + return mediaItem?.type === "video" && track.type === "media"; + }; + + const handleElementSplitContext = () => { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const secondElementId = splitElement(track.id, element.id, currentTime); + if (!secondElementId) { + toast.error("Failed to split element"); + } + } else { + toast.error("Playhead must be within element to split"); + } + }; + + const handleElementDuplicateContext = () => { + const { id, ...elementWithoutId } = element; + addElementToTrack(track.id, { + ...elementWithoutId, + name: element.name + " (copy)", + startTime: + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1, + }); + }; + + const handleElementDeleteContext = () => { + removeElementFromTrack(track.id, element.id); + }; + + const handleReplaceClip = () => { + if (element.type !== "media") { + toast.error("Replace is only available for media clips"); + return; + } + + // Create a file input to select replacement media + 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) return; + + try { + const success = await replaceElementMedia(track.id, element.id, file); + if (success) { + toast.success("Clip replaced successfully"); + } else { + toast.error("Failed to replace clip"); + } + } catch (error) { + toast.error("Failed to replace clip"); + console.log( + JSON.stringify({ error: "Failed to replace clip", details: error }), + ); + } + }; + input.click(); + }; + + const renderElementContent = () => { + if (element.type === "text") { + return ( + <div className="w-full h-full flex items-center justify-start pl-2"> + <span className="text-xs text-foreground/80 truncate"> + {element.content} + </span> + </div> + ); + } + + // Render media element -> + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if (!mediaItem) { + return ( + <span className="text-xs text-foreground/80 truncate"> + {element.name} + </span> + ); + } + + const TILE_ASPECT_RATIO = 16 / 9; + + if (mediaItem.type === "image") { + // Calculate tile size based on 16:9 aspect ratio + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - 8; // Account for padding + const tileWidth = tileHeight * TILE_ASPECT_RATIO; + + return ( + <div className="w-full h-full flex items-center justify-center"> + <div className="bg-[#004D52] py-3 w-full h-full relative"> + {/* Background with tiled images */} + <div + className="absolute top-3 bottom-3 left-0 right-0" + style={{ + backgroundImage: mediaItem.url + ? `url(${mediaItem.url})` + : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + aria-label={`Tiled background of ${mediaItem.name}`} + /> + {/* Overlay with vertical borders */} + <div + className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" + style={{ + backgroundImage: `repeating-linear-gradient( + to right, + transparent 0px, + transparent ${tileWidth - 1}px, + rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, + rgba(255, 255, 255, 0.6) ${tileWidth}px + )`, + backgroundPosition: "left center", + }} + /> + </div> + </div> + ); + } + + const VIDEO_TILE_PADDING = 16; + const OVERLAY_SPACE_MULTIPLIER = 1.5; + + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - VIDEO_TILE_PADDING; + const tileWidth = tileHeight * TILE_ASPECT_RATIO; + + return ( + <div className="w-full h-full flex items-center gap-2"> + <div className="flex-1 h-full relative overflow-hidden"> + {/* Background with tiled thumbnails */} + <div + className="absolute inset-0" + style={{ + backgroundImage: mediaItem.thumbnailUrl + ? `url(${mediaItem.thumbnailUrl})` + : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + aria-label={`Tiled thumbnail of ${mediaItem.name}`} + /> + {/* Overlay with vertical borders */} + <div + className="absolute inset-0 pointer-events-none" + style={{ + backgroundImage: `repeating-linear-gradient( + to right, + transparent 0px, + transparent ${tileWidth - 1}px, + rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, + rgba(255, 255, 255, 0.6) ${tileWidth}px + )`, + backgroundPosition: "left center", + }} + /> + </div> + {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( + <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate"> + {element.name} + </div> + ) : ( + <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]"> + {element.name} + </span> + )} + </div> + ); + } + + // Render audio element -> + if (mediaItem.type === "audio") { + return ( + <div className="w-full h-full flex items-center gap-2"> + <div className="flex-1 min-w-0"> + <AudioWaveform + audioUrl={mediaItem.url || ""} + height={24} + className="w-full" + /> + </div> + </div> + ); + } + + return ( + <span className="text-xs text-foreground/80 truncate"> + {element.name} + </span> + ); + }; + + const handleElementMouseDown = (e: React.MouseEvent) => { + if (onElementMouseDown) { + onElementMouseDown(e, element); + } + }; + + return ( + <ContextMenu> + <ContextMenuTrigger asChild> + <div + className={`absolute top-0 h-full select-none timeline-element ${ + isBeingDragged ? "z-50" : "z-10" + }`} + style={{ + left: `${elementLeft}px`, + width: `${elementWidth}px`, + }} + data-element-id={element.id} + data-track-id={track.id} + onMouseMove={resizing ? handleResizeMove : undefined} + onMouseUp={resizing ? handleResizeEnd : undefined} + onMouseLeave={resizing ? handleResizeEnd : undefined} + > + <div + className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses( + track.type, + )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ + isBeingDragged ? "z-50" : "z-10" + }`} + onClick={(e) => onElementClick && onElementClick(e, element)} + onMouseDown={handleElementMouseDown} + onContextMenu={(e) => + onElementMouseDown && onElementMouseDown(e, element) + } + > + <div className="absolute inset-0 flex items-center h-full"> + {renderElementContent()} + </div> + + {isSelected && ( + <> + <div + className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" + onMouseDown={(e) => handleResizeStart(e, element.id, "left")} + /> + <div + className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50" + onMouseDown={(e) => handleResizeStart(e, element.id, "right")} + /> + </> + )} + </div> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem onClick={handleElementSplitContext}> + <Scissors className="h-4 w-4 mr-2" /> + Split at playhead + </ContextMenuItem> + <ContextMenuItem onClick={handleElementDuplicateContext}> + <Copy className="h-4 w-4 mr-2" /> + Duplicate {element.type === "text" ? "text" : "clip"} + </ContextMenuItem> + {element.type === "media" && ( + <ContextMenuItem onClick={handleReplaceClip}> + <RefreshCw className="h-4 w-4 mr-2" /> + Replace clip + </ContextMenuItem> + )} + <ContextMenuSeparator /> + <ContextMenuItem + onClick={handleElementDeleteContext} + className="text-destructive focus:text-destructive" + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete {element.type === "text" ? "text" : "clip"} + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ); +} diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx index 390abc3d..626df6e8 100644 --- a/apps/web/src/components/editor/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline-playhead.tsx @@ -1,112 +1,112 @@ -"use client";
-
-import { useRef } from "react";
-import { TimelineTrack } from "@/types/timeline";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
-
-interface TimelinePlayheadProps {
- currentTime: number;
- duration: number;
- zoomLevel: number;
- tracks: TimelineTrack[];
- seek: (time: number) => void;
- rulerRef: React.RefObject<HTMLDivElement>;
- rulerScrollRef: React.RefObject<HTMLDivElement>;
- trackLabelsRef?: React.RefObject<HTMLDivElement>;
- timelineRef: React.RefObject<HTMLDivElement>;
- playheadRef?: React.RefObject<HTMLDivElement>;
- isSnappingToPlayhead?: boolean;
-}
-
-export function TimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- tracks,
- seek,
- rulerRef,
- rulerScrollRef,
- trackLabelsRef,
- timelineRef,
- playheadRef: externalPlayheadRef,
- isSnappingToPlayhead = false,
-}: TimelinePlayheadProps) {
- const internalPlayheadRef = useRef<HTMLDivElement>(null);
- const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const { playheadPosition, handleRulerMouseDown } = useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- });
-
- // Use timeline container height minus a few pixels for breathing room
- const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
- const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
-
- // Get dynamic track labels width, fallback to 0 if no tracks or no ref
- const trackLabelsWidth =
- tracks.length > 0 && trackLabelsRef?.current
- ? trackLabelsRef.current.offsetWidth
- : 0;
- const leftPosition =
- trackLabelsWidth +
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
-
- return (
- <div
- ref={playheadRef}
- className="absolute pointer-events-auto z-[50]"
- style={{
- left: `${leftPosition}px`,
- top: 0,
- height: `${totalHeight}px`,
- width: "2px", // Slightly wider for better click target
- }}
- onMouseDown={handleRulerMouseDown}
- >
- {/* The playhead line spanning full height */}
- <div
- className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "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-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
- />
- </div>
- );
-}
-
-// Also export a hook for getting ruler handlers
-export function useTimelinePlayheadRuler({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
-}: Omit<
- TimelinePlayheadProps,
- | "tracks"
- | "trackLabelsRef"
- | "timelineRef"
- | "tracksScrollRef"
- | "playheadRef"
->) {
- const { handleRulerMouseDown } = useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- });
-
- return { handleRulerMouseDown };
-}
-
-export { TimelinePlayhead as default };
+"use client"; + +import { useRef } from "react"; +import { TimelineTrack } from "@/types/timeline"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; + +interface TimelinePlayheadProps { + currentTime: number; + duration: number; + zoomLevel: number; + tracks: TimelineTrack[]; + seek: (time: number) => void; + rulerRef: React.RefObject<HTMLDivElement>; + rulerScrollRef: React.RefObject<HTMLDivElement>; + trackLabelsRef?: React.RefObject<HTMLDivElement>; + timelineRef: React.RefObject<HTMLDivElement>; + playheadRef?: React.RefObject<HTMLDivElement>; + isSnappingToPlayhead?: boolean; +} + +export function TimelinePlayhead({ + currentTime, + duration, + zoomLevel, + tracks, + seek, + rulerRef, + rulerScrollRef, + trackLabelsRef, + timelineRef, + playheadRef: externalPlayheadRef, + isSnappingToPlayhead = false, +}: TimelinePlayheadProps) { + const internalPlayheadRef = useRef<HTMLDivElement>(null); + const playheadRef = externalPlayheadRef || internalPlayheadRef; + const { playheadPosition, handleRulerMouseDown } = useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + }); + + // Use timeline container height minus a few pixels for breathing room + const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; + const totalHeight = timelineContainerHeight - 8; // 8px padding from edges + + // Get dynamic track labels width, fallback to 0 if no tracks or no ref + const trackLabelsWidth = + tracks.length > 0 && trackLabelsRef?.current + ? trackLabelsRef.current.offsetWidth + : 0; + const leftPosition = + trackLabelsWidth + + playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + return ( + <div + ref={playheadRef} + className="absolute pointer-events-auto z-[50]" + style={{ + left: `${leftPosition}px`, + top: 0, + height: `${totalHeight}px`, + width: "2px", // Slightly wider for better click target + }} + onMouseDown={handleRulerMouseDown} + > + {/* The playhead line spanning full height */} + <div + className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "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-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`} + /> + </div> + ); +} + +// Also export a hook for getting ruler handlers +export function useTimelinePlayheadRuler({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, +}: Omit< + TimelinePlayheadProps, + | "tracks" + | "trackLabelsRef" + | "timelineRef" + | "tracksScrollRef" + | "playheadRef" +>) { + const { handleRulerMouseDown } = useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + }); + + return { handleRulerMouseDown }; +} + +export { TimelinePlayhead as default }; diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index 2aa1c2b0..e124ce36 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -1,1097 +1,1097 @@ -"use client";
-
-import { useRef, useState, useEffect } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { toast } from "sonner";
-import { TimelineElement } from "./timeline-element";
-import {
- TimelineTrack,
- sortTracksByOrder,
- ensureMainTrack,
- getMainTrack,
- canElementGoOnTrack,
-} from "@/types/timeline";
-import { usePlaybackStore } from "@/stores/playback-store";
-import type {
- TimelineElement as TimelineElementType,
- DragData,
-} from "@/types/timeline";
-import {
- snapTimeToFrame,
- TIMELINE_CONSTANTS,
-} from "@/constants/timeline-constants";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
-
-export function TimelineTrackContent({
- track,
- zoomLevel,
- onSnapPointChange,
-}: {
- track: TimelineTrack;
- zoomLevel: number;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}) {
- const { mediaItems } = useMediaStore();
- const {
- tracks,
- addTrack,
- moveElementToTrack,
- updateElementStartTime,
- addElementToTrack,
- selectedElements,
- selectElement,
- dragState,
- startDrag: startDragAction,
- updateDragTime,
- endDrag: endDragAction,
- clearSelectedElements,
- insertTrackAt,
- snappingEnabled,
- } = useTimelineStore();
-
- const { currentTime } = usePlaybackStore();
-
- // Initialize snapping hook
- const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
- snapThreshold: 10,
- enableElementSnapping: snappingEnabled,
- enablePlayheadSnapping: snappingEnabled,
- });
-
- // Helper function for drop snapping that tries both edges
- const getDropSnappedTime = (
- dropTime: number,
- elementDuration: number,
- excludeElementId?: string
- ) => {
- if (!snappingEnabled) {
- // Use frame snapping if project has FPS, otherwise use decimal snapping
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- return snapTimeToFrame(dropTime, projectFps);
- }
-
- // 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;
- }
-
- return bestSnapResult.snappedTime;
- };
-
- 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;
- } | null>(null);
-
- // Set up mouse event listeners for drag
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!timelineRef.current) return;
-
- // On first mouse move during drag, ensure the element is selected
- if (dragState.elementId && dragState.trackId) {
- const isSelected = selectedElements.some(
- (c) =>
- c.trackId === dragState.trackId &&
- c.elementId === dragState.elementId
- );
-
- if (!isSelected) {
- // Select this element (replacing other selections) since we're dragging it
- selectElement(dragState.trackId, dragState.elementId, false);
- }
- }
-
- const timelineRect = timelineRef.current.getBoundingClientRect();
- const mouseX = e.clientX - timelineRect.left;
- const mouseTime = Math.max(
- 0,
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
- );
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
-
- // Apply snapping if enabled
- let finalTime = adjustedTime;
- let snapPoint = null;
- 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;
- }
-
- finalTime = bestSnapResult.snappedTime;
- snapPoint = bestSnapResult.snapPoint;
-
- // Notify parent component about snap point change
- onSnapPointChange?.(snapPoint);
- } else {
- // Use frame snapping if project has FPS, otherwise use decimal snapping
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- finalTime = snapTimeToFrame(adjustedTime, projectFps);
-
- // Clear snap point when not snapping
- onSnapPointChange?.(null);
- }
-
- updateDragTime(finalTime);
- };
-
- const handleMouseUp = (e: MouseEvent) => {
- if (!dragState.elementId || !dragState.trackId) return;
-
- // If this track initiated the drag, we should handle the mouse up regardless of where it occurs
- const isTrackThatStartedDrag = dragState.trackId === track.id;
-
- const timelineRect = timelineRef.current?.getBoundingClientRect();
- if (!timelineRect) {
- if (isTrackThatStartedDrag) {
- updateElementStartTime(
- track.id,
- dragState.elementId,
- dragState.currentTime
- );
- endDragAction();
- // Clear snap point when drag ends
- onSnapPointChange?.(null);
- }
- return;
- }
-
- const isMouseOverThisTrack =
- e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
-
- if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
-
- const finalTime = dragState.currentTime;
-
- if (isMouseOverThisTrack) {
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const targetTrack = tracks.find((t) => t.id === track.id);
- const hasOverlap = targetTrack?.elements.some((existingElement) => {
- if (
- dragState.trackId === track.id &&
- existingElement.id === dragState.elementId
- ) {
- return false;
- }
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (dragState.trackId === track.id) {
- updateElementStartTime(track.id, dragState.elementId, finalTime);
- } else {
- moveElementToTrack(
- dragState.trackId,
- track.id,
- dragState.elementId
- );
- requestAnimationFrame(() => {
- updateElementStartTime(
- track.id,
- dragState.elementId!,
- finalTime
- );
- });
- }
- }
- }
- } else if (isTrackThatStartedDrag) {
- // Mouse is not over this track, but this track started the drag
- // This means user released over ruler/outside - update position within same track
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const hasOverlap = track.elements.some((existingElement) => {
- if (existingElement.id === dragState.elementId) {
- return false;
- }
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- updateElementStartTime(track.id, dragState.elementId, finalTime);
- }
- }
- }
-
- if (isTrackThatStartedDrag) {
- endDragAction();
- // Clear snap point when drag ends
- onSnapPointChange?.(null);
- }
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }, [
- dragState.isDragging,
- dragState.clickOffsetTime,
- dragState.elementId,
- dragState.trackId,
- dragState.currentTime,
- zoomLevel,
- tracks,
- track.id,
- updateDragTime,
- updateElementStartTime,
- moveElementToTrack,
- endDragAction,
- selectedElements,
- selectElement,
- onSnapPointChange,
- ]);
-
- const handleElementMouseDown = (
- e: React.MouseEvent,
- element: TimelineElementType
- ) => {
- setMouseDownLocation({ x: e.clientX, y: e.clientY });
-
- // Detect right-click (button 2) and handle selection without starting drag
- const isRightClick = e.button === 2;
- const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
-
- if (isRightClick) {
- // Handle right-click selection
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- // If element is not selected, select it (keep other selections if multi-select)
- if (!isSelected) {
- selectElement(track.id, element.id, isMultiSelect);
- }
- // If element is already selected, keep it selected
-
- // Don't start drag action for right-clicks
- return;
- }
-
- // Handle multi-selection for left-click with modifiers
- if (isMultiSelect) {
- selectElement(track.id, element.id, true);
- }
-
- // Calculate the offset from the left edge of the element to where the user clicked
- const elementElement = e.currentTarget as HTMLElement;
- const elementRect = elementElement.getBoundingClientRect();
- const clickOffsetX = e.clientX - elementRect.left;
- const clickOffsetTime =
- clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
-
- startDragAction(
- element.id,
- track.id,
- e.clientX,
- element.startTime,
- clickOffsetTime
- );
- };
-
- const handleElementClick = (
- e: React.MouseEvent,
- element: TimelineElementType
- ) => {
- e.stopPropagation();
-
- // Check if mouse moved significantly
- if (mouseDownLocation) {
- const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
- const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
- // If it moved more than a few pixels, consider it a drag and not a click.
- if (deltaX > 5 || deltaY > 5) {
- setMouseDownLocation(null); // Reset for next interaction
- return;
- }
- }
-
- // Skip selection logic for multi-selection (handled in mousedown)
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- return;
- }
-
- // Handle single selection
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- if (!isSelected) {
- // If element is not selected, select it (replacing other selections)
- selectElement(track.id, element.id, false);
- }
- // 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 = mediaItems.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"
- );
-
- if (!hasTimelineElement && !hasMediaItem) 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 || 30;
- const snappedTime = snapTimeToFrame(newStartTime, 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
- updateElementStartTime(track.id, elementId, finalStartTime);
- } else {
- // Moving to different track
- moveElementToTrack(fromTrackId, track.id, elementId);
- requestAnimationFrame(() => {
- 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, {
- type: "text",
- name: dragData.name || "Text",
- content: dragData.content || "Default Text",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: textSnappedTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: 48,
- fontFamily: "Arial",
- color: "#ffffff",
- backgroundColor: "transparent",
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- x: 0,
- y: 0,
- rotation: 0,
- opacity: 1,
- });
- } else {
- // Handle media items
- const mediaItem = mediaItems.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("media", track.type)
- : isAudio
- ? canElementGoOnTrack("media", 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,
- });
- }
- }
- } catch (error) {
- console.error("Error handling drop:", error);
- toast.error("Failed to add media to track");
- }
- };
-
- return (
- <div
- className="w-full h-full hover:bg-muted/20"
- onClick={(e) => {
- // If clicking empty area (not on an element), deselect all elements
- if (!(e.target as HTMLElement).closest(".timeline-element")) {
- clearSelectedElements();
- }
- }}
- onDragOver={handleTrackDragOver}
- onDragEnter={handleTrackDragEnter}
- onDragLeave={handleTrackDragLeave}
- onDrop={handleTrackDrop}
- >
- <div
- ref={timelineRef}
- className="h-full relative track-elements-container min-w-full"
- >
- {track.elements.length === 0 ? (
- <div
- className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
- isDropping
- ? 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
- ? wouldOverlap
- ? "Cannot drop - would overlap"
- : "Drop element here"
- : ""}
- </div>
- ) : (
- <>
- {track.elements.map((element) => {
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- const handleElementSplit = () => {
- const { currentTime } = usePlaybackStore();
- const { splitElement } = useTimelineStore();
- const splitTime = currentTime;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (splitTime > effectiveStart && splitTime < effectiveEnd) {
- const secondElementId = splitElement(
- track.id,
- element.id,
- splitTime
- );
- if (!secondElementId) {
- toast.error("Failed to split element");
- }
- } 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 { removeElementFromTrack } = useTimelineStore.getState();
- removeElementFromTrack(track.id, element.id);
- };
-
- return (
- <TimelineElement
- key={element.id}
- element={element}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onElementMouseDown={handleElementMouseDown}
- onElementClick={handleElementClick}
- />
- );
- })}
- </>
- )}
- </div>
- </div>
- );
-}
+"use client"; + +import { useRef, useState, useEffect } from "react"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { toast } from "sonner"; +import { TimelineElement } from "./timeline-element"; +import { + TimelineTrack, + sortTracksByOrder, + ensureMainTrack, + getMainTrack, + canElementGoOnTrack, +} from "@/types/timeline"; +import { usePlaybackStore } from "@/stores/playback-store"; +import type { + TimelineElement as TimelineElementType, + DragData, +} from "@/types/timeline"; +import { + snapTimeToFrame, + TIMELINE_CONSTANTS, +} from "@/constants/timeline-constants"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping"; + +export function TimelineTrackContent({ + track, + zoomLevel, + onSnapPointChange, +}: { + track: TimelineTrack; + zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +}) { + const { mediaItems } = useMediaStore(); + const { + tracks, + addTrack, + moveElementToTrack, + updateElementStartTime, + addElementToTrack, + selectedElements, + selectElement, + dragState, + startDrag: startDragAction, + updateDragTime, + endDrag: endDragAction, + clearSelectedElements, + insertTrackAt, + snappingEnabled, + } = useTimelineStore(); + + const { currentTime } = usePlaybackStore(); + + // Initialize snapping hook + const { snapElementPosition, snapElementEdge } = useTimelineSnapping({ + snapThreshold: 10, + enableElementSnapping: snappingEnabled, + enablePlayheadSnapping: snappingEnabled, + }); + + // Helper function for drop snapping that tries both edges + const getDropSnappedTime = ( + dropTime: number, + elementDuration: number, + excludeElementId?: string, + ) => { + if (!snappingEnabled) { + // Use frame snapping if project has FPS, otherwise use decimal snapping + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + return snapTimeToFrame(dropTime, projectFps); + } + + // 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; + } + + return bestSnapResult.snappedTime; + }; + + 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; + } | null>(null); + + // Set up mouse event listeners for drag + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!timelineRef.current) return; + + // On first mouse move during drag, ensure the element is selected + if (dragState.elementId && dragState.trackId) { + const isSelected = selectedElements.some( + (c) => + c.trackId === dragState.trackId && + c.elementId === dragState.elementId, + ); + + if (!isSelected) { + // Select this element (replacing other selections) since we're dragging it + selectElement(dragState.trackId, dragState.elementId, false); + } + } + + const timelineRect = timelineRef.current.getBoundingClientRect(); + const mouseX = e.clientX - timelineRect.left; + const mouseTime = Math.max( + 0, + mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + + // Apply snapping if enabled + let finalTime = adjustedTime; + let snapPoint = null; + 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; + } + + finalTime = bestSnapResult.snappedTime; + snapPoint = bestSnapResult.snapPoint; + + // Notify parent component about snap point change + onSnapPointChange?.(snapPoint); + } else { + // Use frame snapping if project has FPS, otherwise use decimal snapping + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + finalTime = snapTimeToFrame(adjustedTime, projectFps); + + // Clear snap point when not snapping + onSnapPointChange?.(null); + } + + updateDragTime(finalTime); + }; + + const handleMouseUp = (e: MouseEvent) => { + if (!dragState.elementId || !dragState.trackId) return; + + // If this track initiated the drag, we should handle the mouse up regardless of where it occurs + const isTrackThatStartedDrag = dragState.trackId === track.id; + + const timelineRect = timelineRef.current?.getBoundingClientRect(); + if (!timelineRect) { + if (isTrackThatStartedDrag) { + updateElementStartTime( + track.id, + dragState.elementId, + dragState.currentTime, + ); + endDragAction(); + // Clear snap point when drag ends + onSnapPointChange?.(null); + } + return; + } + + const isMouseOverThisTrack = + e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom; + + if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return; + + const finalTime = dragState.currentTime; + + if (isMouseOverThisTrack) { + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const movingElement = sourceTrack?.elements.find( + (c) => c.id === dragState.elementId, + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const movingElementEnd = finalTime + movingElementDuration; + + const targetTrack = tracks.find((t) => t.id === track.id); + const hasOverlap = targetTrack?.elements.some((existingElement) => { + if ( + dragState.trackId === track.id && + existingElement.id === dragState.elementId + ) { + return false; + } + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return finalTime < existingEnd && movingElementEnd > existingStart; + }); + + if (!hasOverlap) { + if (dragState.trackId === track.id) { + updateElementStartTime(track.id, dragState.elementId, finalTime); + } else { + moveElementToTrack( + dragState.trackId, + track.id, + dragState.elementId, + ); + requestAnimationFrame(() => { + updateElementStartTime( + track.id, + dragState.elementId!, + finalTime, + ); + }); + } + } + } + } else if (isTrackThatStartedDrag) { + // Mouse is not over this track, but this track started the drag + // This means user released over ruler/outside - update position within same track + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const movingElement = sourceTrack?.elements.find( + (c) => c.id === dragState.elementId, + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const movingElementEnd = finalTime + movingElementDuration; + + const hasOverlap = track.elements.some((existingElement) => { + if (existingElement.id === dragState.elementId) { + return false; + } + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return finalTime < existingEnd && movingElementEnd > existingStart; + }); + + if (!hasOverlap) { + updateElementStartTime(track.id, dragState.elementId, finalTime); + } + } + } + + if (isTrackThatStartedDrag) { + endDragAction(); + // Clear snap point when drag ends + onSnapPointChange?.(null); + } + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [ + dragState.isDragging, + dragState.clickOffsetTime, + dragState.elementId, + dragState.trackId, + dragState.currentTime, + zoomLevel, + tracks, + track.id, + updateDragTime, + updateElementStartTime, + moveElementToTrack, + endDragAction, + selectedElements, + selectElement, + onSnapPointChange, + ]); + + const handleElementMouseDown = ( + e: React.MouseEvent, + element: TimelineElementType, + ) => { + setMouseDownLocation({ x: e.clientX, y: e.clientY }); + + // Detect right-click (button 2) and handle selection without starting drag + const isRightClick = e.button === 2; + const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey; + + if (isRightClick) { + // Handle right-click selection + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id, + ); + + // If element is not selected, select it (keep other selections if multi-select) + if (!isSelected) { + selectElement(track.id, element.id, isMultiSelect); + } + // If element is already selected, keep it selected + + // Don't start drag action for right-clicks + return; + } + + // Handle multi-selection for left-click with modifiers + if (isMultiSelect) { + selectElement(track.id, element.id, true); + } + + // Calculate the offset from the left edge of the element to where the user clicked + const elementElement = e.currentTarget as HTMLElement; + const elementRect = elementElement.getBoundingClientRect(); + const clickOffsetX = e.clientX - elementRect.left; + const clickOffsetTime = + clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); + + startDragAction( + element.id, + track.id, + e.clientX, + element.startTime, + clickOffsetTime, + ); + }; + + const handleElementClick = ( + e: React.MouseEvent, + element: TimelineElementType, + ) => { + e.stopPropagation(); + + // Check if mouse moved significantly + if (mouseDownLocation) { + const deltaX = Math.abs(e.clientX - mouseDownLocation.x); + const deltaY = Math.abs(e.clientY - mouseDownLocation.y); + // If it moved more than a few pixels, consider it a drag and not a click. + if (deltaX > 5 || deltaY > 5) { + setMouseDownLocation(null); // Reset for next interaction + return; + } + } + + // Skip selection logic for multi-selection (handled in mousedown) + if (e.metaKey || e.ctrlKey || e.shiftKey) { + return; + } + + // Handle single selection + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id, + ); + + if (!isSelected) { + // If element is not selected, select it (replacing other selections) + selectElement(track.id, element.id, false); + } + // 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 = mediaItems.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", + ); + + if (!hasTimelineElement && !hasMediaItem) 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 || 30; + const snappedTime = snapTimeToFrame(newStartTime, 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 + updateElementStartTime(track.id, elementId, finalStartTime); + } else { + // Moving to different track + moveElementToTrack(fromTrackId, track.id, elementId); + requestAnimationFrame(() => { + 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, { + type: "text", + name: dragData.name || "Text", + content: dragData.content || "Default Text", + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: textSnappedTime, + trimStart: 0, + trimEnd: 0, + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + x: 0, + y: 0, + rotation: 0, + opacity: 1, + }); + } else { + // Handle media items + const mediaItem = mediaItems.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("media", track.type) + : isAudio + ? canElementGoOnTrack("media", 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, + }); + } + } + } catch (error) { + console.error("Error handling drop:", error); + toast.error("Failed to add media to track"); + } + }; + + return ( + <div + className="w-full h-full hover:bg-muted/20" + onClick={(e) => { + // If clicking empty area (not on an element), deselect all elements + if (!(e.target as HTMLElement).closest(".timeline-element")) { + clearSelectedElements(); + } + }} + onDragOver={handleTrackDragOver} + onDragEnter={handleTrackDragEnter} + onDragLeave={handleTrackDragLeave} + onDrop={handleTrackDrop} + > + <div + ref={timelineRef} + className="h-full relative track-elements-container min-w-full" + > + {track.elements.length === 0 ? ( + <div + className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${ + isDropping + ? 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 + ? wouldOverlap + ? "Cannot drop - would overlap" + : "Drop element here" + : ""} + </div> + ) : ( + <> + {track.elements.map((element) => { + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id, + ); + + const handleElementSplit = () => { + const { currentTime } = usePlaybackStore(); + const { splitElement } = useTimelineStore(); + const splitTime = currentTime; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (splitTime > effectiveStart && splitTime < effectiveEnd) { + const secondElementId = splitElement( + track.id, + element.id, + splitTime, + ); + if (!secondElementId) { + toast.error("Failed to split element"); + } + } 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 { removeElementFromTrack } = useTimelineStore.getState(); + removeElementFromTrack(track.id, element.id); + }; + + return ( + <TimelineElement + key={element.id} + element={element} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onElementMouseDown={handleElementMouseDown} + onElementClick={handleElementClick} + /> + ); + })} + </> + )} + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 5130c753..9ebe69cb 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1,948 +1,949 @@ -"use client";
-
-import { ScrollArea } from "../ui/scroll-area";
-import { Button } from "../ui/button";
-import {
- Scissors,
- ArrowLeftToLine,
- ArrowRightToLine,
- Trash2,
- Snowflake,
- Copy,
- SplitSquareHorizontal,
- Pause,
- Play,
- Video,
- Music,
- TypeIcon,
- Lock,
- ZoomIn,
- ZoomOut,
- LockOpen,
-} from "lucide-react";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- TooltipProvider,
-} from "../ui/tooltip";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuTrigger,
-} from "../ui/context-menu";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
-import { processMediaFiles } from "@/lib/media-processing";
-import { toast } from "sonner";
-import { useState, useRef, useEffect, useCallback } from "react";
-import { TimelineTrackContent } from "./timeline-track";
-import {
- TimelinePlayhead,
- useTimelinePlayheadRuler,
-} from "./timeline-playhead";
-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 type { DragData, TimelineTrack } from "@/types/timeline";
-import {
- getTrackHeight,
- getCumulativeHeightBefore,
- getTotalTracksHeight,
- TIMELINE_CONSTANTS,
- snapTimeToFrame,
-} from "@/constants/timeline-constants";
-import { Slider } from "../ui/slider";
-import TimelineCanvasRuler from "./timeline-canvas/timeline-canvas-ruler";
-import TimelineCanvasRulerWrapper from "./timeline-canvas/timeline-canvas-ruler-wrapper";
-
-export function Timeline() {
- // Timeline shows all tracks (video, audio, effects) and their elements.
- // You can drag media here to add it to your project.
- // elements can be trimmed, deleted, and moved.
-
- const {
- tracks,
- addTrack,
- addElementToTrack,
- removeElementFromTrack,
- getTotalDuration,
- selectedElements,
- clearSelectedElements,
- setSelectedElements,
- splitElement,
- splitAndKeepLeft,
- splitAndKeepRight,
- toggleTrackMute,
- separateAudio,
- undo,
- redo,
- snappingEnabled,
- toggleSnapping,
- dragState,
- } = useTimelineStore();
- const { mediaItems, addMediaItem } = useMediaStore();
- const { activeProject } = useProjectStore();
- const { currentTime, duration, seek, setDuration, isPlaying, toggle } =
- usePlaybackStore();
- const [isDragOver, setIsDragOver] = useState(false);
- const [isProcessing, setIsProcessing] = useState(false);
- const [progress, setProgress] = useState(0);
- const dragCounterRef = useRef(0);
- const timelineRef = useRef<HTMLDivElement>(null);
- const rulerRef = useRef<HTMLDivElement>(null);
- const [isInTimeline, setIsInTimeline] = useState(false);
-
- // Timeline zoom functionality
- const {
- zoomLevel,
- zoomStep,
- handleChangeZoomLevel,
- handleChangeZoomStep,
- handleWheel,
- } = useTimelineZoom();
-
- // Old marquee selection removed - using new SelectionBox component instead
-
- // Dynamic timeline width calculation based on playhead position and duration
- const dynamicTimelineWidth = Math.max(
- (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration
- (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer
- timelineRef.current?.clientWidth || 1000 // Minimum width
- );
-
- // Scroll synchronization and auto-scroll to playhead
- const rulerScrollRef = useRef<HTMLDivElement>(null);
- const trackLabelsRef = useRef<HTMLDivElement>(null);
- const playheadRef = useRef<HTMLDivElement>(null);
- const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
- const isUpdatingRef = useRef(false);
- const lastRulerSync = useRef(0);
- const lastTracksSync = useRef(0);
- const lastVerticalSync = useRef(0);
-
- // Timeline playhead ruler handlers
- const { handleRulerMouseDown } = useTimelinePlayheadRuler({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- });
-
- // Selection box functionality
- const tracksContainerRef = useRef<HTMLDivElement>(null);
- const {
- selectionBox,
- handleMouseDown: handleSelectionMouseDown,
- isSelecting,
- justFinishedSelecting,
- } = useSelectionBox({
- containerRef: tracksContainerRef,
- playheadRef,
- onSelectionComplete: (elements) => {
- console.log(JSON.stringify({ onSelectionComplete: elements.length }));
- setSelectedElements(elements);
- },
- });
-
- // Calculate snap indicator state
- const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
- null
- );
- const showSnapIndicator =
- dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
-
- // Callback to handle snap point changes from TimelineTrackContent
- const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
- setCurrentSnapPoint(snapPoint);
- }, []);
-
- // Timeline content click to seek handler
- const handleTimelineContentClick = useCallback(
- (e: React.MouseEvent) => {
- console.log(
- JSON.stringify({
- timelineClick: {
- isSelecting,
- justFinishedSelecting,
- willReturn: isSelecting || justFinishedSelecting,
- },
- })
- );
-
- // Don't seek if this was a selection box operation
- if (isSelecting || justFinishedSelecting) {
- return;
- }
-
- // Don't seek if clicking on timeline elements, but still deselect
- if ((e.target as HTMLElement).closest(".timeline-element")) {
- return;
- }
-
- // Don't seek if clicking on playhead
- if (playheadRef.current?.contains(e.target as Node)) {
- return;
- }
-
- // Don't seek if clicking on track labels
- if ((e.target as HTMLElement).closest("[data-track-labels]")) {
- clearSelectedElements();
- return;
- }
-
- // Clear selected elements when clicking empty timeline area
- console.log(JSON.stringify({ clearingSelectedElements: true }));
- clearSelectedElements();
-
- // Determine if we're clicking in ruler or tracks area
- const isTracksAreaClick = (e.target as HTMLElement).closest(
- "[data-tracks-area]"
- );
-
- let mouseX: number;
- let scrollLeft = 0;
-
- if (isTracksAreaClick) {
- // Calculate based on ruler position
- const rulerContent = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- if (!rulerContent) return;
- const rect = rulerContent.getBoundingClientRect();
- mouseX = e.clientX - rect.left;
- scrollLeft = rulerContent.scrollLeft;
- } else {
- return;
- }
-
- const rawTime = Math.max(
- 0,
- Math.min(
- duration,
- (mouseX + scrollLeft) /
- (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
- )
- );
-
- // Use frame snapping for timeline clicking
- const projectFps = activeProject?.fps || 30;
- const time = snapTimeToFrame(rawTime, projectFps);
-
- seek(time);
- },
- [
- duration,
- zoomLevel,
- seek,
- rulerScrollRef,
- clearSelectedElements,
- isSelecting,
- justFinishedSelecting,
- ]
- );
-
- // Update timeline duration when tracks change
- useEffect(() => {
- const totalDuration = getTotalDuration();
- setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline
- }, [tracks, setDuration, getTotalDuration]);
-
- // Old marquee system removed - using new SelectionBox component instead
-
- const handleDragEnter = (e: React.DragEvent) => {
- // When something is dragged over the timeline, show overlay
- e.preventDefault();
- // Don't show overlay for timeline elements - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-element")) {
- return;
- }
- dragCounterRef.current += 1;
- if (!isDragOver) {
- setIsDragOver(true);
- }
- };
-
- const handleDragOver = (e: React.DragEvent) => {
- e.preventDefault();
- };
-
- const handleDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Don't update state for timeline elements - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-element")) {
- return;
- }
-
- dragCounterRef.current -= 1;
- if (dragCounterRef.current === 0) {
- setIsDragOver(false);
- }
- };
-
- const handleDrop = async (e: React.DragEvent) => {
- // When media is dropped, add it as a new track/element
- e.preventDefault();
- setIsDragOver(false);
- dragCounterRef.current = 0;
-
- // Ignore timeline element drags - they're handled by track-specific handlers
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- if (hasTimelineElement) {
- return;
- }
-
- const itemData = e.dataTransfer.getData("application/x-media-item");
- if (itemData) {
- try {
- const dragData: DragData = JSON.parse(itemData);
-
- if (dragData.type === "text") {
- // Always create new text track to avoid overlaps
- useTimelineStore.getState().addTextToNewTrack(dragData);
- } else {
- // Handle media items
- const mediaItem = mediaItems.find(
- (item: any) => item.id === dragData.id
- );
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
-
- useTimelineStore.getState().addMediaToNewTrack(mediaItem);
- }
- } catch (error) {
- console.error("Error parsing dropped item data:", error);
- toast.error("Failed to add item to timeline");
- }
- } else if (e.dataTransfer.files?.length > 0) {
- // Handle file drops by creating new tracks
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- setIsProcessing(true);
- setProgress(0);
- try {
- const processedItems = await processMediaFiles(
- e.dataTransfer.files,
- (p) => setProgress(p)
- );
- for (const processedItem of processedItems) {
- await addMediaItem(activeProject.id, processedItem);
- const currentMediaItems = useMediaStore.getState().mediaItems;
- const addedItem = currentMediaItems.find(
- (item) =>
- item.name === processedItem.name && item.url === processedItem.url
- );
- if (addedItem) {
- useTimelineStore.getState().addMediaToNewTrack(addedItem);
- }
- }
- } catch (error) {
- // Show error if file processing fails
- console.error("Error processing external files:", error);
- toast.error("Failed to process dropped files");
- } finally {
- setIsProcessing(false);
- setProgress(0);
- }
- }
- };
-
- const dragProps = {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- };
-
- // Action handlers for toolbar
- const handleSplitSelected = () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- let splitCount = 0;
- selectedElements.forEach(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (element && track) {
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const newElementId = splitElement(trackId, elementId, currentTime);
- if (newElementId) splitCount++;
- }
- }
- });
- if (splitCount === 0) {
- toast.error("Playhead must be within selected elements to split");
- }
- };
-
- const handleDuplicateSelected = () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- const canDuplicate = selectedElements.length === 1;
- if (!canDuplicate) return;
-
- const newSelections: { trackId: string; elementId: string }[] = [];
-
- selectedElements.forEach(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((el) => el.id === elementId);
-
- if (element) {
- const newStartTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1;
-
- // Create element without id (will be generated by store)
- const { id, ...elementWithoutId } = element;
-
- addElementToTrack(trackId, {
- ...elementWithoutId,
- startTime: newStartTime,
- });
-
- // We can't predict the new id, so just clear selection for now
- // TODO: addElementToTrack could return the new element id
- }
- });
-
- clearSelectedElements();
- };
-
- const handleFreezeSelected = () => {
- toast.info("Freeze frame functionality coming soon!");
- };
-
- const handleSplitAndKeepLeft = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
- splitAndKeepLeft(trackId, elementId, currentTime);
- };
-
- const handleSplitAndKeepRight = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
- toast.error("Playhead must be within selected element");
- return;
- }
- splitAndKeepRight(trackId, elementId, currentTime);
- };
-
- const handleSeparateAudio = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one media element to separate audio");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- if (!track || track.type !== "media") {
- toast.error("Select a media element to separate audio");
- return;
- }
- separateAudio(trackId, elementId);
- };
-
- const handleDeleteSelected = () => {
- if (selectedElements.length === 0) {
- toast.error("No elements selected");
- return;
- }
- selectedElements.forEach(({ trackId, elementId }) => {
- removeElementFromTrack(trackId, elementId);
- });
- clearSelectedElements();
- };
-
- // --- Scroll synchronization effect ---
- useEffect(() => {
- const rulerViewport = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
-
- if (!rulerViewport) return;
-
- // Horizontal scroll synchronization between ruler and tracks
- const handleRulerScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
- lastRulerSync.current = now;
- isUpdatingRef.current = true;
- isUpdatingRef.current = false;
- };
- const handleTracksScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
- lastTracksSync.current = now;
- isUpdatingRef.current = true;
- isUpdatingRef.current = false;
- };
-
- rulerViewport.addEventListener("scroll", handleRulerScroll);
-
- // Vertical scroll synchronization between track labels and tracks content
- if (trackLabelsViewport) {
- const handleTrackLabelsScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
- return;
- lastVerticalSync.current = now;
- isUpdatingRef.current = true;
- isUpdatingRef.current = false;
- };
- const handleTracksVerticalScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
- return;
- lastVerticalSync.current = now;
- isUpdatingRef.current = true;
- isUpdatingRef.current = false;
- };
-
- trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
-
- return () => {
- rulerViewport.removeEventListener("scroll", handleRulerScroll);
- trackLabelsViewport.removeEventListener(
- "scroll",
- handleTrackLabelsScroll
- );
- };
- }
-
- return () => {
- rulerViewport.removeEventListener("scroll", handleRulerScroll);
- };
- }, []);
-
- return (
- <div
- className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`}
- {...dragProps}
- onMouseEnter={() => setIsInTimeline(true)}
- onMouseLeave={() => setIsInTimeline(false)}
- >
- {/* Toolbar */}
- <div className="border-b flex items-center justify-between px-2 py-1">
- <div className="flex items-center gap-1 w-full">
- <TooltipProvider delayDuration={500}>
- {/* Play/Pause Button */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={toggle}
- className="mr-2"
- >
- {isPlaying ? (
- <Pause className="h-4 w-4" />
- ) : (
- <Play className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {isPlaying ? "Pause (Space)" : "Play (Space)"}
- </TooltipContent>
- </Tooltip>
- <div className="w-px h-6 bg-border mx-1" />
- {/* Time Display */}
- <div
- className="text-xs text-muted-foreground font-mono px-2"
- style={{ minWidth: "18ch", textAlign: "center" }}
- >
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
- </div>
- {/* Test Clip Button - for debugging */}
- {tracks.length === 0 && (
- <>
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const trackId = addTrack("media");
- addElementToTrack(trackId, {
- type: "media",
- mediaId: "test",
- name: "Test Clip",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- }}
- className="text-xs"
- >
- Add Test Clip
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- Add a test clip to try playback
- </TooltipContent>
- </Tooltip>
- </>
- )}
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitSelected}
- >
- <Scissors className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split element (Ctrl+S)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepLeft}
- >
- <ArrowLeftToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepRight}
- >
- <ArrowRightToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSeparateAudio}
- >
- <SplitSquareHorizontal className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleDuplicateSelected}
- >
- <Copy className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleFreezeSelected}
- >
- <Snowflake className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Freeze frame (F)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleDeleteSelected}
- >
- <Trash2 className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Delete element (Delete)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- className="ml-auto"
- variant="text"
- size="icon"
- onClick={() => handleChangeZoomLevel(zoomLevel - 0.15)}
- >
- <ZoomOut className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Zoom Out</TooltipContent>
- </Tooltip>
- <Slider
- className="max-w-24"
- max={TIMELINE_CONSTANTS.MAX_ZOOM_STEP}
- min={0}
- step={1}
- value={[zoomStep]}
- onValueChange={(value) => handleChangeZoomStep(value[0])}
- />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={() => handleChangeZoomLevel(zoomLevel + 0.15)}
- >
- <ZoomIn className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Zoom In</TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- <div className="flex items-center gap-1">
- <TooltipProvider delayDuration={500}>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={toggleSnapping}>
- {snappingEnabled ? (
- <Lock className="h-4 w-4" />
- ) : (
- <LockOpen className="h-4 w-4 text-primary" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>Auto snapping</TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </div>
-
- {/* Timeline Container */}
- <div
- className="flex-1 flex flex-col overflow-hidden relative"
- ref={timelineRef}
- >
- <TimelinePlayhead
- currentTime={currentTime}
- duration={duration}
- zoomLevel={zoomLevel}
- tracks={tracks}
- seek={seek}
- rulerRef={rulerRef}
- rulerScrollRef={rulerScrollRef}
- trackLabelsRef={trackLabelsRef}
- timelineRef={timelineRef}
- playheadRef={playheadRef}
- isSnappingToPlayhead={
- showSnapIndicator && currentSnapPoint?.type === "playhead"
- }
- />
- <SnapIndicator
- snapPoint={currentSnapPoint}
- zoomLevel={zoomLevel}
- tracks={tracks}
- timelineRef={timelineRef}
- trackLabelsRef={trackLabelsRef}
- isVisible={showSnapIndicator}
- />
- {/* Timeline Header with Ruler */}
- <div className="flex bg-panel sticky top-0 z-10">
- <div className="w-48 flex-shrink-0 bg-muted/30 border-r h-5 px-3" />
-
- <div className="flex-1 overflow-hidden h-5">
- <ScrollArea className="w-full" ref={rulerScrollRef}>
- <TimelineCanvasRulerWrapper
- ref={rulerRef}
- onMouseDown={handleRulerMouseDown}
- >
- <TimelineCanvasRuler
- zoomLevel={zoomLevel}
- duration={duration}
- width={dynamicTimelineWidth}
- />
- </TimelineCanvasRulerWrapper>
- </ScrollArea>
- </div>
- </div>
-
- {/* Tracks Area */}
- <ScrollArea className="w-full h-full">
- <div
- className="flex-1 flex overflow-hidden overflow-y-auto"
- data-tracks-area
- >
- {/* Track Labels */}
- {tracks.length > 0 && (
- <div
- ref={trackLabelsRef}
- className="w-48 flex-shrink-0 border-r bg-panel-accent "
- data-track-labels
- >
- <div className="flex flex-col gap-1" ref={trackLabelsScrollRef}>
- {tracks.map((track) => (
- <div
- key={track.id}
- className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
- style={{ height: `${getTrackHeight(track.type)}px` }}
- >
- <div className="flex items-center flex-1 min-w-0">
- <TrackIcon track={track} />
- </div>
- {track.muted && (
- <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
- Muted
- </span>
- )}
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* Timeline Tracks Content */}
- <div
- className="flex-1 relative overflow-hidden"
- onWheel={handleWheel}
- onMouseDown={handleSelectionMouseDown}
- onClick={handleTimelineContentClick}
- ref={tracksContainerRef}
- >
- <SelectionBox
- startPos={selectionBox?.startPos || null}
- currentPos={selectionBox?.currentPos || null}
- containerRef={tracksContainerRef}
- isActive={selectionBox?.isActive || false}
- />
- <div
- className="relative flex-1"
- style={{
- height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`,
- width: `${dynamicTimelineWidth}px`,
- }}
- >
- {tracks.length === 0 ? (
- <div></div>
- ) : (
- <>
- {tracks.map((track, index) => (
- <ContextMenu key={track.id}>
- <ContextMenuTrigger asChild>
- <div
- className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]"
- style={{
- top: `${getCumulativeHeightBefore(tracks, index)}px`,
- height: `${getTrackHeight(track.type)}px`,
- }}
- onClick={(e) => {
- // If clicking empty area (not on a element), deselect all elements
- if (
- !(e.target as HTMLElement).closest(
- ".timeline-element"
- )
- ) {
- clearSelectedElements();
- }
- }}
- >
- <TimelineTrackContent
- track={track}
- zoomLevel={zoomLevel}
- onSnapPointChange={handleSnapPointChange}
- />
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem
- onClick={() => toggleTrackMute(track.id)}
- >
- {track.muted ? "Unmute Track" : "Mute Track"}
- </ContextMenuItem>
- <ContextMenuItem>
- Track settings (soon)
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- ))}
- </>
- )}
- </div>
- </div>
- </div>
- </ScrollArea>
- </div>
- </div>
- );
-}
-
-function TrackIcon({ track }: { track: TimelineTrack }) {
- return (
- <>
- {track.type === "media" && (
- <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
- )}
- {track.type === "text" && (
- <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
- )}
- {track.type === "audio" && (
- <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
- )}
- </>
- );
-}
+"use client"; + +import { ScrollArea } from "../ui/scroll-area"; +import { Button } from "../ui/button"; +import { + Scissors, + ArrowLeftToLine, + ArrowRightToLine, + Trash2, + Snowflake, + Copy, + SplitSquareHorizontal, + Pause, + Play, + Video, + Music, + TypeIcon, + Lock, + ZoomIn, + ZoomOut, + LockOpen, +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, +} from "../ui/tooltip"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "../ui/context-menu"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; +import { processMediaFiles } from "@/lib/media-processing"; +import { toast } from "sonner"; +import { useState, useRef, useEffect, useCallback } from "react"; +import { TimelineTrackContent } from "./timeline-track"; +import { + TimelinePlayhead, + useTimelinePlayheadRuler, +} from "./timeline-playhead"; +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 type { DragData, TimelineTrack } from "@/types/timeline"; +import { + getTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, + TIMELINE_CONSTANTS, + snapTimeToFrame, +} from "@/constants/timeline-constants"; +import { Slider } from "../ui/slider"; +import TimelineCanvasRuler from "./timeline-canvas/timeline-canvas-ruler"; +import TimelineCanvasRulerWrapper from "./timeline-canvas/timeline-canvas-ruler-wrapper"; + +export function Timeline() { + // Timeline shows all tracks (video, audio, effects) and their elements. + // You can drag media here to add it to your project. + // elements can be trimmed, deleted, and moved. + + const { + tracks, + addTrack, + addElementToTrack, + removeElementFromTrack, + getTotalDuration, + selectedElements, + clearSelectedElements, + setSelectedElements, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + toggleTrackMute, + separateAudio, + undo, + redo, + snappingEnabled, + toggleSnapping, + dragState, + } = useTimelineStore(); + const { mediaItems, addMediaItem } = useMediaStore(); + const { activeProject } = useProjectStore(); + const { currentTime, duration, seek, setDuration, isPlaying, toggle } = + usePlaybackStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const dragCounterRef = useRef(0); + const timelineRef = useRef<HTMLDivElement>(null); + const rulerRef = useRef<HTMLDivElement>(null); + const [isInTimeline, setIsInTimeline] = useState(false); + + // Timeline zoom functionality + const { + zoomLevel, + zoomStep, + handleChangeZoomLevel, + handleChangeZoomStep, + handleWheel, + } = useTimelineZoom(); + + // Old marquee selection removed - using new SelectionBox component instead + + // Dynamic timeline width calculation based on playhead position and duration + const dynamicTimelineWidth = Math.max( + (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration + (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer + timelineRef.current?.clientWidth || 1000, // Minimum width + ); + + // Scroll synchronization and auto-scroll to playhead + const rulerScrollRef = useRef<HTMLDivElement>(null); + const trackLabelsRef = useRef<HTMLDivElement>(null); + const playheadRef = useRef<HTMLDivElement>(null); + const trackLabelsScrollRef = useRef<HTMLDivElement>(null); + const isUpdatingRef = useRef(false); + const lastRulerSync = useRef(0); + const lastTracksSync = useRef(0); + const lastVerticalSync = useRef(0); + + // Timeline playhead ruler handlers + const { handleRulerMouseDown } = useTimelinePlayheadRuler({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + }); + + // Selection box functionality + const tracksContainerRef = useRef<HTMLDivElement>(null); + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + justFinishedSelecting, + } = useSelectionBox({ + containerRef: tracksContainerRef, + playheadRef, + onSelectionComplete: (elements) => { + console.log(JSON.stringify({ onSelectionComplete: elements.length })); + setSelectedElements(elements); + }, + }); + + // Calculate snap indicator state + const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( + null, + ); + const showSnapIndicator = + dragState.isDragging && snappingEnabled && currentSnapPoint !== null; + + // Callback to handle snap point changes from TimelineTrackContent + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); + + // Timeline content click to seek handler + const handleTimelineContentClick = useCallback( + (e: React.MouseEvent) => { + console.log( + JSON.stringify({ + timelineClick: { + isSelecting, + justFinishedSelecting, + willReturn: isSelecting || justFinishedSelecting, + }, + }), + ); + + // Don't seek if this was a selection box operation + if (isSelecting || justFinishedSelecting) { + return; + } + + // Don't seek if clicking on timeline elements, but still deselect + if ((e.target as HTMLElement).closest(".timeline-element")) { + return; + } + + // Don't seek if clicking on playhead + if (playheadRef.current?.contains(e.target as Node)) { + return; + } + + // Don't seek if clicking on track labels + if ((e.target as HTMLElement).closest("[data-track-labels]")) { + clearSelectedElements(); + return; + } + + // Clear selected elements when clicking empty timeline area + console.log(JSON.stringify({ clearingSelectedElements: true })); + clearSelectedElements(); + + // Determine if we're clicking in ruler or tracks area + const isTracksAreaClick = (e.target as HTMLElement).closest( + "[data-tracks-area]", + ); + + let mouseX: number; + let scrollLeft = 0; + + if (isTracksAreaClick) { + // Calculate based on ruler position + const rulerContent = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + if (!rulerContent) return; + const rect = rulerContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = rulerContent.scrollLeft; + } else { + return; + } + + const rawTime = Math.max( + 0, + Math.min( + duration, + (mouseX + scrollLeft) / + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ), + ); + + // Use frame snapping for timeline clicking + const projectFps = activeProject?.fps || 30; + const time = snapTimeToFrame(rawTime, projectFps); + + seek(time); + }, + [ + duration, + zoomLevel, + seek, + rulerScrollRef, + clearSelectedElements, + isSelecting, + justFinishedSelecting, + ], + ); + + // Update timeline duration when tracks change + useEffect(() => { + const totalDuration = getTotalDuration(); + setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline + }, [tracks, setDuration, getTotalDuration]); + + // Old marquee system removed - using new SelectionBox component instead + + const handleDragEnter = (e: React.DragEvent) => { + // When something is dragged over the timeline, show overlay + e.preventDefault(); + // Don't show overlay for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { + return; + } + dragCounterRef.current += 1; + if (!isDragOver) { + setIsDragOver(true); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + // Don't update state for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { + return; + } + + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + // When media is dropped, add it as a new track/element + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; + + // Ignore timeline element drags - they're handled by track-specific handlers + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element", + ); + if (hasTimelineElement) { + return; + } + + const itemData = e.dataTransfer.getData("application/x-media-item"); + if (itemData) { + try { + const dragData: DragData = JSON.parse(itemData); + + if (dragData.type === "text") { + // Always create new text track to avoid overlaps + useTimelineStore.getState().addTextToNewTrack(dragData); + } else { + // Handle media items + const mediaItem = mediaItems.find( + (item: any) => item.id === dragData.id, + ); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + useTimelineStore.getState().addMediaToNewTrack(mediaItem); + } + } catch (error) { + console.error("Error parsing dropped item data:", error); + toast.error("Failed to add item to timeline"); + } + } else if (e.dataTransfer.files?.length > 0) { + // Handle file drops by creating new tracks + if (!activeProject) { + toast.error("No active project"); + return; + } + + setIsProcessing(true); + setProgress(0); + try { + const processedItems = await processMediaFiles( + e.dataTransfer.files, + (p) => setProgress(p), + ); + for (const processedItem of processedItems) { + await addMediaItem(activeProject.id, processedItem); + const currentMediaItems = useMediaStore.getState().mediaItems; + const addedItem = currentMediaItems.find( + (item) => + item.name === processedItem.name && + item.url === processedItem.url, + ); + if (addedItem) { + useTimelineStore.getState().addMediaToNewTrack(addedItem); + } + } + } catch (error) { + // Show error if file processing fails + console.error("Error processing external files:", error); + toast.error("Failed to process dropped files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + } + }; + + const dragProps = { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; + + // Action handlers for toolbar + const handleSplitSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + let splitCount = 0; + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (element && track) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const newElementId = splitElement(trackId, elementId, currentTime); + if (newElementId) splitCount++; + } + } + }); + if (splitCount === 0) { + toast.error("Playhead must be within selected elements to split"); + } + }; + + const handleDuplicateSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; + + const newSelections: { trackId: string; elementId: string }[] = []; + + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((el) => el.id === elementId); + + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + + // Create element without id (will be generated by store) + const { id, ...elementWithoutId } = element; + + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + + // We can't predict the new id, so just clear selection for now + // TODO: addElementToTrack could return the new element id + } + }); + + clearSelectedElements(); + }; + + const handleFreezeSelected = () => { + toast.info("Freeze frame functionality coming soon!"); + }; + + const handleSplitAndKeepLeft = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepLeft(trackId, elementId, currentTime); + }; + + const handleSplitAndKeepRight = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepRight(trackId, elementId, currentTime); + }; + + const handleSeparateAudio = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one media element to separate audio"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + if (!track || track.type !== "media") { + toast.error("Select a media element to separate audio"); + return; + } + separateAudio(trackId, elementId); + }; + + const handleDeleteSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + selectedElements.forEach(({ trackId, elementId }) => { + removeElementFromTrack(trackId, elementId); + }); + clearSelectedElements(); + }; + + // --- Scroll synchronization effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + + if (!rulerViewport) return; + + // Horizontal scroll synchronization between ruler and tracks + const handleRulerScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; + lastRulerSync.current = now; + isUpdatingRef.current = true; + isUpdatingRef.current = false; + }; + const handleTracksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; + lastTracksSync.current = now; + isUpdatingRef.current = true; + isUpdatingRef.current = false; + }; + + rulerViewport.addEventListener("scroll", handleRulerScroll); + + // Vertical scroll synchronization between track labels and tracks content + if (trackLabelsViewport) { + const handleTrackLabelsScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + isUpdatingRef.current = false; + }; + const handleTracksVerticalScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + isUpdatingRef.current = false; + }; + + trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); + + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + trackLabelsViewport.removeEventListener( + "scroll", + handleTrackLabelsScroll, + ); + }; + } + + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + }; + }, []); + + return ( + <div + className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`} + {...dragProps} + onMouseEnter={() => setIsInTimeline(true)} + onMouseLeave={() => setIsInTimeline(false)} + > + {/* Toolbar */} + <div className="border-b flex items-center justify-between px-2 py-1"> + <div className="flex items-center gap-1 w-full"> + <TooltipProvider delayDuration={500}> + {/* Play/Pause Button */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={toggle} + className="mr-2" + > + {isPlaying ? ( + <Pause className="h-4 w-4" /> + ) : ( + <Play className="h-4 w-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + {isPlaying ? "Pause (Space)" : "Play (Space)"} + </TooltipContent> + </Tooltip> + <div className="w-px h-6 bg-border mx-1" /> + {/* Time Display */} + <div + className="text-xs text-muted-foreground font-mono px-2" + style={{ minWidth: "18ch", textAlign: "center" }} + > + {currentTime.toFixed(1)}s / {duration.toFixed(1)}s + </div> + {/* Test Clip Button - for debugging */} + {tracks.length === 0 && ( + <> + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => { + const trackId = addTrack("media"); + addElementToTrack(trackId, { + type: "media", + mediaId: "test", + name: "Test Clip", + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + }} + className="text-xs" + > + Add Test Clip + </Button> + </TooltipTrigger> + <TooltipContent> + Add a test clip to try playback + </TooltipContent> + </Tooltip> + </> + )} + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitSelected} + > + <Scissors className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split element (Ctrl+S)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitAndKeepLeft} + > + <ArrowLeftToLine className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitAndKeepRight} + > + <ArrowRightToLine className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSeparateAudio} + > + <SplitSquareHorizontal className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleDuplicateSelected} + > + <Copy className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleFreezeSelected} + > + <Snowflake className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Freeze frame (F)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleDeleteSelected} + > + <Trash2 className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Delete element (Delete)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + className="ml-auto" + variant="text" + size="icon" + onClick={() => handleChangeZoomLevel(zoomLevel - 0.15)} + > + <ZoomOut className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Zoom Out</TooltipContent> + </Tooltip> + <Slider + className="max-w-24" + max={TIMELINE_CONSTANTS.MAX_ZOOM_STEP} + min={0} + step={1} + value={[zoomStep]} + onValueChange={(value) => handleChangeZoomStep(value[0])} + /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={() => handleChangeZoomLevel(zoomLevel + 0.15)} + > + <ZoomIn className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Zoom In</TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={toggleSnapping}> + {snappingEnabled ? ( + <Lock className="h-4 w-4" /> + ) : ( + <LockOpen className="h-4 w-4 text-primary" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent>Auto snapping</TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + </div> + + {/* Timeline Container */} + <div + className="flex-1 flex flex-col overflow-hidden relative" + ref={timelineRef} + > + <TimelinePlayhead + currentTime={currentTime} + duration={duration} + zoomLevel={zoomLevel} + tracks={tracks} + seek={seek} + rulerRef={rulerRef} + rulerScrollRef={rulerScrollRef} + trackLabelsRef={trackLabelsRef} + timelineRef={timelineRef} + playheadRef={playheadRef} + isSnappingToPlayhead={ + showSnapIndicator && currentSnapPoint?.type === "playhead" + } + /> + <SnapIndicator + snapPoint={currentSnapPoint} + zoomLevel={zoomLevel} + tracks={tracks} + timelineRef={timelineRef} + trackLabelsRef={trackLabelsRef} + isVisible={showSnapIndicator} + /> + {/* Timeline Header with Ruler */} + <div className="flex bg-panel sticky top-0 z-10"> + <div className="w-48 flex-shrink-0 bg-muted/30 border-r h-5 px-3" /> + + <div className="flex-1 overflow-hidden h-5"> + <ScrollArea className="w-full" ref={rulerScrollRef}> + <TimelineCanvasRulerWrapper + ref={rulerRef} + onMouseDown={handleRulerMouseDown} + > + <TimelineCanvasRuler + zoomLevel={zoomLevel} + duration={duration} + width={dynamicTimelineWidth} + /> + </TimelineCanvasRulerWrapper> + </ScrollArea> + </div> + </div> + + {/* Tracks Area */} + <ScrollArea className="w-full h-full"> + <div + className="flex-1 flex overflow-hidden overflow-y-auto" + data-tracks-area + > + {/* Track Labels */} + {tracks.length > 0 && ( + <div + ref={trackLabelsRef} + className="w-48 flex-shrink-0 border-r bg-panel-accent " + data-track-labels + > + <div className="flex flex-col gap-1" ref={trackLabelsScrollRef}> + {tracks.map((track) => ( + <div + key={track.id} + className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5" + style={{ height: `${getTrackHeight(track.type)}px` }} + > + <div className="flex items-center flex-1 min-w-0"> + <TrackIcon track={track} /> + </div> + {track.muted && ( + <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0"> + Muted + </span> + )} + </div> + ))} + </div> + </div> + )} + + {/* Timeline Tracks Content */} + <div + className="flex-1 relative overflow-hidden" + onWheel={handleWheel} + onMouseDown={handleSelectionMouseDown} + onClick={handleTimelineContentClick} + ref={tracksContainerRef} + > + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> + <div + className="relative flex-1" + style={{ + height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`, + width: `${dynamicTimelineWidth}px`, + }} + > + {tracks.length === 0 ? ( + <div></div> + ) : ( + <> + {tracks.map((track, index) => ( + <ContextMenu key={track.id}> + <ContextMenuTrigger asChild> + <div + className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]" + style={{ + top: `${getCumulativeHeightBefore(tracks, index)}px`, + height: `${getTrackHeight(track.type)}px`, + }} + onClick={(e) => { + // If clicking empty area (not on a element), deselect all elements + if ( + !(e.target as HTMLElement).closest( + ".timeline-element", + ) + ) { + clearSelectedElements(); + } + }} + > + <TimelineTrackContent + track={track} + zoomLevel={zoomLevel} + onSnapPointChange={handleSnapPointChange} + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem + onClick={() => toggleTrackMute(track.id)} + > + {track.muted ? "Unmute Track" : "Mute Track"} + </ContextMenuItem> + <ContextMenuItem> + Track settings (soon) + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ))} + </> + )} + </div> + </div> + </div> + </ScrollArea> + </div> + </div> + ); +} + +function TrackIcon({ track }: { track: TimelineTrack }) { + return ( + <> + {track.type === "media" && ( + <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + )} + {track.type === "text" && ( + <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + )} + {track.type === "audio" && ( + <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + )} + </> + ); +} |
