From b461234c6549bd1ce9acfd76b0f3146c5b49d7a6 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 29 Jun 2025 23:20:17 +0200 Subject: refactor: separate timeline track content into new file --- apps/web/src/components/editor/timeline-track.tsx | 660 ++++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 apps/web/src/components/editor/timeline-track.tsx (limited to 'apps/web/src/components/editor/timeline-track.tsx') 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(null); + const [isDropping, setIsDropping] = useState(false); + const [dropPosition, setDropPosition] = useState(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 ( +
{ + // 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} + > +
+ {track.clips.length === 0 ? ( +
+ {isDropping + ? wouldOverlap + ? "Cannot drop - would overlap" + : "Drop clip here" + : "Drop media here"} +
+ ) : ( + <> + {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 ( + + +
+ +
+
+ + + + Split at Playhead + + + + Duplicate Clip + + + + + Delete Clip + + +
+ ); + })} + + )} +
+
+ ); +} -- cgit v1.3.1