"use client"; import { ScrollArea } from "../ui/scroll-area"; import { Button } from "../ui/button"; import { Scissors, ArrowLeftToLine, ArrowRightToLine, Trash2, Snowflake, Copy, SplitSquareHorizontal, Volume2, VolumeX, Pause, Play, } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider, } from "../ui/tooltip"; import { useTimelineStore, type TimelineTrack, type TimelineClip as TypeTimelineClip, } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { useDragClip } from "@/hooks/use-drag-clip"; import { processMediaFiles } from "@/lib/media-processing"; import { toast } from "sonner"; import { useState, useRef, useEffect, useCallback } from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select"; import { TimelineClip } from "./timeline-clip"; import { ContextMenuState } from "@/types/timeline"; export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their clips. // You can drag media here to add it to your project. // Clips can be trimmed, deleted, and moved. const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, getTotalDuration, selectedClips, clearSelectedClips, setSelectedClips, updateClipTrim, splitClip, splitAndKeepLeft, splitAndKeepRight, separateAudio, undo, redo, } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { currentTime, duration, seek, setDuration, isPlaying, toggle, setSpeed, speed, } = usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); const [zoomLevel, setZoomLevel] = useState(1); const dragCounterRef = useRef(0); const timelineRef = useRef(null); const [isInTimeline, setIsInTimeline] = useState(false); // Unified context menu state const [contextMenu, setContextMenu] = useState(null); // Marquee selection state const [marquee, setMarquee] = useState<{ startX: number; startY: number; endX: number; endY: number; active: boolean; additive: boolean; } | null>(null); // Playhead scrubbing state const [isScrubbing, setIsScrubbing] = useState(false); const [scrubTime, setScrubTime] = useState(null); // Update timeline duration when tracks change useEffect(() => { const totalDuration = getTotalDuration(); setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline }, [tracks, setDuration, getTotalDuration]); // Close context menu on click elsewhere useEffect(() => { const handleClick = () => setContextMenu(null); if (contextMenu) { window.addEventListener("click", handleClick); return () => window.removeEventListener("click", handleClick); } }, [contextMenu]); // Keyboard event for deleting selected clips useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ( (e.key === "Delete" || e.key === "Backspace") && selectedClips.length > 0 ) { selectedClips.forEach(({ trackId, clipId }) => { removeClipFromTrack(trackId, clipId); }); clearSelectedClips(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedClips, removeClipFromTrack, clearSelectedClips]); // Keyboard event for undo (Cmd+Z) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) { e.preventDefault(); undo(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [undo]); // Keyboard event for redo (Cmd+Shift+Z or Cmd+Y) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) { e.preventDefault(); redo(); } else if ((e.metaKey || e.ctrlKey) && e.key === "y") { e.preventDefault(); redo(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [redo]); // Mouse down on timeline background to start marquee const handleTimelineMouseDown = (e: React.MouseEvent) => { if (e.target === e.currentTarget && e.button === 0) { setMarquee({ startX: e.clientX, startY: e.clientY, endX: e.clientX, endY: e.clientY, active: true, additive: e.metaKey || e.ctrlKey || e.shiftKey, }); } }; // Mouse move to update marquee useEffect(() => { if (!marquee || !marquee.active) return; const handleMouseMove = (e: MouseEvent) => { setMarquee( (prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY } ); }; const handleMouseUp = (e: MouseEvent) => { setMarquee( (prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false } ); }; window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, [marquee]); // On marquee end, select clips in box useEffect(() => { if (!marquee || marquee.active) return; const timeline = timelineRef.current; if (!timeline) return; const rect = timeline.getBoundingClientRect(); const x1 = Math.min(marquee.startX, marquee.endX) - rect.left; const x2 = Math.max(marquee.startX, marquee.endX) - rect.left; const y1 = Math.min(marquee.startY, marquee.endY) - rect.top; const y2 = Math.max(marquee.startY, marquee.endY) - rect.top; // Validation: skip if too small if (Math.abs(x2 - x1) < 5 || Math.abs(y2 - y1) < 5) { setMarquee(null); return; } // Clamp to timeline bounds const clamp = (val: number, min: number, max: number) => Math.max(min, Math.min(max, val)); const bx1 = clamp(x1, 0, rect.width); const bx2 = clamp(x2, 0, rect.width); const by1 = clamp(y1, 0, rect.height); const by2 = clamp(y2, 0, rect.height); let newSelection: { trackId: string; clipId: string }[] = []; tracks.forEach((track, trackIdx) => { track.clips.forEach((clip) => { const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); const clipLeft = clip.startTime * 50 * zoomLevel; const clipTop = trackIdx * 60; const clipBottom = clipTop + 60; const clipRight = clipLeft + 60; // Set a fixed width for time display if ( bx1 < clipRight && bx2 > clipLeft && by1 < clipBottom && by2 > clipTop ) { newSelection.push({ trackId: track.id, clipId: clip.id }); } }); }); if (newSelection.length > 0) { if (marquee.additive) { const selectedSet = new Set( selectedClips.map((c) => c.trackId + ":" + c.clipId) ); newSelection = [ ...selectedClips, ...newSelection.filter( (c) => !selectedSet.has(c.trackId + ":" + c.clipId) ), ]; } setSelectedClips(newSelection); } else if (!marquee.additive) { clearSelectedClips(); } setMarquee(null); }, [ marquee, tracks, zoomLevel, selectedClips, setSelectedClips, clearSelectedClips, ]); const handleDragEnter = (e: React.DragEvent) => { // When something is dragged over the timeline, show overlay e.preventDefault(); // Don't show overlay for timeline clips - they're handled by tracks if (e.dataTransfer.types.includes("application/x-timeline-clip")) { 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 clips - they're handled by tracks if (e.dataTransfer.types.includes("application/x-timeline-clip")) { 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/clip e.preventDefault(); setIsDragOver(false); dragCounterRef.current = 0; // Ignore timeline clip drags - they're handled by track-specific handlers const hasTimelineClip = e.dataTransfer.types.includes( "application/x-timeline-clip" ); if (hasTimelineClip) { return; } const mediaItemData = e.dataTransfer.getData("application/x-media-item"); if (mediaItemData) { // Handle media item drops by creating new tracks try { const { id, type } = JSON.parse(mediaItemData); const mediaItem = mediaItems.find((item) => item.id === id); if (!mediaItem) { toast.error("Media item not found"); return; } // Add to video or audio track depending on type const trackType = type === "audio" ? "audio" : "video"; const newTrackId = addTrack(trackType); addClipToTrack(newTrackId, { mediaId: mediaItem.id, name: mediaItem.name, duration: mediaItem.duration || 5, startTime: 0, trimStart: 0, trimEnd: 0, }); toast.success(`Added ${mediaItem.name} to new ${trackType} track`); } catch (error) { // Show error if parsing fails console.error("Error parsing media item data:", error); toast.error("Failed to add media to timeline"); } } else if (e.dataTransfer.files?.length > 0) { // Handle file drops by creating new tracks setIsProcessing(true); setProgress(0); try { const processedItems = await processMediaFiles( e.dataTransfer.files, (p) => setProgress(p) ); for (const processedItem of processedItems) { addMediaItem(processedItem); const currentMediaItems = useMediaStore.getState().mediaItems; const addedItem = currentMediaItems.find( (item) => item.name === processedItem.name && item.url === processedItem.url ); if (addedItem) { const trackType = processedItem.type === "audio" ? "audio" : "video"; const newTrackId = addTrack(trackType); addClipToTrack(newTrackId, { mediaId: addedItem.id, name: addedItem.name, duration: addedItem.duration || 5, startTime: 0, trimStart: 0, trimEnd: 0, }); } } } 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 handleSeekToPosition = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickedTime = clickX / (50 * zoomLevel); const clampedTime = Math.max(0, Math.min(duration, clickedTime)); seek(clampedTime); }; const handleTimelineAreaClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { clearSelectedClips(); // Calculate the clicked time position and seek to it handleSeekToPosition(e); } }; const handleWheel = (e: React.WheelEvent) => { // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true) if (e.ctrlKey || e.metaKey) { e.preventDefault(); const delta = e.deltaY > 0 ? -0.05 : 0.05; setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); } // Otherwise, allow normal scrolling }; // --- Playhead Scrubbing Handlers --- const handlePlayheadMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); setIsScrubbing(true); handleScrub(e); }, [duration, zoomLevel] ); const handleScrub = useCallback( (e: MouseEvent | React.MouseEvent) => { const timeline = timelineRef.current; if (!timeline) return; const rect = timeline.getBoundingClientRect(); const x = e.clientX - rect.left; const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); setScrubTime(time); seek(time); // update video preview in real time }, [duration, zoomLevel, seek] ); useEffect(() => { if (!isScrubbing) return; const onMouseMove = (e: MouseEvent) => handleScrub(e); const onMouseUp = (e: MouseEvent) => { setIsScrubbing(false); if (scrubTime !== null) seek(scrubTime); // finalize seek setScrubTime(null); }; window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); return () => { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); }; }, [isScrubbing, scrubTime, seek, handleScrub]); const playheadPosition = isScrubbing && scrubTime !== null ? scrubTime : currentTime; const dragProps = { onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop, }; // Action handlers for toolbar const handleSplitSelected = () => { if (selectedClips.length === 0) { toast.error("No clips selected"); return; } let splitCount = 0; selectedClips.forEach(({ trackId, clipId }) => { const track = tracks.find((t) => t.id === trackId); const clip = track?.clips.find((c) => c.id === clipId); if (clip && track) { const effectiveStart = clip.startTime; const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); if (currentTime > effectiveStart && currentTime < effectiveEnd) { const newClipId = splitClip(trackId, clipId, currentTime); if (newClipId) splitCount++; } } }); if (splitCount > 0) { toast.success(`Split ${splitCount} clip(s) at playhead`); } else { toast.error("Playhead must be within selected clips to split"); } }; const handleDuplicateSelected = () => { if (selectedClips.length === 0) { toast.error("No clips selected"); return; } selectedClips.forEach(({ trackId, clipId }) => { const track = tracks.find((t) => t.id === trackId); const clip = track?.clips.find((c) => c.id === clipId); if (clip && track) { addClipToTrack(track.id, { mediaId: clip.mediaId, name: clip.name + " (copy)", duration: clip.duration, startTime: clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd) + 0.1, trimStart: clip.trimStart, trimEnd: clip.trimEnd, }); } }); toast.success("Duplicated selected clip(s)"); }; const handleFreezeSelected = () => { if (selectedClips.length === 0) { toast.error("No clips selected"); return; } selectedClips.forEach(({ trackId, clipId }) => { const track = tracks.find((t) => t.id === trackId); const clip = track?.clips.find((c) => c.id === clipId); if (clip && track) { // Add a new freeze frame clip at the playhead addClipToTrack(track.id, { mediaId: clip.mediaId, name: clip.name + " (freeze)", duration: 1, // 1 second freeze frame startTime: currentTime, trimStart: 0, trimEnd: clip.duration - 1, }); } }); toast.success("Freeze frame added for selected clip(s)"); }; const handleSplitAndKeepLeft = () => { if (selectedClips.length === 0) { toast.error("No clips selected"); return; } let splitCount = 0; selectedClips.forEach(({ trackId, clipId }) => { const track = tracks.find((t) => t.id === trackId); const clip = track?.clips.find((c) => c.id === clipId); if (clip && track) { const effectiveStart = clip.startTime; const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); if (currentTime > effectiveStart && currentTime < effectiveEnd) { splitAndKeepLeft(trackId, clipId, currentTime); splitCount++; } } }); if (splitCount > 0) { toast.success(`Split and kept left portion of ${splitCount} clip(s)`); } else { toast.error("Playhead must be within selected clips"); } }; const handleSplitAndKeepRight = () => { if (selectedClips.length === 0) { toast.error("No clips selected"); return; } let splitCount = 0; selectedClips.forEach(({ trackId, clipId }) => { const track = tracks.find((t) => t.id === trackId); const clip = track?.clips.find((c) => c.id === clipId); if (clip && track) { const effectiveStart = clip.startTime; const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); if (currentTime > effectiveStart && currentTime < effectiveEnd) { splitAndKeepRight(trackId, clipId, currentTime); splitCount++; } } }); if (splitCount > 0) { toast.success(`Split and kept right portion of ${splitCount} clip(s)`); } else { toast.error("Playhead must be within selected clips"); } }; const handleSeparateAudio = () => { if (selectedClips.length === 0) { toast.error("No clips selected"); return; } let separatedCount = 0; selectedClips.forEach(({ trackId, clipId }) => { const track = tracks.find((t) => t.id === trackId); const clip = track?.clips.find((c) => c.id === clipId); const mediaItem = mediaItems.find((item) => item.id === clip?.mediaId); if (clip && track && mediaItem?.type === "video" && track.type === "video") { const audioClipId = separateAudio(trackId, clipId); if (audioClipId) separatedCount++; } }); if (separatedCount > 0) { toast.success(`Separated audio from ${separatedCount} video clip(s)`); } else { toast.error("Select video clips to separate audio"); } }; const handleDeleteSelected = () => { if (selectedClips.length === 0) { toast.error("No clips selected"); return; } selectedClips.forEach(({ trackId, clipId }) => { removeClipFromTrack(trackId, clipId); }); clearSelectedClips(); toast.success("Deleted selected clip(s)"); }; // Prevent explorer zooming in/out when in timeline useEffect(() => { const preventZoom = (e: WheelEvent) => { // if (isInTimeline && (e.ctrlKey || e.metaKey)) { if ( isInTimeline && (e.ctrlKey || e.metaKey) && timelineRef.current?.contains(e.target as Node) ) { e.preventDefault(); } }; document.addEventListener("wheel", preventZoom, { passive: false }); return () => { document.removeEventListener("wheel", preventZoom); }; }, [isInTimeline]); return (
setIsInTimeline(true)} onMouseLeave={() => setIsInTimeline(false)} onWheel={handleWheel} > {/* Toolbar */}
{/* Play/Pause Button */} {isPlaying ? "Pause (Space)" : "Play (Space)"}
{/* Time Display */}
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
{/* Test Clip Button - for debugging */} {tracks.length === 0 && ( <>
Add a test clip to try playback )}
Split clip (Ctrl+S) Split and keep left (Ctrl+Q) Split and keep right (Ctrl+W) Separate audio (Ctrl+D) Duplicate clip (Ctrl+D) Freeze frame (F) Delete clip (Delete)
{/* Speed Control */} Playback Speed
{/* Timeline Container */}
{/* Timeline Header with Ruler */}
{/* Track Labels Header */}
Tracks
{zoomLevel.toFixed(1)}x
{/* Timeline Ruler */}
{ // Calculate the clicked time position and seek to it handleSeekToPosition(e); }} > {/* Time markers */} {(() => { // Calculate appropriate time interval based on zoom level const getTimeInterval = (zoom: number) => { const pixelsPerSecond = 50 * 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 (
{(() => { 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")}`; } else if (minutes > 0) { return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; } else if (interval >= 1) { return `${Math.floor(secs)}s`; } else { return `${secs.toFixed(1)}s`; } }; return formatTime(time); })()}
); }).filter(Boolean); })()} {/* Playhead in ruler (scrubbable) */}
{/* Tracks Area */}
{/* Track Labels */} {tracks.length > 0 && (
{tracks.map((track) => (
{ e.preventDefault(); setContextMenu({ type: "track", trackId: track.id, x: e.clientX, y: e.clientY, }); }} >
{track.name}
{track.muted && ( Muted )}
))}
)} {/* Timeline Tracks Content */}
{/* Timeline grid and clips area (with left margin for sifdebar) */}
{tracks.length === 0 ? (

Drop media here to start

) : ( <> {tracks.map((track, index) => (
{ e.preventDefault(); setContextMenu({ type: "track", trackId: track.id, x: e.clientX, y: e.clientY, }); }} onClick={(e) => { // If clicking empty area (not on a clip), deselect all clips if ( !(e.target as HTMLElement).closest(".timeline-clip") ) { const { clearSelectedClips } = useTimelineStore.getState(); clearSelectedClips(); } }} >
))} {/* Playhead for tracks area (scrubbable) */} {tracks.length > 0 && (
)} )} {isDragOver && (
{isProcessing ? `Processing ${progress}%` : "Drop media here to add to timeline"}
)}
{/* Clean Unified Context Menu */} {contextMenu && (
e.preventDefault()} > {contextMenu.type === "track" ? ( // Track context menu <>
) : ( // Clip context menu <>
)}
)}
); } function TimelineTrackContent({ track, zoomLevel, setContextMenu, contextMenu, }: { track: TimelineTrack; zoomLevel: number; setContextMenu: (menu: ContextMenuState | null) => void; contextMenu: ContextMenuState | null; }) { const { mediaItems } = useMediaStore(); const { tracks, moveClipToTrack, updateClipStartTime, addClipToTrack, selectedClips, selectClip, deselectClip, dragState, startDrag: startDragAction, updateDragTime, endDrag: endDragAction, } = useTimelineStore(); const timelineRef = useRef(null); const [isDropping, setIsDropping] = useState(false); const [dropPosition, setDropPosition] = useState(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; const timelineRect = timelineRef.current.getBoundingClientRect(); const mouseX = e.clientX - timelineRect.left; const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); const snappedTime = Math.round(adjustedTime * 10) / 10; updateDragTime(snappedTime); }; const handleMouseUp = () => { if (!dragState.clipId || !dragState.trackId) return; const finalTime = dragState.currentTime; // Check for overlaps and update position const sourceTrack = tracks.find((t) => t.id === dragState.trackId); const movingClip = sourceTrack?.clips.find( (c) => c.id === dragState.clipId ); if (movingClip) { const movingClipDuration = movingClip.duration - movingClip.trimStart - movingClip.trimEnd; const movingClipEnd = finalTime + movingClipDuration; const targetTrack = tracks.find((t) => t.id === track.id); const hasOverlap = targetTrack?.clips.some((existingClip) => { if ( dragState.trackId === track.id && existingClip.id === dragState.clipId ) { return false; } const existingStart = existingClip.startTime; const existingEnd = existingClip.startTime + (existingClip.duration - existingClip.trimStart - existingClip.trimEnd); return finalTime < existingEnd && movingClipEnd > existingStart; }); if (!hasOverlap) { if (dragState.trackId === track.id) { updateClipStartTime(track.id, dragState.clipId, finalTime); } else { moveClipToTrack(dragState.trackId, track.id, dragState.clipId); requestAnimationFrame(() => { updateClipStartTime(track.id, dragState.clipId!, finalTime); }); } } } endDragAction(); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; }, [ dragState.isDragging, dragState.clickOffsetTime, dragState.clipId, dragState.trackId, dragState.currentTime, zoomLevel, tracks, track.id, updateDragTime, updateClipStartTime, moveClipToTrack, endDragAction, ]); const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => { setMouseDownLocation({ x: e.clientX, y: e.clientY }); // Handle multi-selection only in mousedown if (e.metaKey || e.ctrlKey || e.shiftKey) { selectClip(track.id, clip.id, true); } // Calculate the offset from the left edge of the clip to where the user clicked const clipElement = e.currentTarget as HTMLElement; const clipRect = clipElement.getBoundingClientRect(); const clickOffsetX = e.clientX - clipRect.left; const clickOffsetTime = clickOffsetX / (50 * zoomLevel); startDragAction( clip.id, track.id, e.clientX, clip.startTime, clickOffsetTime ); }; const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => { 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; } } // Close context menu if it's open if (contextMenu) { setContextMenu(null); return; // Don't handle selection when closing context menu } // Skip selection logic for multi-selection (handled in mousedown) if (e.metaKey || e.ctrlKey || e.shiftKey) { return; } // Handle single selection/deselection const isSelected = selectedClips.some( (c) => c.trackId === track.id && c.clipId === clip.id ); if (isSelected) { // If clip is selected, deselect it deselectClip(track.id, clip.id); } else { // If clip is not selected, select it (replacing other selections) selectClip(track.id, clip.id, false); } }; const handleClipContextMenu = (e: React.MouseEvent, clipId: string) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ type: "clip", trackId: track.id, clipId: clipId, x: e.clientX, y: e.clientY, }); }; const handleTrackDragOver = (e: React.DragEvent) => { e.preventDefault(); // Handle both timeline clips and media items const hasTimelineClip = e.dataTransfer.types.includes( "application/x-timeline-clip" ); const hasMediaItem = e.dataTransfer.types.includes( "application/x-media-item" ); if (!hasTimelineClip && !hasMediaItem) return; if (hasMediaItem) { try { const mediaItemData = e.dataTransfer.getData( "application/x-media-item" ); if (mediaItemData) { const { type } = JSON.parse(mediaItemData); const isCompatible = (track.type === "video" && (type === "video" || type === "image")) || (track.type === "audio" && type === "audio"); if (!isCompatible) { e.dataTransfer.dropEffect = "none"; return; } } } catch (error) { console.error("Error parsing dropped media item:", error); } } // Calculate drop position for overlap checking const trackContainer = e.currentTarget.querySelector( ".track-clips-container" ) as HTMLElement; let dropTime = 0; if (trackContainer) { const rect = trackContainer.getBoundingClientRect(); const mouseX = Math.max(0, e.clientX - rect.left); dropTime = mouseX / (50 * 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 { id } = JSON.parse(mediaItemData); const mediaItem = mediaItems.find((item) => item.id === id); if (mediaItem) { const newClipDuration = mediaItem.duration || 5; const snappedTime = Math.round(dropTime * 10) / 10; const newClipEnd = snappedTime + newClipDuration; wouldOverlap = track.clips.some((existingClip) => { const existingStart = existingClip.startTime; const existingEnd = existingClip.startTime + (existingClip.duration - existingClip.trimStart - existingClip.trimEnd); return snappedTime < existingEnd && newClipEnd > existingStart; }); } } } catch (error) { // Continue with default behavior } } else if (hasTimelineClip) { try { const timelineClipData = e.dataTransfer.getData( "application/x-timeline-clip" ); if (timelineClipData) { const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData); const sourceTrack = tracks.find( (t: TimelineTrack) => t.id === fromTrackId ); const movingClip = sourceTrack?.clips.find( (c: any) => c.id === clipId ); if (movingClip) { const movingClipDuration = movingClip.duration - movingClip.trimStart - movingClip.trimEnd; const snappedTime = Math.round(dropTime * 10) / 10; const movingClipEnd = snappedTime + movingClipDuration; wouldOverlap = track.clips.some((existingClip) => { if (fromTrackId === track.id && existingClip.id === clipId) return false; const existingStart = existingClip.startTime; const existingEnd = existingClip.startTime + (existingClip.duration - existingClip.trimStart - existingClip.trimEnd); return snappedTime < existingEnd && movingClipEnd > existingStart; }); } } } catch (error) { // Continue with default behavior } } if (wouldOverlap) { e.dataTransfer.dropEffect = "none"; setWouldOverlap(true); setDropPosition(Math.round(dropTime * 10) / 10); return; } e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy"; setWouldOverlap(false); setDropPosition(Math.round(dropTime * 10) / 10); }; const handleTrackDragEnter = (e: React.DragEvent) => { e.preventDefault(); const hasTimelineClip = e.dataTransfer.types.includes( "application/x-timeline-clip" ); const hasMediaItem = e.dataTransfer.types.includes( "application/x-media-item" ); if (!hasTimelineClip && !hasMediaItem) return; dragCounterRef.current++; setIsDropping(true); }; const handleTrackDragLeave = (e: React.DragEvent) => { e.preventDefault(); const hasTimelineClip = e.dataTransfer.types.includes( "application/x-timeline-clip" ); const hasMediaItem = e.dataTransfer.types.includes( "application/x-media-item" ); if (!hasTimelineClip && !hasMediaItem) return; dragCounterRef.current--; if (dragCounterRef.current === 0) { setIsDropping(false); setWouldOverlap(false); setDropPosition(null); } }; const handleTrackDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); // Reset all drag states dragCounterRef.current = 0; setIsDropping(false); setWouldOverlap(false); const currentDropPosition = dropPosition; setDropPosition(null); const hasTimelineClip = e.dataTransfer.types.includes( "application/x-timeline-clip" ); const hasMediaItem = e.dataTransfer.types.includes( "application/x-media-item" ); if (!hasTimelineClip && !hasMediaItem) return; const trackContainer = e.currentTarget.querySelector( ".track-clips-container" ) as HTMLElement; if (!trackContainer) return; const rect = trackContainer.getBoundingClientRect(); const mouseX = Math.max(0, e.clientX - rect.left); const newStartTime = mouseX / (50 * zoomLevel); const snappedTime = Math.round(newStartTime * 10) / 10; try { if (hasTimelineClip) { // Handle timeline clip movement const timelineClipData = e.dataTransfer.getData( "application/x-timeline-clip" ); if (!timelineClipData) return; const { clipId, trackId: fromTrackId, clickOffsetTime = 0, } = JSON.parse(timelineClipData); // Find the clip being moved const sourceTrack = tracks.find( (t: TimelineTrack) => t.id === fromTrackId ); const movingClip = sourceTrack?.clips.find( (c: TypeTimelineClip) => c.id === clipId ); if (!movingClip) { toast.error("Clip not found"); return; } // Adjust position based on where user clicked on the clip const adjustedStartTime = snappedTime - clickOffsetTime; const finalStartTime = Math.max( 0, Math.round(adjustedStartTime * 10) / 10 ); // Check for overlaps with existing clips (excluding the moving clip itself) const movingClipDuration = movingClip.duration - movingClip.trimStart - movingClip.trimEnd; const movingClipEnd = finalStartTime + movingClipDuration; const hasOverlap = track.clips.some((existingClip) => { // Skip the clip being moved if it's on the same track if (fromTrackId === track.id && existingClip.id === clipId) return false; const existingStart = existingClip.startTime; const existingEnd = existingClip.startTime + (existingClip.duration - existingClip.trimStart - existingClip.trimEnd); // Check if clips overlap return finalStartTime < existingEnd && movingClipEnd > existingStart; }); if (hasOverlap) { toast.error( "Cannot move clip here - it would overlap with existing clips" ); return; } if (fromTrackId === track.id) { // Moving within same track updateClipStartTime(track.id, clipId, finalStartTime); } else { // Moving to different track moveClipToTrack(fromTrackId, track.id, clipId); requestAnimationFrame(() => { updateClipStartTime(track.id, clipId, finalStartTime); }); } } else if (hasMediaItem) { // Handle media item drop const mediaItemData = e.dataTransfer.getData( "application/x-media-item" ); if (!mediaItemData) return; const { id, type } = JSON.parse(mediaItemData); const mediaItem = mediaItems.find((item) => item.id === id); if (!mediaItem) { toast.error("Media item not found"); return; } // Check if track type is compatible const isCompatible = (track.type === "video" && (type === "video" || type === "image")) || (track.type === "audio" && type === "audio"); if (!isCompatible) { toast.error(`Cannot add ${type} to ${track.type} track`); return; } // Check for overlaps with existing clips const newClipDuration = mediaItem.duration || 5; const newClipEnd = snappedTime + newClipDuration; const hasOverlap = track.clips.some((existingClip) => { const existingStart = existingClip.startTime; const existingEnd = existingClip.startTime + (existingClip.duration - existingClip.trimStart - existingClip.trimEnd); // Check if clips overlap return snappedTime < existingEnd && newClipEnd > existingStart; }); if (hasOverlap) { toast.error( "Cannot place clip here - it would overlap with existing clips" ); return; } addClipToTrack(track.id, { mediaId: mediaItem.id, name: mediaItem.name, duration: mediaItem.duration || 5, startTime: snappedTime, trimStart: 0, trimEnd: 0, }); toast.success(`Added ${mediaItem.name} to ${track.name}`); } } catch (error) { console.error("Error handling drop:", error); toast.error("Failed to add media to track"); } }; return (
{ e.preventDefault(); // Only show track menu if we didn't click on a clip if (!(e.target as HTMLElement).closest(".timeline-clip")) { setContextMenu({ type: "track", trackId: track.id, x: e.clientX, y: e.clientY, }); } }} onClick={(e) => { // If clicking empty area (not on a clip), deselect all clips if (!(e.target as HTMLElement).closest(".timeline-clip")) { const { clearSelectedClips } = useTimelineStore.getState(); clearSelectedClips(); } }} onDragOver={handleTrackDragOver} onDragEnter={handleTrackDragEnter} onDragLeave={handleTrackDragLeave} onDrop={handleTrackDrop} >
{track.clips.length === 0 ? (
{isDropping ? wouldOverlap ? "Cannot drop - would overlap" : "Drop clip here" : "Drop media here"}
) : ( <> {track.clips.map((clip) => { const isSelected = selectedClips.some( (c) => c.trackId === track.id && c.clipId === clip.id ); return ( ); })} )}
); }