From 9abb502919135782cc51e82fa8a7154a7fc95a80 Mon Sep 17 00:00:00 2001 From: Sompalkar Date: Mon, 23 Jun 2025 14:43:12 +0530 Subject: fix: delete only selected clip from timeline --- apps/web/src/components/editor/timeline.tsx | 805 ++++++++++++++++++++++------ 1 file changed, 655 insertions(+), 150 deletions(-) (limited to 'apps/web/src/components/editor/timeline.tsx') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 7f7794a7..9b0def90 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -10,6 +10,9 @@ import { Snowflake, Copy, SplitSquareHorizontal, + MoreVertical, + Volume2, + VolumeX, } from "lucide-react"; import { Tooltip, @@ -27,7 +30,11 @@ import { toast } from "sonner"; import { useState, useRef, useEffect } from "react"; export function Timeline() { - const { tracks, addTrack, addClipToTrack } = useTimelineStore(); + // 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 } = + useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { currentTime, duration, seek } = usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); @@ -35,15 +42,31 @@ export function Timeline() { const [zoomLevel, setZoomLevel] = useState(1); const dragCounterRef = useRef(0); const timelineRef = useRef(null); + // Track menu state + const [trackMenuOpen, setTrackMenuOpen] = useState(null); + // Context menu state for tracks + const [contextMenu, setContextMenu] = useState<{ + trackId: string; + x: number; + y: number; + } | null>(null); + + // Close context menu on click elsewhere + useEffect(() => { + const handleClick = () => setContextMenu(null); + if (contextMenu) { + window.addEventListener("click", handleClick); + return () => window.removeEventListener("click", handleClick); + } + }, [contextMenu]); const handleDragEnter = (e: React.DragEvent) => { + // When something is dragged over the timeline, show overlay e.preventDefault(); - // Don't show overlay for timeline clips or other internal drags if (e.dataTransfer.types.includes("application/x-timeline-clip")) { return; } - dragCounterRef.current += 1; if (!isDragOver) { setIsDragOver(true); @@ -69,6 +92,7 @@ export function Timeline() { }; const handleDrop = async (e: React.DragEvent) => { + // When media is dropped, add it as a new track/clip e.preventDefault(); setIsDragOver(false); dragCounterRef.current = 0; @@ -79,15 +103,13 @@ export function Timeline() { 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, @@ -96,31 +118,28 @@ export function Timeline() { 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); - try { const processedItems = await processMediaFiles(e.dataTransfer.files); - 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 + (item) => + item.name === processedItem.name && item.url === processedItem.url ); - if (addedItem) { - const trackType = processedItem.type === "audio" ? "audio" : "video"; + const trackType = + processedItem.type === "audio" ? "audio" : "video"; const newTrackId = addTrack(trackType); - addClipToTrack(newTrackId, { mediaId: addedItem.id, name: addedItem.name, @@ -132,6 +151,7 @@ export function Timeline() { } } } catch (error) { + // Show error if file processing fails console.error("Error processing external files:", error); toast.error("Failed to process dropped files"); } finally { @@ -156,7 +176,7 @@ export function Timeline() { const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? -0.05 : 0.05; - setZoomLevel(prev => Math.max(0.1, Math.min(10, prev + delta))); + setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); }; const dragProps = { @@ -168,10 +188,17 @@ export function Timeline() { return (
+ {/* Show overlay when dragging media over the timeline */} + {isDragOver && ( +
+
+ Drop media here to add to timeline +
+
+ )} {/* Toolbar */}
@@ -247,7 +274,9 @@ export function Timeline() {
{/* Track Labels Header */}
- Tracks + + Tracks +
{zoomLevel.toFixed(1)}x
@@ -258,7 +287,9 @@ export function Timeline() {
{/* Time markers */} {(() => { @@ -267,11 +298,11 @@ export function Timeline() { 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 + 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); @@ -281,21 +312,26 @@ export function Timeline() { const time = i * interval; if (time > duration) return null; - const isMainMarker = time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; + const isMainMarker = + time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; return (
- + {(() => { const formatTime = (seconds: number) => { const hours = Math.floor(seconds / 3600); @@ -303,9 +339,9 @@ export function Timeline() { const secs = seconds % 60; if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${Math.floor(secs).toString().padStart(2, '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')}`; + return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; } else if (interval >= 1) { return `${Math.floor(secs)}s`; } else { @@ -351,13 +387,26 @@ export function Timeline() { {tracks.map((track) => (
-
- {track.name} +
+ + {track.name} + + {track.muted && ( + + Muted + + )}
))} @@ -373,7 +422,8 @@ export function Timeline() { className="relative cursor-pointer select-none" style={{ width: `${Math.max(1000, duration * 50 * zoomLevel)}px`, - minHeight: tracks.length > 0 ? `${tracks.length * 60}px` : '200px' + minHeight: + tracks.length > 0 ? `${tracks.length * 60}px` : "200px", }} onClick={handleTimelineClick} onWheel={handleWheel} @@ -384,7 +434,9 @@ export function Timeline() {
-

Drop media here to start

+

+ Drop media here to start +

) : ( @@ -395,10 +447,79 @@ export function Timeline() { className="absolute left-0 right-0 border-b border-muted/30" style={{ top: `${index * 60}px`, - height: '60px' + height: "60px", + }} + // Show context menu on right click + onContextMenu={(e) => { + e.preventDefault(); + setContextMenu({ + trackId: track.id, + x: e.clientX, + y: e.clientY, + }); }} > - + + {/* Render context menu if open for this track */} + {contextMenu && contextMenu.trackId === track.id && ( + setContextMenu(null)} + onSplit={() => { + // Split all clips at playhead + track.clips.forEach((clip) => { + const splitTime = currentTime; + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + + (clip.duration - + clip.trimStart - + clip.trimEnd); + if ( + splitTime > effectiveStart && + splitTime < effectiveEnd + ) { + // First part: adjust original clip + useTimelineStore + .getState() + .updateClipTrim( + track.id, + clip.id, + clip.trimStart, + clip.trimEnd + (effectiveEnd - splitTime) + ); + // Second part: add new clip after split + useTimelineStore + .getState() + .addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (cut)", + duration: clip.duration, + startTime: splitTime, + trimStart: + clip.trimStart + + (splitTime - effectiveStart), + trimEnd: clip.trimEnd, + }); + } + }); + setContextMenu(null); + }} + onMute={() => { + toggleTrackMute(track.id); + setContextMenu(null); + }} + onDelete={() => { + removeTrack(track.id); + setContextMenu(null); + }} + /> + )}
))} @@ -407,7 +528,7 @@ export function Timeline() { className="absolute top-0 w-0.5 bg-red-500 pointer-events-none z-20" style={{ left: `${currentTime * 50 * zoomLevel}px`, - height: `${tracks.length * 60}px` + height: `${tracks.length * 60}px`, }} /> @@ -421,27 +542,131 @@ export function Timeline() { ); } -function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoomLevel: number }) { +function TimelineTrackContent({ + track, + zoomLevel, +}: { + track: TimelineTrack; + zoomLevel: number; +}) { const { mediaItems } = useMediaStore(); - const { tracks, moveClipToTrack, updateClipTrim, updateClipStartTime, addClipToTrack } = useTimelineStore(); + const { + tracks, + moveClipToTrack, + updateClipTrim, + updateClipStartTime, + addClipToTrack, + removeClipFromTrack, + toggleTrackMute, + } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); const [isDropping, setIsDropping] = useState(false); const [dropPosition, setDropPosition] = useState(null); const [isDraggedOver, setIsDraggedOver] = useState(false); const [wouldOverlap, setWouldOverlap] = useState(false); const [resizing, setResizing] = useState<{ clipId: string; - side: 'left' | 'right'; + side: "left" | "right"; startX: number; initialTrimStart: number; initialTrimEnd: number; } | null>(null); const dragCounterRef = useRef(0); + const [clipMenuOpen, setClipMenuOpen] = useState(null); + // Track-level context menu state (for empty areas) + const [trackContextMenu, setTrackContextMenu] = useState<{ + x: number; + y: number; + } | null>(null); + + // Clip-level context menu state (for specific clips) + const [clipContextMenu, setClipContextMenu] = useState<{ + clipId: string; + x: number; + y: number; + } | null>(null); - const handleResizeStart = (e: React.MouseEvent, clipId: string, side: 'left' | 'right') => { + // Auto-dismiss timer ref + const dismissTimerRef = useRef(); + + // Handle right-click on empty track area + const handleTrackContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + // Only show track menu if we didn't click on a clip + if (!(e.target as HTMLElement).closest(".timeline-clip")) { + clearTimeout(dismissTimerRef.current); + setTrackContextMenu({ + x: e.clientX, + y: e.clientY, + }); + // Auto-dismiss after 3 seconds + dismissTimerRef.current = setTimeout(() => { + setTrackContextMenu(null); + }, 3000); + } + }; + + // Handle right-click on a clip + const handleClipContextMenu = (e: React.MouseEvent, clipId: string) => { + e.preventDefault(); + e.stopPropagation(); + clearTimeout(dismissTimerRef.current); + setClipContextMenu({ + clipId, + x: e.clientX, + y: e.clientY, + }); + // Auto-dismiss after 3 seconds + dismissTimerRef.current = setTimeout(() => { + setClipContextMenu(null); + }, 3000); + }; + + // Close context menus when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + // Check if click is outside both menus + const isOutsideTrackMenu = + trackContextMenu && + !(e.target as HTMLElement).closest(".track-context-menu"); + const isOutsideClipMenu = + clipContextMenu && + !(e.target as HTMLElement).closest(".clip-context-menu"); + + if (isOutsideTrackMenu) setTrackContextMenu(null); + if (isOutsideClipMenu) setClipContextMenu(null); + }; + + // Clean up any existing timer when component unmounts + const cleanup = () => clearTimeout(dismissTimerRef.current); + + window.addEventListener("click", handleClickOutside); + return () => { + window.removeEventListener("click", handleClickOutside); + cleanup(); + }; + }, [trackContextMenu, clipContextMenu]); + + // Handle clip deletion + const handleDeleteClip = (clipId: string) => { + // Find the clip to be deleted + const clipToDelete = track.clips.find((c) => c.id === clipId); + if (!clipToDelete) return; + + // Remove only this specific clip from the track + removeClipFromTrack(track.id, clipId); + setClipContextMenu(null); + }; + + const handleResizeStart = ( + e: React.MouseEvent, + clipId: string, + side: "left" | "right" + ) => { e.stopPropagation(); e.preventDefault(); - const clip = track.clips.find(c => c.id === clipId); + const clip = track.clips.find((c) => c.id === clipId); if (!clip) return; setResizing({ @@ -449,30 +674,36 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom side, startX: e.clientX, initialTrimStart: clip.trimStart, - initialTrimEnd: clip.trimEnd + initialTrimEnd: clip.trimEnd, }); }; const updateTrimFromMouseMove = (e: { clientX: number }) => { if (!resizing) return; - const clip = track.clips.find(c => c.id === resizing.clipId); + const clip = track.clips.find((c) => c.id === resizing.clipId); if (!clip) return; const deltaX = e.clientX - resizing.startX; const deltaTime = deltaX / (50 * zoomLevel); - if (resizing.side === 'left') { - const newTrimStart = Math.max(0, Math.min( - clip.duration - clip.trimEnd - 0.1, - resizing.initialTrimStart + deltaTime - )); + if (resizing.side === "left") { + const newTrimStart = Math.max( + 0, + Math.min( + clip.duration - clip.trimEnd - 0.1, + resizing.initialTrimStart + deltaTime + ) + ); updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd); } else { - const newTrimEnd = Math.max(0, Math.min( - clip.duration - clip.trimStart - 0.1, - resizing.initialTrimEnd - deltaTime - )); + const newTrimEnd = Math.max( + 0, + Math.min( + clip.duration - clip.trimStart - 0.1, + resizing.initialTrimEnd - deltaTime + ) + ); updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd); } }; @@ -485,30 +716,13 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom setResizing(null); }; - useEffect(() => { - if (!resizing) return; - - const handleGlobalMouseMove = (e: MouseEvent) => { - updateTrimFromMouseMove(e); - }; - - const handleGlobalMouseUp = () => { - setResizing(null); - }; - - document.addEventListener('mousemove', handleGlobalMouseMove); - document.addEventListener('mouseup', handleGlobalMouseUp); - - return () => { - document.removeEventListener('mousemove', handleGlobalMouseMove); - document.removeEventListener('mouseup', handleGlobalMouseUp); - }; - }, [resizing, track.id, zoomLevel, updateClipTrim]); - const handleClipDragStart = (e: React.DragEvent, clip: any) => { const dragData = { clipId: clip.id, trackId: track.id, name: clip.name }; - e.dataTransfer.setData("application/x-timeline-clip", JSON.stringify(dragData)); + e.dataTransfer.setData( + "application/x-timeline-clip", + JSON.stringify(dragData) + ); e.dataTransfer.effectAllowed = "move"; // Add visual feedback to the dragged element @@ -528,18 +742,25 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom 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"); + 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"); + 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 === "video" && + (type === "video" || type === "image")) || (track.type === "audio" && type === "audio"); if (!isCompatible) { @@ -547,12 +768,13 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom return; } } - } catch (error) { - } + } catch (error) {} } // Calculate drop position for overlap checking - const trackContainer = e.currentTarget.querySelector(".track-clips-container") as HTMLElement; + const trackContainer = e.currentTarget.querySelector( + ".track-clips-container" + ) as HTMLElement; let dropTime = 0; if (trackContainer) { const rect = trackContainer.getBoundingClientRect(); @@ -565,7 +787,9 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom if (hasMediaItem) { try { - const mediaItemData = e.dataTransfer.getData("application/x-media-item"); + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); if (mediaItemData) { const { id } = JSON.parse(mediaItemData); const mediaItem = mediaItems.find((item) => item.id === id); @@ -574,10 +798,14 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom const snappedTime = Math.round(dropTime * 10) / 10; const newClipEnd = snappedTime + newClipDuration; - wouldOverlap = track.clips.some(existingClip => { + wouldOverlap = track.clips.some((existingClip) => { const existingStart = existingClip.startTime; - const existingEnd = existingClip.startTime + (existingClip.duration - existingClip.trimStart - existingClip.trimEnd); - return (snappedTime < existingEnd && newClipEnd > existingStart); + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + return snappedTime < existingEnd && newClipEnd > existingStart; }); } } @@ -586,23 +814,35 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom } } else if (hasTimelineClip) { try { - const timelineClipData = e.dataTransfer.getData("application/x-timeline-clip"); + 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); + 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 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; + 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); + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + return snappedTime < existingEnd && movingClipEnd > existingStart; }); } } @@ -628,8 +868,12 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom 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"); + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); if (!hasTimelineClip && !hasMediaItem) return; @@ -641,8 +885,12 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom 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"); + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); if (!hasTimelineClip && !hasMediaItem) return; @@ -667,12 +915,18 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom 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"); + 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; + const trackContainer = e.currentTarget.querySelector( + ".track-clips-container" + ) as HTMLElement; if (!trackContainer) return; const rect = trackContainer.getBoundingClientRect(); @@ -683,13 +937,17 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom try { if (hasTimelineClip) { // Handle timeline clip movement - const timelineClipData = e.dataTransfer.getData("application/x-timeline-clip"); + const timelineClipData = e.dataTransfer.getData( + "application/x-timeline-clip" + ); if (!timelineClipData) return; const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData); // Find the clip being moved - const sourceTrack = tracks.find((t: TimelineTrack) => t.id === fromTrackId); + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId + ); const movingClip = sourceTrack?.clips.find((c: any) => c.id === clipId); if (!movingClip) { @@ -698,22 +956,30 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom } // Check for overlaps with existing clips (excluding the moving clip itself) - const movingClipDuration = movingClip.duration - movingClip.trimStart - movingClip.trimEnd; + const movingClipDuration = + movingClip.duration - movingClip.trimStart - movingClip.trimEnd; const movingClipEnd = snappedTime + movingClipDuration; - const hasOverlap = track.clips.some(existingClip => { + 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; + if (fromTrackId === track.id && existingClip.id === clipId) + return false; const existingStart = existingClip.startTime; - const existingEnd = existingClip.startTime + (existingClip.duration - existingClip.trimStart - existingClip.trimEnd); + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); // Check if clips overlap - return (snappedTime < existingEnd && movingClipEnd > existingStart); + return snappedTime < existingEnd && movingClipEnd > existingStart; }); if (hasOverlap) { - toast.error("Cannot move clip here - it would overlap with existing clips"); + toast.error( + "Cannot move clip here - it would overlap with existing clips" + ); return; } @@ -727,7 +993,9 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom } } else if (hasMediaItem) { // Handle media item drop - const mediaItemData = e.dataTransfer.getData("application/x-media-item"); + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); if (!mediaItemData) return; const { id, type } = JSON.parse(mediaItemData); @@ -752,16 +1020,22 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom const newClipDuration = mediaItem.duration || 5; const newClipEnd = snappedTime + newClipDuration; - const hasOverlap = track.clips.some(existingClip => { + const hasOverlap = track.clips.some((existingClip) => { const existingStart = existingClip.startTime; - const existingEnd = existingClip.startTime + (existingClip.duration - existingClip.trimStart - existingClip.trimEnd); + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); // Check if clips overlap - return (snappedTime < existingEnd && newClipEnd > existingStart); + return snappedTime < existingEnd && newClipEnd > existingStart; }); if (hasOverlap) { - toast.error("Cannot place clip here - it would overlap with existing clips"); + toast.error( + "Cannot place clip here - it would overlap with existing clips" + ); return; } @@ -839,14 +1113,44 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom ); }; + const handleSplitClip = (clip: any) => { + // Use current playback time as split point + const splitTime = currentTime; + // Only split if splitTime is within the clip's effective range + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; + const firstDuration = splitTime - effectiveStart; + const secondDuration = effectiveEnd - splitTime; + // First part: adjust original clip + updateClipTrim( + track.id, + clip.id, + clip.trimStart, + clip.trimEnd + secondDuration + ); + // Second part: add new clip after split + addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (cut)", + duration: clip.duration, + startTime: splitTime, + trimStart: clip.trimStart + firstDuration, + trimEnd: clip.trimEnd, + }); + }; + return (
{track.clips.length === 0 ? ( -
+
{isDropping ? wouldOverlap ? "Cannot drop - would overlap" @@ -872,8 +1179,12 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom ) : ( <> {track.clips.map((clip) => { - const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; - const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); + const effectiveDuration = + clip.duration - clip.trimStart - clip.trimEnd; + const clipWidth = Math.max( + 80, + effectiveDuration * 50 * zoomLevel + ); const clipLeft = clip.startTime * 50 * zoomLevel; return ( @@ -881,24 +1192,56 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom key={clip.id} className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg`} style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }} + onContextMenu={(e) => handleClipContextMenu(e, clip.id)} > + {/* Left trim handle */}
handleResizeStart(e, clip.id, 'left')} + onMouseDown={(e) => handleResizeStart(e, clip.id, "left")} /> - + {/* Clip content */}
handleClipDragStart(e, clip)} onDragEnd={handleClipDragEnd} > {renderClipContent(clip)} + {/* Clip options menu */} +
+ + {clipMenuOpen === clip.id && ( +
+ + +
+ )} +
- + {/* Right trim handle */}
handleResizeStart(e, clip.id, 'right')} + onMouseDown={(e) => handleResizeStart(e, clip.id, "right")} />
); @@ -907,23 +1250,185 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom {/* Drop position indicator */} {isDraggedOver && dropPosition !== null && (
-
-
-
- {wouldOverlap ? "⚠️" : ""}{dropPosition.toFixed(1)}s +
+
+
+ {wouldOverlap ? "⚠️" : ""} + {dropPosition.toFixed(1)}s
)} )} + + {/* Track Context Menu (for empty areas) */} + {trackContextMenu && ( +
e.preventDefault()} + > + {/* Mute/Unmute option */} + +
+ )} + + {/* Clip Context Menu (for specific clips) */} + {clipContextMenu && + track.clips.some((c) => c.id === clipContextMenu.clipId) && ( +
e.preventDefault()} + > + {/* Split option */} + + {/* Delete clip option */} + +
+ )}
); } + +// Custom context menu for track actions +function TrackContextMenu({ + x, + y, + track, + onClose, + onSplit, + onMute, + onDelete, +}: { + x: number; + y: number; + track: any; + onClose: () => void; + onSplit: () => void; + onMute: () => void; + onDelete: () => void; +}) { + // Small, modern, visually appealing popup + return ( +
e.preventDefault()} + > + {/* Split option */} + + {/* Mute/Unmute option */} + + {/* Delete option */} + +
+ ); +} + + + + + + + + + + + + + + + -- cgit v1.3.1