cleanup more edge cases, disable overlaying items on the same track

This commit is contained in:
Hyteq
2025-06-23 09:52:26 +03:00
parent e57618a08f
commit 56efb55a0f

View File

@ -73,11 +73,9 @@ export function Timeline() {
setIsDragOver(false); setIsDragOver(false);
dragCounterRef.current = 0; dragCounterRef.current = 0;
const timelineClipData = e.dataTransfer.getData("application/x-timeline-clip");
if (timelineClipData) return;
const mediaItemData = e.dataTransfer.getData("application/x-media-item"); const mediaItemData = e.dataTransfer.getData("application/x-media-item");
if (mediaItemData) { if (mediaItemData) {
// Handle media item drops by creating new tracks
try { try {
const { id, type } = JSON.parse(mediaItemData); const { id, type } = JSON.parse(mediaItemData);
const mediaItem = mediaItems.find((item) => item.id === id); const mediaItem = mediaItems.find((item) => item.id === id);
@ -98,11 +96,14 @@ export function Timeline() {
trimStart: 0, trimStart: 0,
trimEnd: 0, trimEnd: 0,
}); });
toast.success(`Added ${mediaItem.name} to new ${trackType} track`);
} catch (error) { } catch (error) {
console.error("Error parsing media item data:", error); console.error("Error parsing media item data:", error);
toast.error("Failed to add media to timeline"); toast.error("Failed to add media to timeline");
} }
} else if (e.dataTransfer.files?.length > 0) { } else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks
setIsProcessing(true); setIsProcessing(true);
try { try {
@ -171,15 +172,6 @@ export function Timeline() {
}`} }`}
{...dragProps} {...dragProps}
> >
<DragOverlay
isVisible={isDragOver}
title={isProcessing ? "Processing files..." : "Drop media here"}
description={
isProcessing
? "Please wait while files are being processed"
: "Add media to timeline tracks"
}
/>
{/* Toolbar */} {/* Toolbar */}
<div className="border-b flex items-center px-2 py-1 gap-1"> <div className="border-b flex items-center px-2 py-1 gap-1">
@ -431,10 +423,11 @@ export function Timeline() {
function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoomLevel: number }) { function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoomLevel: number }) {
const { mediaItems } = useMediaStore(); const { mediaItems } = useMediaStore();
const { moveClipToTrack, updateClipTrim, updateClipStartTime } = useTimelineStore(); const { tracks, moveClipToTrack, updateClipTrim, updateClipStartTime, addClipToTrack } = useTimelineStore();
const [isDropping, setIsDropping] = useState(false); const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null); const [dropPosition, setDropPosition] = useState<number | null>(null);
const [isDraggedOver, setIsDraggedOver] = useState(false); const [isDraggedOver, setIsDraggedOver] = useState(false);
const [wouldOverlap, setWouldOverlap] = useState(false);
const [resizing, setResizing] = useState<{ const [resizing, setResizing] = useState<{
clipId: string; clipId: string;
side: 'left' | 'right'; side: 'left' | 'right';
@ -533,24 +526,112 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom
const handleTrackDragOver = (e: React.DragEvent) => { const handleTrackDragOver = (e: React.DragEvent) => {
e.preventDefault(); 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; const trackContainer = e.currentTarget.querySelector(".track-clips-container") as HTMLElement;
let dropTime = 0;
if (trackContainer) { if (trackContainer) {
const rect = trackContainer.getBoundingClientRect(); const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left); const mouseX = Math.max(0, e.clientX - rect.left);
const dropTime = mouseX / (50 * zoomLevel); dropTime = mouseX / (50 * zoomLevel);
setDropPosition(dropTime);
} }
// 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) => { const handleTrackDragEnter = (e: React.DragEvent) => {
e.preventDefault(); 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++; dragCounterRef.current++;
setIsDropping(true); setIsDropping(true);
@ -559,13 +640,18 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom
const handleTrackDragLeave = (e: React.DragEvent) => { const handleTrackDragLeave = (e: React.DragEvent) => {
e.preventDefault(); 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--; dragCounterRef.current--;
if (dragCounterRef.current === 0) { if (dragCounterRef.current === 0) {
setIsDropping(false); setIsDropping(false);
setIsDraggedOver(false); setIsDraggedOver(false);
setWouldOverlap(false);
setDropPosition(null); setDropPosition(null);
} }
}; };
@ -577,38 +663,122 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom
dragCounterRef.current = 0; dragCounterRef.current = 0;
setIsDropping(false); setIsDropping(false);
setIsDraggedOver(false); setIsDraggedOver(false);
setWouldOverlap(false);
const currentDropPosition = dropPosition;
setDropPosition(null); 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 (!hasTimelineClip && !hasMediaItem) return;
if (!timelineClipData) 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 { try {
const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData); if (hasTimelineClip) {
const trackContainer = e.currentTarget.querySelector(".track-clips-container") as HTMLElement; // 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(); // Find the clip being moved
const mouseX = Math.max(0, e.clientX - rect.left); const sourceTrack = tracks.find((t: TimelineTrack) => t.id === fromTrackId);
const newStartTime = mouseX / (50 * zoomLevel); const movingClip = sourceTrack?.clips.find((c: any) => c.id === clipId);
// Snap to grid (optional - every 0.1 seconds) if (!movingClip) {
const snappedTime = Math.round(newStartTime * 10) / 10; toast.error("Clip not found");
return;
}
if (fromTrackId === track.id) { // Check for overlaps with existing clips (excluding the moving clip itself)
updateClipStartTime(track.id, clipId, snappedTime); const movingClipDuration = movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
} else { const movingClipEnd = snappedTime + movingClipDuration;
moveClipToTrack(fromTrackId, track.id, clipId);
// Use a small delay to ensure the clip is moved before updating position const hasOverlap = track.clips.some(existingClip => {
requestAnimationFrame(() => { // Skip the clip being moved if it's on the same track
updateClipStartTime(track.id, clipId, snappedTime); 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) { } catch (error) {
console.error("Error moving clip:", error); console.error("Error handling drop:", error);
toast.error("Failed to move clip"); toast.error("Failed to add media to track");
} }
}; };
@ -672,7 +842,9 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom
return ( return (
<div <div
className={`w-full h-full transition-all duration-150 ease-out ${isDraggedOver className={`w-full h-full transition-all duration-150 ease-out ${isDraggedOver
? "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg" ? wouldOverlap
? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg"
: "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
: "hover:bg-muted/20" : "hover:bg-muted/20"
}`} }`}
onDragOver={handleTrackDragOver} onDragOver={handleTrackDragOver}
@ -685,9 +857,17 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom
> >
<div className="h-full relative track-clips-container min-w-full"> <div className="h-full relative track-clips-container min-w-full">
{track.clips.length === 0 ? ( {track.clips.length === 0 ? (
<div className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping ? "border-blue-500 bg-blue-500/10 text-blue-600" : "border-muted/30" <div className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping
? wouldOverlap
? "border-red-500 bg-red-500/10 text-red-600"
: "border-blue-500 bg-blue-500/10 text-blue-600"
: "border-muted/30"
}`}> }`}>
{isDropping ? "Drop clip here" : "Drop media here"} {isDropping
? wouldOverlap
? "Cannot drop - would overlap"
: "Drop clip here"
: "Drop media here"}
</div> </div>
) : ( ) : (
<> <>
@ -727,13 +907,17 @@ function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoom
{/* Drop position indicator */} {/* Drop position indicator */}
{isDraggedOver && dropPosition !== null && ( {isDraggedOver && dropPosition !== null && (
<div <div
className="absolute top-0 bottom-0 w-1 bg-blue-500 pointer-events-none z-30 transition-all duration-75 ease-out" className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
}`}
style={{ left: `${dropPosition * 50 * zoomLevel}px`, transform: 'translateX(-50%)' }} style={{ left: `${dropPosition * 50 * zoomLevel}px`, transform: 'translateX(-50%)' }}
> >
<div className="absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-blue-500 rounded-full border-2 border-white shadow-md" /> <div className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-blue-500 rounded-full border-2 border-white shadow-md" /> }`} />
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white bg-blue-500 px-1 py-0.5 rounded whitespace-nowrap"> <div className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
{dropPosition.toFixed(1)}s }`} />
<div className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
}`}>
{wouldOverlap ? "⚠️" : ""}{dropPosition.toFixed(1)}s
</div> </div>
</div> </div>
)} )}