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
)}