diff options
Diffstat (limited to 'apps/web/src/components/editor/timeline.tsx')
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 649 |
1 files changed, 2 insertions, 647 deletions
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> - ); -} |
