From 56efb55a0fbcd22fefd6d8c5cf4300166b7f168a Mon Sep 17 00:00:00 2001 From: Hyteq Date: Mon, 23 Jun 2025 09:52:26 +0300 Subject: [PATCH] cleanup more edge cases, disable overlaying items on the same track --- apps/web/src/components/editor/timeline.tsx | 282 ++++++++++++++++---- 1 file changed, 233 insertions(+), 49 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 03f7f6a..7f7794a 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -73,11 +73,9 @@ export function Timeline() { setIsDragOver(false); dragCounterRef.current = 0; - const timelineClipData = e.dataTransfer.getData("application/x-timeline-clip"); - if (timelineClipData) 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); @@ -98,11 +96,14 @@ export function Timeline() { trimStart: 0, trimEnd: 0, }); + + toast.success(`Added ${mediaItem.name} to new ${trackType} track`); } catch (error) { 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 { @@ -171,15 +172,6 @@ export function Timeline() { }`} {...dragProps} > - {/* Toolbar */}
@@ -431,10 +423,11 @@ export function Timeline() { function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoomLevel: number }) { const { mediaItems } = useMediaStore(); - const { moveClipToTrack, updateClipTrim, updateClipStartTime } = useTimelineStore(); + const { tracks, moveClipToTrack, updateClipTrim, updateClipStartTime, addClipToTrack } = useTimelineStore(); 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'; @@ -533,24 +526,112 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom const handleTrackDragOver = (e: React.DragEvent) => { e.preventDefault(); - if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return; - e.dataTransfer.dropEffect = "move"; - setIsDraggedOver(true); + // 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"); - // Calculate and show drop position with better precision + 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) { + } + } + + // 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); - const dropTime = mouseX / (50 * zoomLevel); - setDropPosition(dropTime); + 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"; + setIsDraggedOver(true); + setWouldOverlap(true); + setDropPosition(Math.round(dropTime * 10) / 10); + return; + } + + e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy"; + setIsDraggedOver(true); + setWouldOverlap(false); + setDropPosition(Math.round(dropTime * 10) / 10); }; const handleTrackDragEnter = (e: React.DragEvent) => { e.preventDefault(); - if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return; + + 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); @@ -559,13 +640,18 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom const handleTrackDragLeave = (e: React.DragEvent) => { e.preventDefault(); - if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return; + + 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); setIsDraggedOver(false); + setWouldOverlap(false); setDropPosition(null); } }; @@ -577,38 +663,122 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom dragCounterRef.current = 0; setIsDropping(false); setIsDraggedOver(false); + setWouldOverlap(false); + const currentDropPosition = dropPosition; setDropPosition(null); - if (!e.dataTransfer.types.includes("application/x-timeline-clip")) return; + const hasTimelineClip = e.dataTransfer.types.includes("application/x-timeline-clip"); + const hasMediaItem = e.dataTransfer.types.includes("application/x-media-item"); - const timelineClipData = e.dataTransfer.getData("application/x-timeline-clip"); - if (!timelineClipData) return; + 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 { - const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData); - const trackContainer = e.currentTarget.querySelector(".track-clips-container") as HTMLElement; + if (hasTimelineClip) { + // Handle timeline clip movement + const timelineClipData = e.dataTransfer.getData("application/x-timeline-clip"); + if (!timelineClipData) return; - if (!trackContainer) return; + const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData); - const rect = trackContainer.getBoundingClientRect(); - const mouseX = Math.max(0, e.clientX - rect.left); - const newStartTime = mouseX / (50 * zoomLevel); + // Find the clip being moved + const sourceTrack = tracks.find((t: TimelineTrack) => t.id === fromTrackId); + const movingClip = sourceTrack?.clips.find((c: any) => c.id === clipId); - // Snap to grid (optional - every 0.1 seconds) - const snappedTime = Math.round(newStartTime * 10) / 10; + if (!movingClip) { + toast.error("Clip not found"); + return; + } - if (fromTrackId === track.id) { - updateClipStartTime(track.id, clipId, snappedTime); - } else { - moveClipToTrack(fromTrackId, track.id, clipId); - // Use a small delay to ensure the clip is moved before updating position - requestAnimationFrame(() => { - updateClipStartTime(track.id, clipId, snappedTime); + // Check for overlaps with existing clips (excluding the moving clip itself) + const movingClipDuration = movingClip.duration - movingClip.trimStart - movingClip.trimEnd; + const movingClipEnd = snappedTime + 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 (snappedTime < existingEnd && movingClipEnd > existingStart); }); + + if (hasOverlap) { + toast.error("Cannot move clip here - it would overlap with existing clips"); + return; + } + + if (fromTrackId === track.id) { + updateClipStartTime(track.id, clipId, snappedTime); + } else { + moveClipToTrack(fromTrackId, track.id, clipId); + requestAnimationFrame(() => { + updateClipStartTime(track.id, clipId, snappedTime); + }); + } + } 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 moving clip:", error); - toast.error("Failed to move clip"); + console.error("Error handling drop:", error); + toast.error("Failed to add media to track"); } }; @@ -672,7 +842,9 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom return (
{track.clips.length === 0 ? ( -
- {isDropping ? "Drop clip here" : "Drop media here"} + {isDropping + ? wouldOverlap + ? "Cannot drop - would overlap" + : "Drop clip here" + : "Drop media here"}
) : ( <> @@ -727,13 +907,17 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom {/* Drop position indicator */} {isDraggedOver && dropPosition !== null && (
-
-
-
- {dropPosition.toFixed(1)}s +
+
+
+ {wouldOverlap ? "⚠️" : ""}{dropPosition.toFixed(1)}s
)}