refactor: separate timeline track content into new file
This commit is contained in:
660
apps/web/src/components/editor/timeline-track.tsx
Normal file
660
apps/web/src/components/editor/timeline-track.tsx
Normal file
@ -0,0 +1,660 @@
|
|||||||
|
"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 { TimelineClip } from "./timeline-clip";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "../ui/context-menu";
|
||||||
|
import {
|
||||||
|
TimelineTrack,
|
||||||
|
TimelineClip as TypeTimelineClip,
|
||||||
|
} from "@/stores/timeline-store";
|
||||||
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
|
|
||||||
|
export function TimelineTrackContent({
|
||||||
|
track,
|
||||||
|
zoomLevel,
|
||||||
|
}: {
|
||||||
|
track: TimelineTrack;
|
||||||
|
zoomLevel: number;
|
||||||
|
}) {
|
||||||
|
const { mediaItems } = useMediaStore();
|
||||||
|
const {
|
||||||
|
tracks,
|
||||||
|
moveClipToTrack,
|
||||||
|
updateClipStartTime,
|
||||||
|
addClipToTrack,
|
||||||
|
selectedClips,
|
||||||
|
selectClip,
|
||||||
|
deselectClip,
|
||||||
|
dragState,
|
||||||
|
startDrag: startDragAction,
|
||||||
|
updateDragTime,
|
||||||
|
endDrag: endDragAction,
|
||||||
|
} = useTimelineStore();
|
||||||
|
|
||||||
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDropping, setIsDropping] = useState(false);
|
||||||
|
const [dropPosition, setDropPosition] = useState<number | null>(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;
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
if (!dragState.clipId || !dragState.trackId) return;
|
||||||
|
|
||||||
|
const finalTime = dragState.currentTime;
|
||||||
|
|
||||||
|
// Check for overlaps and update position
|
||||||
|
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||||
|
const movingClip = sourceTrack?.clips.find(
|
||||||
|
(c) => c.id === dragState.clipId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (movingClip) {
|
||||||
|
const movingClipDuration =
|
||||||
|
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||||
|
const movingClipEnd = finalTime + movingClipDuration;
|
||||||
|
|
||||||
|
const targetTrack = tracks.find((t) => t.id === track.id);
|
||||||
|
const hasOverlap = targetTrack?.clips.some((existingClip) => {
|
||||||
|
if (
|
||||||
|
dragState.trackId === track.id &&
|
||||||
|
existingClip.id === dragState.clipId
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const existingStart = existingClip.startTime;
|
||||||
|
const existingEnd =
|
||||||
|
existingClip.startTime +
|
||||||
|
(existingClip.duration -
|
||||||
|
existingClip.trimStart -
|
||||||
|
existingClip.trimEnd);
|
||||||
|
return finalTime < existingEnd && movingClipEnd > existingStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasOverlap) {
|
||||||
|
if (dragState.trackId === track.id) {
|
||||||
|
updateClipStartTime(track.id, dragState.clipId, finalTime);
|
||||||
|
} else {
|
||||||
|
moveClipToTrack(dragState.trackId, track.id, dragState.clipId);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateClipStartTime(track.id, dragState.clipId!, finalTime);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endDragAction();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
dragState.isDragging,
|
||||||
|
dragState.clickOffsetTime,
|
||||||
|
dragState.clipId,
|
||||||
|
dragState.trackId,
|
||||||
|
dragState.currentTime,
|
||||||
|
zoomLevel,
|
||||||
|
tracks,
|
||||||
|
track.id,
|
||||||
|
updateDragTime,
|
||||||
|
updateClipStartTime,
|
||||||
|
moveClipToTrack,
|
||||||
|
endDragAction,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => {
|
||||||
|
setMouseDownLocation({ x: e.clientX, y: e.clientY });
|
||||||
|
// Handle multi-selection only in mousedown
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||||
|
selectClip(track.id, clip.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the offset from the left edge of the clip to where the user clicked
|
||||||
|
const clipElement = e.currentTarget as HTMLElement;
|
||||||
|
const clipRect = clipElement.getBoundingClientRect();
|
||||||
|
const clickOffsetX = e.clientX - clipRect.left;
|
||||||
|
const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
|
||||||
|
|
||||||
|
startDragAction(
|
||||||
|
clip.id,
|
||||||
|
track.id,
|
||||||
|
e.clientX,
|
||||||
|
clip.startTime,
|
||||||
|
clickOffsetTime
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => {
|
||||||
|
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/deselection
|
||||||
|
const isSelected = selectedClips.some(
|
||||||
|
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
// If clip is selected, deselect it
|
||||||
|
deselectClip(track.id, clip.id);
|
||||||
|
} else {
|
||||||
|
// If clip is not selected, select it (replacing other selections)
|
||||||
|
selectClip(track.id, clip.id, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrackDragOver = (e: React.DragEvent) => {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
console.error("Error parsing dropped media item:", 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);
|
||||||
|
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";
|
||||||
|
setWouldOverlap(true);
|
||||||
|
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
|
||||||
|
setWouldOverlap(false);
|
||||||
|
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasTimelineClip && !hasMediaItem) return;
|
||||||
|
|
||||||
|
dragCounterRef.current++;
|
||||||
|
setIsDropping(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasTimelineClip && !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 currentDropPosition = dropPosition;
|
||||||
|
setDropPosition(null);
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 {
|
||||||
|
if (hasTimelineClip) {
|
||||||
|
// Handle timeline clip movement
|
||||||
|
const timelineClipData = e.dataTransfer.getData(
|
||||||
|
"application/x-timeline-clip"
|
||||||
|
);
|
||||||
|
if (!timelineClipData) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
clipId,
|
||||||
|
trackId: fromTrackId,
|
||||||
|
clickOffsetTime = 0,
|
||||||
|
} = JSON.parse(timelineClipData);
|
||||||
|
|
||||||
|
// Find the clip being moved
|
||||||
|
const sourceTrack = tracks.find(
|
||||||
|
(t: TimelineTrack) => t.id === fromTrackId
|
||||||
|
);
|
||||||
|
const movingClip = sourceTrack?.clips.find(
|
||||||
|
(c: TypeTimelineClip) => c.id === clipId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!movingClip) {
|
||||||
|
toast.error("Clip not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust position based on where user clicked on the clip
|
||||||
|
const adjustedStartTime = snappedTime - clickOffsetTime;
|
||||||
|
const finalStartTime = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round(adjustedStartTime * 10) / 10
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for overlaps with existing clips (excluding the moving clip itself)
|
||||||
|
const movingClipDuration =
|
||||||
|
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||||
|
const movingClipEnd = finalStartTime + 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 finalStartTime < existingEnd && movingClipEnd > existingStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasOverlap) {
|
||||||
|
toast.error(
|
||||||
|
"Cannot move clip here - it would overlap with existing clips"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromTrackId === track.id) {
|
||||||
|
// Moving within same track
|
||||||
|
updateClipStartTime(track.id, clipId, finalStartTime);
|
||||||
|
} else {
|
||||||
|
// Moving to different track
|
||||||
|
moveClipToTrack(fromTrackId, track.id, clipId);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateClipStartTime(track.id, clipId, finalStartTime);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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 handling drop:", error);
|
||||||
|
toast.error("Failed to add media to track");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full h-full hover:bg-muted/20"
|
||||||
|
onClick={(e) => {
|
||||||
|
// If clicking empty area (not on a clip), deselect all clips
|
||||||
|
if (!(e.target as HTMLElement).closest(".timeline-clip")) {
|
||||||
|
const { clearSelectedClips } = useTimelineStore.getState();
|
||||||
|
clearSelectedClips();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={handleTrackDragOver}
|
||||||
|
onDragEnter={handleTrackDragEnter}
|
||||||
|
onDragLeave={handleTrackDragLeave}
|
||||||
|
onDrop={handleTrackDrop}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={timelineRef}
|
||||||
|
className="h-full relative track-clips-container min-w-full"
|
||||||
|
>
|
||||||
|
{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
|
||||||
|
? 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
|
||||||
|
? wouldOverlap
|
||||||
|
? "Cannot drop - would overlap"
|
||||||
|
: "Drop clip here"
|
||||||
|
: "Drop media here"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{track.clips.map((clip) => {
|
||||||
|
const isSelected = selectedClips.some(
|
||||||
|
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClipSplit = () => {
|
||||||
|
const { currentTime } = usePlaybackStore();
|
||||||
|
const { updateClipTrim, addClipToTrack } = useTimelineStore();
|
||||||
|
const splitTime = currentTime;
|
||||||
|
const effectiveStart = clip.startTime;
|
||||||
|
const effectiveEnd =
|
||||||
|
clip.startTime +
|
||||||
|
(clip.duration - clip.trimStart - clip.trimEnd);
|
||||||
|
|
||||||
|
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
||||||
|
updateClipTrim(
|
||||||
|
track.id,
|
||||||
|
clip.id,
|
||||||
|
clip.trimStart,
|
||||||
|
clip.trimEnd + (effectiveEnd - splitTime)
|
||||||
|
);
|
||||||
|
addClipToTrack(track.id, {
|
||||||
|
mediaId: clip.mediaId,
|
||||||
|
name: clip.name + " (split)",
|
||||||
|
duration: clip.duration,
|
||||||
|
startTime: splitTime,
|
||||||
|
trimStart: clip.trimStart + (splitTime - effectiveStart),
|
||||||
|
trimEnd: clip.trimEnd,
|
||||||
|
});
|
||||||
|
toast.success("Clip split successfully");
|
||||||
|
} else {
|
||||||
|
toast.error("Playhead must be within clip to split");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClipDuplicate = () => {
|
||||||
|
const { addClipToTrack } = useTimelineStore.getState();
|
||||||
|
addClipToTrack(track.id, {
|
||||||
|
mediaId: clip.mediaId,
|
||||||
|
name: clip.name + " (copy)",
|
||||||
|
duration: clip.duration,
|
||||||
|
startTime:
|
||||||
|
clip.startTime +
|
||||||
|
(clip.duration - clip.trimStart - clip.trimEnd) +
|
||||||
|
0.1,
|
||||||
|
trimStart: clip.trimStart,
|
||||||
|
trimEnd: clip.trimEnd,
|
||||||
|
});
|
||||||
|
toast.success("Clip duplicated");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClipDelete = () => {
|
||||||
|
const { removeClipFromTrack } = useTimelineStore.getState();
|
||||||
|
removeClipFromTrack(track.id, clip.id);
|
||||||
|
toast.success("Clip deleted");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu key={clip.id}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<TimelineClip
|
||||||
|
clip={clip}
|
||||||
|
track={track}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onClipMouseDown={handleClipMouseDown}
|
||||||
|
onClipClick={handleClipClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onClick={handleClipSplit}>
|
||||||
|
<Scissors className="h-4 w-4 mr-2" />
|
||||||
|
Split at Playhead
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={handleClipDuplicate}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
Duplicate Clip
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={handleClipDelete}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete Clip
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -28,14 +28,9 @@ import {
|
|||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "../ui/context-menu";
|
} from "../ui/context-menu";
|
||||||
import {
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
useTimelineStore,
|
|
||||||
type TimelineTrack,
|
|
||||||
type TimelineClip as TypeTimelineClip,
|
|
||||||
} from "@/stores/timeline-store";
|
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
import { useMediaStore } from "@/stores/media-store";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import { useDragClip } from "@/hooks/use-drag-clip";
|
|
||||||
import { processMediaFiles } from "@/lib/media-processing";
|
import { processMediaFiles } from "@/lib/media-processing";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
@ -46,7 +41,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "../ui/select";
|
} from "../ui/select";
|
||||||
import { TimelineClip } from "./timeline-clip";
|
import { TimelineTrackContent } from "./timeline-track";
|
||||||
|
|
||||||
export function Timeline() {
|
export function Timeline() {
|
||||||
// Timeline shows all tracks (video, audio, effects) and their clips.
|
// Timeline shows all tracks (video, audio, effects) and their clips.
|
||||||
@ -1150,643 +1145,3 @@ export function Timeline() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineTrackContent({
|
|
||||||
track,
|
|
||||||
zoomLevel,
|
|
||||||
}: {
|
|
||||||
track: TimelineTrack;
|
|
||||||
zoomLevel: number;
|
|
||||||
}) {
|
|
||||||
const { mediaItems } = useMediaStore();
|
|
||||||
const {
|
|
||||||
tracks,
|
|
||||||
moveClipToTrack,
|
|
||||||
updateClipStartTime,
|
|
||||||
addClipToTrack,
|
|
||||||
selectedClips,
|
|
||||||
selectClip,
|
|
||||||
deselectClip,
|
|
||||||
dragState,
|
|
||||||
startDrag: startDragAction,
|
|
||||||
updateDragTime,
|
|
||||||
endDrag: endDragAction,
|
|
||||||
} = useTimelineStore();
|
|
||||||
|
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [isDropping, setIsDropping] = useState(false);
|
|
||||||
const [dropPosition, setDropPosition] = useState<number | null>(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;
|
|
||||||
|
|
||||||
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 = () => {
|
|
||||||
if (!dragState.clipId || !dragState.trackId) return;
|
|
||||||
|
|
||||||
const finalTime = dragState.currentTime;
|
|
||||||
|
|
||||||
// Check for overlaps and update position
|
|
||||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
|
||||||
const movingClip = sourceTrack?.clips.find(
|
|
||||||
(c) => c.id === dragState.clipId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (movingClip) {
|
|
||||||
const movingClipDuration =
|
|
||||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
|
||||||
const movingClipEnd = finalTime + movingClipDuration;
|
|
||||||
|
|
||||||
const targetTrack = tracks.find((t) => t.id === track.id);
|
|
||||||
const hasOverlap = targetTrack?.clips.some((existingClip) => {
|
|
||||||
if (
|
|
||||||
dragState.trackId === track.id &&
|
|
||||||
existingClip.id === dragState.clipId
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const existingStart = existingClip.startTime;
|
|
||||||
const existingEnd =
|
|
||||||
existingClip.startTime +
|
|
||||||
(existingClip.duration -
|
|
||||||
existingClip.trimStart -
|
|
||||||
existingClip.trimEnd);
|
|
||||||
return finalTime < existingEnd && movingClipEnd > existingStart;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasOverlap) {
|
|
||||||
if (dragState.trackId === track.id) {
|
|
||||||
updateClipStartTime(track.id, dragState.clipId, finalTime);
|
|
||||||
} else {
|
|
||||||
moveClipToTrack(dragState.trackId, track.id, dragState.clipId);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
updateClipStartTime(track.id, dragState.clipId!, finalTime);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endDragAction();
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
dragState.isDragging,
|
|
||||||
dragState.clickOffsetTime,
|
|
||||||
dragState.clipId,
|
|
||||||
dragState.trackId,
|
|
||||||
dragState.currentTime,
|
|
||||||
zoomLevel,
|
|
||||||
tracks,
|
|
||||||
track.id,
|
|
||||||
updateDragTime,
|
|
||||||
updateClipStartTime,
|
|
||||||
moveClipToTrack,
|
|
||||||
endDragAction,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => {
|
|
||||||
setMouseDownLocation({ x: e.clientX, y: e.clientY });
|
|
||||||
// Handle multi-selection only in mousedown
|
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
|
||||||
selectClip(track.id, clip.id, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the offset from the left edge of the clip to where the user clicked
|
|
||||||
const clipElement = e.currentTarget as HTMLElement;
|
|
||||||
const clipRect = clipElement.getBoundingClientRect();
|
|
||||||
const clickOffsetX = e.clientX - clipRect.left;
|
|
||||||
const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
|
|
||||||
|
|
||||||
startDragAction(
|
|
||||||
clip.id,
|
|
||||||
track.id,
|
|
||||||
e.clientX,
|
|
||||||
clip.startTime,
|
|
||||||
clickOffsetTime
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => {
|
|
||||||
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/deselection
|
|
||||||
const isSelected = selectedClips.some(
|
|
||||||
(c) => c.trackId === track.id && c.clipId === clip.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isSelected) {
|
|
||||||
// If clip is selected, deselect it
|
|
||||||
deselectClip(track.id, clip.id);
|
|
||||||
} else {
|
|
||||||
// If clip is not selected, select it (replacing other selections)
|
|
||||||
selectClip(track.id, clip.id, false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTrackDragOver = (e: React.DragEvent) => {
|
|
||||||
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"
|
|
||||||
);
|
|
||||||
|
|
||||||
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) {
|
|
||||||
console.error("Error parsing dropped media item:", 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);
|
|
||||||
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";
|
|
||||||
setWouldOverlap(true);
|
|
||||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
|
|
||||||
setWouldOverlap(false);
|
|
||||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
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"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasTimelineClip && !hasMediaItem) return;
|
|
||||||
|
|
||||||
dragCounterRef.current++;
|
|
||||||
setIsDropping(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
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"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasTimelineClip && !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 currentDropPosition = dropPosition;
|
|
||||||
setDropPosition(null);
|
|
||||||
|
|
||||||
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;
|
|
||||||
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 {
|
|
||||||
if (hasTimelineClip) {
|
|
||||||
// Handle timeline clip movement
|
|
||||||
const timelineClipData = e.dataTransfer.getData(
|
|
||||||
"application/x-timeline-clip"
|
|
||||||
);
|
|
||||||
if (!timelineClipData) return;
|
|
||||||
|
|
||||||
const {
|
|
||||||
clipId,
|
|
||||||
trackId: fromTrackId,
|
|
||||||
clickOffsetTime = 0,
|
|
||||||
} = JSON.parse(timelineClipData);
|
|
||||||
|
|
||||||
// Find the clip being moved
|
|
||||||
const sourceTrack = tracks.find(
|
|
||||||
(t: TimelineTrack) => t.id === fromTrackId
|
|
||||||
);
|
|
||||||
const movingClip = sourceTrack?.clips.find(
|
|
||||||
(c: TypeTimelineClip) => c.id === clipId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!movingClip) {
|
|
||||||
toast.error("Clip not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust position based on where user clicked on the clip
|
|
||||||
const adjustedStartTime = snappedTime - clickOffsetTime;
|
|
||||||
const finalStartTime = Math.max(
|
|
||||||
0,
|
|
||||||
Math.round(adjustedStartTime * 10) / 10
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check for overlaps with existing clips (excluding the moving clip itself)
|
|
||||||
const movingClipDuration =
|
|
||||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
|
||||||
const movingClipEnd = finalStartTime + 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 finalStartTime < existingEnd && movingClipEnd > existingStart;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasOverlap) {
|
|
||||||
toast.error(
|
|
||||||
"Cannot move clip here - it would overlap with existing clips"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fromTrackId === track.id) {
|
|
||||||
// Moving within same track
|
|
||||||
updateClipStartTime(track.id, clipId, finalStartTime);
|
|
||||||
} else {
|
|
||||||
// Moving to different track
|
|
||||||
moveClipToTrack(fromTrackId, track.id, clipId);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
updateClipStartTime(track.id, clipId, finalStartTime);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} 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 handling drop:", error);
|
|
||||||
toast.error("Failed to add media to track");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-full h-full hover:bg-muted/20"
|
|
||||||
onClick={(e) => {
|
|
||||||
// If clicking empty area (not on a clip), deselect all clips
|
|
||||||
if (!(e.target as HTMLElement).closest(".timeline-clip")) {
|
|
||||||
const { clearSelectedClips } = useTimelineStore.getState();
|
|
||||||
clearSelectedClips();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragOver={handleTrackDragOver}
|
|
||||||
onDragEnter={handleTrackDragEnter}
|
|
||||||
onDragLeave={handleTrackDragLeave}
|
|
||||||
onDrop={handleTrackDrop}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={timelineRef}
|
|
||||||
className="h-full relative track-clips-container min-w-full"
|
|
||||||
>
|
|
||||||
{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
|
|
||||||
? 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
|
|
||||||
? wouldOverlap
|
|
||||||
? "Cannot drop - would overlap"
|
|
||||||
: "Drop clip here"
|
|
||||||
: "Drop media here"}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{track.clips.map((clip) => {
|
|
||||||
const isSelected = selectedClips.some(
|
|
||||||
(c) => c.trackId === track.id && c.clipId === clip.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClipSplit = () => {
|
|
||||||
const { currentTime } = usePlaybackStore();
|
|
||||||
const { updateClipTrim, addClipToTrack } = useTimelineStore();
|
|
||||||
const splitTime = currentTime;
|
|
||||||
const effectiveStart = clip.startTime;
|
|
||||||
const effectiveEnd =
|
|
||||||
clip.startTime +
|
|
||||||
(clip.duration - clip.trimStart - clip.trimEnd);
|
|
||||||
|
|
||||||
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
|
||||||
updateClipTrim(
|
|
||||||
track.id,
|
|
||||||
clip.id,
|
|
||||||
clip.trimStart,
|
|
||||||
clip.trimEnd + (effectiveEnd - splitTime)
|
|
||||||
);
|
|
||||||
addClipToTrack(track.id, {
|
|
||||||
mediaId: clip.mediaId,
|
|
||||||
name: clip.name + " (split)",
|
|
||||||
duration: clip.duration,
|
|
||||||
startTime: splitTime,
|
|
||||||
trimStart: clip.trimStart + (splitTime - effectiveStart),
|
|
||||||
trimEnd: clip.trimEnd,
|
|
||||||
});
|
|
||||||
toast.success("Clip split successfully");
|
|
||||||
} else {
|
|
||||||
toast.error("Playhead must be within clip to split");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClipDuplicate = () => {
|
|
||||||
const { addClipToTrack } = useTimelineStore.getState();
|
|
||||||
addClipToTrack(track.id, {
|
|
||||||
mediaId: clip.mediaId,
|
|
||||||
name: clip.name + " (copy)",
|
|
||||||
duration: clip.duration,
|
|
||||||
startTime:
|
|
||||||
clip.startTime +
|
|
||||||
(clip.duration - clip.trimStart - clip.trimEnd) +
|
|
||||||
0.1,
|
|
||||||
trimStart: clip.trimStart,
|
|
||||||
trimEnd: clip.trimEnd,
|
|
||||||
});
|
|
||||||
toast.success("Clip duplicated");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClipDelete = () => {
|
|
||||||
const { removeClipFromTrack } = useTimelineStore.getState();
|
|
||||||
removeClipFromTrack(track.id, clip.id);
|
|
||||||
toast.success("Clip deleted");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu key={clip.id}>
|
|
||||||
<ContextMenuTrigger asChild>
|
|
||||||
<div>
|
|
||||||
<TimelineClip
|
|
||||||
clip={clip}
|
|
||||||
track={track}
|
|
||||||
zoomLevel={zoomLevel}
|
|
||||||
isSelected={isSelected}
|
|
||||||
onClipMouseDown={handleClipMouseDown}
|
|
||||||
onClipClick={handleClipClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuItem onClick={handleClipSplit}>
|
|
||||||
<Scissors className="h-4 w-4 mr-2" />
|
|
||||||
Split at Playhead
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onClick={handleClipDuplicate}>
|
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
|
||||||
Duplicate Clip
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={handleClipDelete}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
Delete Clip
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
Reference in New Issue
Block a user