summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/components/editor/timeline-track.tsx660
-rw-r--r--apps/web/src/components/editor/timeline.tsx649
2 files changed, 662 insertions, 647 deletions
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx
new file mode 100644
index 00000000..0dd1ca1b
--- /dev/null
+++ b/apps/web/src/components/editor/timeline-track.tsx
@@ -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>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index ba84386e..94509be3 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -28,14 +28,9 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "../ui/context-menu";
-import {
- useTimelineStore,
- type TimelineTrack,
- type TimelineClip as TypeTimelineClip,
-} from "@/stores/timeline-store";
+import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
-import { useDragClip } from "@/hooks/use-drag-clip";
import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner";
import { useState, useRef, useEffect, useCallback } from "react";
@@ -46,7 +41,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
-import { TimelineClip } from "./timeline-clip";
+import { TimelineTrackContent } from "./timeline-track";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
@@ -1150,643 +1145,3 @@ export function Timeline() {
</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>
- );
-}