"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 { Copy, Scissors, Trash2 } from "lucide-react"; import { TimelineElement } from "./timeline-element"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "../ui/context-menu"; import { TimelineTrack, sortTracksByOrder, ensureMainTrack, getMainTrack, canElementGoOnTrack, } from "@/types/timeline"; import { usePlaybackStore } from "@/stores/playback-store"; import type { TimelineElement as TimelineElementType, DragData, } from "@/types/timeline"; export function TimelineTrackContent({ track, zoomLevel, }: { track: TimelineTrack; zoomLevel: number; }) { const { mediaItems } = useMediaStore(); const { tracks, moveElementToTrack, updateElementStartTime, addElementToTrack, selectedElements, selectElement, dragState, startDrag: startDragAction, updateDragTime, endDrag: endDragAction, clearSelectedElements, insertTrackAt, } = 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; // 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 / (50 * zoomLevel)); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); const snappedTime = Math.round(adjustedTime * 10) / 10; updateDragTime(snappedTime); }; const handleMouseUp = (e: MouseEvent) => { if (!dragState.elementId || !dragState.trackId) return; // Check if the mouse is actually over this track const timelineRect = timelineRef.current?.getBoundingClientRect(); if (!timelineRect) return; const isMouseOverThisTrack = e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom; // Only handle if mouse is over this track if (!isMouseOverThisTrack) return; const finalTime = dragState.currentTime; // Check for overlaps and update position const sourceTrack = tracks.find((t) => t.id === dragState.trackId); const movingElement = sourceTrack?.elements.find( (c) => c.id === dragState.elementId ); if (movingElement) { const movingElementDuration = movingElement.duration - movingElement.trimStart - movingElement.trimEnd; const movingElementEnd = finalTime + movingElementDuration; const targetTrack = tracks.find((t) => t.id === track.id); const hasOverlap = targetTrack?.elements.some((existingElement) => { if ( dragState.trackId === track.id && existingElement.id === dragState.elementId ) { return false; } const existingStart = existingElement.startTime; const existingEnd = existingElement.startTime + (existingElement.duration - existingElement.trimStart - existingElement.trimEnd); return finalTime < existingEnd && movingElementEnd > existingStart; }); if (!hasOverlap) { if (dragState.trackId === track.id) { updateElementStartTime(track.id, dragState.elementId, finalTime); } else { moveElementToTrack( dragState.trackId, track.id, dragState.elementId ); requestAnimationFrame(() => { updateElementStartTime(track.id, dragState.elementId!, finalTime); }); } } } endDragAction(); }; 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, ]); 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 / (50 * 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 / (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 dragData: DragData = JSON.parse(mediaItemData); if (dragData.type === "text") { // Text elements have default duration of 5 seconds const newElementDuration = 5; const snappedTime = Math.round(dropTime * 10) / 10; 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 = Math.round(dropTime * 10) / 10; 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 = Math.round(dropTime * 10) / 10; 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); setDropPosition(Math.round(dropTime * 10) / 10); return; } e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; setWouldOverlap(false); setDropPosition(Math.round(dropTime * 10) / 10); }; 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(); // 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 / (50 * zoomLevel); const snappedTime = Math.round(newStartTime * 10) / 10; // Calculate drop position relative to tracks const TRACK_HEIGHT = 60; 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; } // Adjust position based on where user clicked on the element const adjustedStartTime = snappedTime - clickOffsetTime; const finalStartTime = Math.max( 0, Math.round(adjustedStartTime * 10) / 10 ); // Check for overlaps with existing elements (excluding the moving element itself) const movingElementDuration = movingElement.duration - movingElement.trimStart - movingElement.trimEnd; const movingElementEnd = finalStartTime + movingElementDuration; const hasOverlap = track.elements.some((existingElement) => { // Skip the element being moved if it's on the same track if (fromTrackId === track.id && existingElement.id === elementId) return false; const existingStart = existingElement.startTime; const existingEnd = existingElement.startTime + (existingElement.duration - existingElement.trimStart - existingElement.trimEnd); // Check if elements overlap return ( finalStartTime < existingEnd && movingElementEnd > existingStart ); }); if (hasOverlap) { toast.error( "Cannot move element here - it would overlap with existing elements" ); return; } if (fromTrackId === track.id) { // Moving within same track updateElementStartTime(track.id, elementId, finalStartTime); } else { // Moving to different track moveElementToTrack(fromTrackId, track.id, elementId); requestAnimationFrame(() => { updateElementStartTime(track.id, elementId, finalStartTime); }); } } else if (hasMediaItem) { // Handle media item drop const mediaItemData = e.dataTransfer.getData( "application/x-media-item" ); if (!mediaItemData) return; const dragData: DragData = JSON.parse(mediaItemData); if (dragData.type === "text") { let targetTrackId = track.id; let targetTrack = track; // Handle position-aware track creation for text if (track.type !== "text" || dropPosition !== "on") { // Text tracks should go above the main track const mainTrack = getMainTrack(tracks); let insertIndex: number; if (dropPosition === "above") { insertIndex = currentTrackIndex; } else if (dropPosition === "below") { insertIndex = currentTrackIndex + 1; } else { // dropPosition === "on" but track is not text type // Insert above main track if main track exists, otherwise at top if (mainTrack) { const mainTrackIndex = tracks.findIndex( (t) => t.id === mainTrack.id ); insertIndex = mainTrackIndex; } else { insertIndex = 0; // Top of timeline } } targetTrackId = insertTrackAt("text", insertIndex); // Get the updated tracks array after creating the new track const updatedTracks = useTimelineStore.getState().tracks; const newTargetTrack = updatedTracks.find( (t) => t.id === targetTrackId ); if (!newTargetTrack) return; targetTrack = newTargetTrack; } // Check for overlaps with existing elements in target track const newElementDuration = 5; // Default text duration const newElementEnd = snappedTime + 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 snappedTime < 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: 5, startTime: snappedTime, 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") { const needsNewTrack = !isCompatible || dropPosition !== "on"; if (needsNewTrack) { 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 const updatedTracks = ensureMainTrack(tracks); const newMainTrack = getMainTrack(updatedTracks); if (newMainTrack && newMainTrack.elements.length === 0) { targetTrackId = newMainTrack.id; targetTrack = newMainTrack; } else { // Main track was created but somehow has elements, create new media track const mainTrackIndex = updatedTracks.findIndex( (t) => t.id === newMainTrack?.id ); targetTrackId = insertTrackAt("media", mainTrackIndex); const updatedTracksAfterInsert = useTimelineStore.getState().tracks; const newTargetTrack = updatedTracksAfterInsert.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 above main track const mainTrackIndex = tracks.findIndex( (t) => t.id === mainTrack.id ); let insertIndex: number; if (dropPosition === "above") { insertIndex = currentTrackIndex; } else if (dropPosition === "below") { insertIndex = currentTrackIndex + 1; } else { // Insert above main track 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 newElementEnd = snappedTime + 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 snappedTime < 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: snappedTime, trimStart: 0, trimEnd: 0, }); } } } catch (error) { console.error("Error handling drop:", error); toast.error("Failed to add media to track"); } }; return (
{ // 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} >
{track.elements.length === 0 ? (
{isDropping ? wouldOverlap ? "Cannot drop - would overlap" : "Drop element here" : ""}
) : ( <> {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 (
Split at playhead Duplicate {element.type === "text" ? "text" : "clip"} Delete {element.type === "text" ? "text" : "clip"}
); })} )}
); }