diff options
Diffstat (limited to 'apps/web/src/components/editor/timeline')
| -rw-r--r-- | apps/web/src/components/editor/timeline/index.tsx | 1121 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/timeline-element.tsx | 383 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/timeline-playhead.tsx | 117 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/timeline-track.tsx | 1151 |
4 files changed, 2772 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx new file mode 100644 index 00000000..fd2b2a11 --- /dev/null +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -0,0 +1,1121 @@ +"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, + Magnet, + Link, + ZoomIn, + ZoomOut, +} 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 "@/components/ui/slider"; + +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, + getTotalDuration, + clearSelectedElements, + snappingEnabled, + setSelectedElements, + toggleTrackMute, + 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); + + // Track mouse down/up for distinguishing clicks from drag/resize ends + const mouseTrackingRef = useRef({ + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }); + + // Timeline zoom functionality + const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ + containerRef: timelineRef, + isInTimeline, + }); + + // 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 tracksScrollRef = 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, + tracksScrollRef, + playheadRef, + }); + + // 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); + }, []); + + // Track mouse down to distinguish real clicks from drag/resize ends + const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { + // Only track mouse down on timeline background areas (not elements) + const target = e.target as HTMLElement; + const isTimelineBackground = + !target.closest(".timeline-element") && + !playheadRef.current?.contains(target) && + !target.closest("[data-track-labels]"); + + if (isTimelineBackground) { + mouseTrackingRef.current = { + isMouseDown: true, + downX: e.clientX, + downY: e.clientY, + downTime: e.timeStamp, + }; + } + }, []); + + // Timeline content click to seek handler + const handleTimelineContentClick = useCallback( + (e: React.MouseEvent) => { + const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; + + // Reset mouse tracking + mouseTrackingRef.current = { + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }; + + // Only process as click if we tracked a mouse down on timeline background + if (!isMouseDown) { + console.log( + JSON.stringify({ + ignoredClickWithoutMouseDown: true, + timeStamp: e.timeStamp, + }) + ); + return; + } + + // Check if mouse moved significantly (indicates drag, not click) + const deltaX = Math.abs(e.clientX - downX); + const deltaY = Math.abs(e.clientY - downY); + const deltaTime = e.timeStamp - downTime; + + if (deltaX > 5 || deltaY > 5 || deltaTime > 500) { + console.log( + JSON.stringify({ + ignoredDragNotClick: true, + deltaX, + deltaY, + deltaTime, + timeStamp: e.timeStamp, + }) + ); + return; + } + + // 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 isRulerClick = (e.target as HTMLElement).closest( + "[data-ruler-area]" + ); + + let mouseX: number; + let scrollLeft = 0; + + if (isRulerClick) { + // 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 { + // Calculate based on tracks content position + const tracksContent = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + if (!tracksContent) return; + const rect = tracksContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = tracksContent.scrollLeft; + } + + 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, + tracksScrollRef, + 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, + }; + + // --- Scroll synchronization effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + + if (!rulerViewport || !tracksViewport) 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; + tracksViewport.scrollLeft = rulerViewport.scrollLeft; + isUpdatingRef.current = false; + }; + const handleTracksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; + lastTracksSync.current = now; + isUpdatingRef.current = true; + rulerViewport.scrollLeft = tracksViewport.scrollLeft; + isUpdatingRef.current = false; + }; + + rulerViewport.addEventListener("scroll", handleRulerScroll); + tracksViewport.addEventListener("scroll", handleTracksScroll); + + // 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; + tracksViewport.scrollTop = trackLabelsViewport.scrollTop; + isUpdatingRef.current = false; + }; + const handleTracksVerticalScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + trackLabelsViewport.scrollTop = tracksViewport.scrollTop; + isUpdatingRef.current = false; + }; + + trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); + tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); + + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + trackLabelsViewport.removeEventListener( + "scroll", + handleTrackLabelsScroll + ); + tracksViewport.removeEventListener( + "scroll", + handleTracksVerticalScroll + ); + }; + } + + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + }; + }, []); + + 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)} + > + <TimelineToolbar zoomLevel={zoomLevel} setZoomLevel={setZoomLevel} /> + + {/* 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} + tracksScrollRef={tracksScrollRef} + 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"> + {/* Track Labels Header */} + <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2"> + {/* Empty space */} + <span className="text-sm font-medium text-muted-foreground opacity-0"> + . + </span> + </div> + + {/* Timeline Ruler */} + <div + className="flex-1 relative overflow-hidden h-4" + onWheel={(e) => { + // Check if this is horizontal scrolling - if so, don't handle it here + if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + return; // Let ScrollArea handle horizontal scrolling + } + handleWheel(e); + }} + onMouseDown={handleSelectionMouseDown} + onClick={handleTimelineContentClick} + data-ruler-area + > + <ScrollArea className="w-full" ref={rulerScrollRef}> + <div + ref={rulerRef} + className="relative h-4 select-none cursor-default" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + onMouseDown={handleRulerMouseDown} + > + {/* Time markers */} + {(() => { + // Calculate appropriate time interval based on zoom level + const getTimeInterval = (zoom: number) => { + const pixelsPerSecond = + TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; + if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in + if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in + if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom + if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out + if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out + if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out + return 30; // Every 30s when extremely zoomed out + }; + + const interval = getTimeInterval(zoomLevel); + const markerCount = Math.ceil(duration / interval) + 1; + + return Array.from({ length: markerCount }, (_, i) => { + const time = i * interval; + if (time > duration) return null; + + const isMainMarker = + time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; + + return ( + <div + key={i} + className={`absolute top-0 bottom-0 ${ + isMainMarker + ? "border-l border-muted-foreground/40" + : "border-l border-muted-foreground/20" + }`} + style={{ + left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, + }} + > + <span + className={`absolute top-1 left-1 text-[0.6rem] ${ + isMainMarker + ? "text-muted-foreground font-medium" + : "text-muted-foreground/70" + }`} + > + {(() => { + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; + } + if (minutes > 0) { + return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; + } + if (interval >= 1) { + return `${Math.floor(secs)}s`; + } + return `${secs.toFixed(1)}s`; + }; + return formatTime(time); + })()} + </span> + </div> + ); + }).filter(Boolean); + })()} + </div> + </ScrollArea> + </div> + </div> + + {/* Tracks Area */} + <div className="flex-1 flex overflow-hidden"> + {/* Track Labels */} + {tracks.length > 0 && ( + <div + ref={trackLabelsRef} + className="w-48 flex-shrink-0 border-r border-black overflow-y-auto" + data-track-labels + > + <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> + <div className="flex flex-col gap-1"> + {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> + </ScrollArea> + </div> + )} + + {/* Timeline Tracks Content */} + <div + className="flex-1 relative overflow-hidden" + onWheel={(e) => { + // Check if this is horizontal scrolling - if so, don't handle it here + if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + return; // Let ScrollArea handle horizontal scrolling + } + handleWheel(e); + }} + onMouseDown={(e) => { + handleTimelineMouseDown(e); + handleSelectionMouseDown(e); + }} + onClick={handleTimelineContentClick} + ref={tracksContainerRef} + > + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> + <ScrollArea + className="w-full h-full" + ref={tracksScrollRef} + type="scroll" + showHorizontalScrollbar + > + <div + className="relative flex-1" + style={{ + height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`, + width: `${dynamicTimelineWidth}px`, + }} + > + {tracks.length === 0 ? ( + <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> + </ScrollArea> + </div> + </div> + </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" /> + )} + </> + ); +} + +function TimelineToolbar({ + zoomLevel, + setZoomLevel, +}: { + zoomLevel: number; + setZoomLevel: (zoom: number) => void; +}) { + const { + tracks, + addTrack, + addElementToTrack, + removeElementFromTrack, + removeElementFromTrackWithRipple, + selectedElements, + clearSelectedElements, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + snappingEnabled, + toggleSnapping, + rippleEditingEnabled, + toggleRippleEditing, + } = useTimelineStore(); + const { currentTime, duration, isPlaying, toggle } = usePlaybackStore(); + + // Action handlers + const handleSplitSelected = () => { + if (selectedElements.length === 0) return; + let splitCount = 0; + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (element && track) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const newElementId = splitElement(trackId, elementId, currentTime); + if (newElementId) splitCount++; + } + } + }); + if (splitCount === 0) { + toast.error("Playhead must be within selected elements to split"); + } + }; + + const handleDuplicateSelected = () => { + if (selectedElements.length === 0) return; + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; + + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((el) => el.id === elementId); + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + const { id, ...elementWithoutId } = element; + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + } + }); + clearSelectedElements(); + }; + + const handleFreezeSelected = () => { + toast.info("Freeze frame functionality coming soon!"); + }; + + const handleSplitAndKeepLeft = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepLeft(trackId, elementId, currentTime); + }; + + const handleSplitAndKeepRight = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepRight(trackId, elementId, currentTime); + }; + + const handleSeparateAudio = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one media element to separate audio"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + if (!track || track.type !== "media") { + toast.error("Select a media element to separate audio"); + return; + } + separateAudio(trackId, elementId); + }; + + const handleDeleteSelected = () => { + if (selectedElements.length === 0) return; + selectedElements.forEach(({ trackId, elementId }) => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(trackId, elementId); + } else { + removeElementFromTrack(trackId, elementId); + } + }); + clearSelectedElements(); + }; + + // Zoom handlers + const handleZoomIn = () => { + setZoomLevel(Math.min(4, zoomLevel + 0.25)); + }; + + const handleZoomOut = () => { + setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); + }; + + const handleZoomSliderChange = (values: number[]) => { + setZoomLevel(values[0]); + }; + return ( + <div className="border-b flex items-center justify-between px-2 py-1"> + <div className="flex items-center gap-1 w-full"> + <TooltipProvider delayDuration={500}> + {/* Play/Pause Button */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={toggle} + className="mr-2" + > + {isPlaying ? ( + <Pause className="h-4 w-4" /> + ) : ( + <Play className="h-4 w-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + {isPlaying ? "Pause (Space)" : "Play (Space)"} + </TooltipContent> + </Tooltip> + <div className="w-px h-6 bg-border mx-1" /> + {/* Time Display */} + <div + className="text-xs text-muted-foreground font-mono px-2" + style={{ minWidth: "18ch", textAlign: "center" }} + > + {currentTime.toFixed(1)}s / {duration.toFixed(1)}s + </div> + {/* Test Clip Button - for debugging */} + {tracks.length === 0 && ( + <> + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => { + const trackId = addTrack("media"); + addElementToTrack(trackId, { + type: "media", + mediaId: "test", + name: "Test Clip", + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + }} + className="text-xs" + > + Add Test Clip + </Button> + </TooltipTrigger> + <TooltipContent>Add a test clip to try playback</TooltipContent> + </Tooltip> + </> + )} + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleSplitSelected}> + <Scissors className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split element (Ctrl+S)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitAndKeepLeft} + > + <ArrowLeftToLine className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitAndKeepRight} + > + <ArrowRightToLine className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleSeparateAudio}> + <SplitSquareHorizontal className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleDuplicateSelected} + > + <Copy className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleFreezeSelected}> + <Snowflake className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Freeze frame (F)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleDeleteSelected}> + <Trash2 className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Delete element (Delete)</TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={toggleSnapping}> + {snappingEnabled ? ( + <Magnet className="h-4 w-4 text-primary" /> + ) : ( + <Magnet className="h-4 w-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent>Auto snapping</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={toggleRippleEditing}> + <Link + className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`} + /> + </Button> + </TooltipTrigger> + <TooltipContent> + {rippleEditingEnabled + ? "Disable Ripple Editing" + : "Enable Ripple Editing"} + </TooltipContent> + </Tooltip> + </TooltipProvider> + <div className="flex items-center gap-1"> + <Button variant="text" size="icon" onClick={handleZoomOut}> + <ZoomOut className="h-4 w-4" /> + </Button> + <Slider + className="w-24" + value={[zoomLevel]} + onValueChange={handleZoomSliderChange} + min={0.25} + max={4} + step={0.25} + /> + <Button variant="text" size="icon" onClick={handleZoomIn}> + <ZoomIn className="h-4 w-4" /> + </Button> + </div> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx new file mode 100644 index 00000000..8aadde82 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -0,0 +1,383 @@ +"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, + removeElementFromTrackWithRipple, + dragState, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + addElementToTrack, + replaceElementMedia, + rippleEditingEnabled, + } = 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; + + // Element should always be positioned at startTime - trimStart only affects content, not position + const elementLeft = elementStartTime * 50 * zoomLevel; + + 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 = () => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(track.id, element.id); + } else { + 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 - 8; // Match image 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 thumbnails */} + <div + className="absolute top-3 bottom-3 left-0 right-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 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> + ); + } + + // 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/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx new file mode 100644 index 00000000..d0aea7fa --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useRef } from "react"; +import { TimelineTrack } from "@/types/timeline"; +import { + TIMELINE_CONSTANTS, + getTotalTracksHeight, +} 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>; + tracksScrollRef: 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, + tracksScrollRef, + trackLabelsRef, + timelineRef, + playheadRef: externalPlayheadRef, + isSnappingToPlayhead = false, +}: TimelinePlayheadProps) { + const internalPlayheadRef = useRef<HTMLDivElement>(null); + const playheadRef = externalPlayheadRef || internalPlayheadRef; + const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + // 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-[150]" + style={{ + left: `${leftPosition}px`, + top: 0, + height: `${totalHeight}px`, + width: "2px", // Slightly wider for better click target + zIndex: 100, + }} + onMouseDown={handlePlayheadMouseDown} + > + {/* 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, + tracksScrollRef, + playheadRef, +}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) { + const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + return { handleRulerMouseDown, isDraggingRuler }; +} + +export { TimelinePlayhead as default }; diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx new file mode 100644 index 00000000..097fc051 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -0,0 +1,1151 @@ +"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, + updateElementStartTimeWithRipple, + addElementToTrack, + selectedElements, + selectElement, + dragState, + startDrag: startDragAction, + updateDragTime, + endDrag: endDragAction, + clearSelectedElements, + insertTrackAt, + snappingEnabled, + rippleEditingEnabled, + } = 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) { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + dragState.elementId, + dragState.currentTime + ); + } else { + 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) { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + dragState.elementId, + finalTime + ); + } else { + updateElementStartTime( + track.id, + dragState.elementId, + finalTime + ); + } + } else { + moveElementToTrack( + dragState.trackId, + track.id, + dragState.elementId + ); + requestAnimationFrame(() => { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + dragState.elementId!, + finalTime + ); + } else { + 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) { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + dragState.elementId, + finalTime + ); + } else { + 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 + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + elementId, + finalStartTime + ); + } else { + updateElementStartTime(track.id, elementId, finalStartTime); + } + } else { + // Moving to different track + moveElementToTrack(fromTrackId, track.id, elementId); + requestAnimationFrame(() => { + if (rippleEditingEnabled) { + updateElementStartTimeWithRipple( + track.id, + elementId, + finalStartTime + ); + } else { + updateElementStartTime(track.id, elementId, finalStartTime); + } + }); + } + } else if (hasMediaItem) { + // Handle media item drop + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); + if (!mediaItemData) return; + + const dragData: DragData = JSON.parse(mediaItemData); + + if (dragData.type === "text") { + let targetTrackId = track.id; + let targetTrack = track; + + // Handle position-aware track creation for text + if (track.type !== "text" || dropPosition !== "on") { + // Text tracks should go above the main track + const mainTrack = getMainTrack(tracks); + let insertIndex: number; + + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // dropPosition === "on" but track is not text type + // Insert above main track if main track exists, otherwise at top + if (mainTrack) { + const mainTrackIndex = tracks.findIndex( + (t) => t.id === mainTrack.id + ); + insertIndex = mainTrackIndex; + } else { + insertIndex = 0; // Top of timeline + } + } + + targetTrackId = insertTrackAt("text", insertIndex); + // Get the updated tracks array after creating the new track + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + + // Check for overlaps with existing elements in target track + const newElementDuration = 5; // Default text duration + const textSnappedTime = getDropSnappedTime( + newStartTime, + newElementDuration + ); + const newElementEnd = textSnappedTime + newElementDuration; + + const hasOverlap = targetTrack.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return ( + textSnappedTime < existingEnd && newElementEnd > existingStart + ); + }); + + if (hasOverlap) { + toast.error( + "Cannot place element here - it would overlap with existing elements" + ); + return; + } + + addElementToTrack(targetTrackId, { + 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> + ); +} |
