From 6ee16f9df8aeabfef0ca533f9f5b13daab3a3758 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 22 Jun 2025 19:28:03 +0200 Subject: feat: so much stuff --- apps/web/src/components/editor/timeline.tsx | 409 ++++++++++++++++++++++++++-- 1 file changed, 385 insertions(+), 24 deletions(-) (limited to 'apps/web/src/components/editor/timeline.tsx') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index dd312764..2e23bfba 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -19,17 +19,164 @@ import { } from "../ui/tooltip"; import { DragOverlay } from "../ui/drag-overlay"; import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store"; -import { useDragDrop } from "@/hooks/use-drag-drop"; +import { useMediaStore } from "@/stores/media-store"; +import { processMediaFiles } from "@/lib/media-processing"; +import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; +import { toast } from "sonner"; +import { useState, useRef } from "react"; export function Timeline() { - const { tracks, addTrack } = useTimelineStore(); + const { tracks, addTrack, addClipToTrack } = useTimelineStore(); + const { mediaItems, addMediaItem } = useMediaStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const dragCounterRef = useRef(0); - const { isDragOver, dragProps } = useDragDrop({ - onDrop: (files) => { - // TODO: Handle file drop functionality for timeline - console.log("Files dropped on timeline:", files); - }, - }); + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + + // Don't show overlay for timeline clips or other internal drags + if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + dragCounterRef.current += 1; + if (!isDragOver) { + setIsDragOver(true); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + // Don't update state for timeline clips + if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; + + // Check if this is a timeline clip drop - now we'll handle it! + const timelineClipData = e.dataTransfer.getData( + "application/x-timeline-clip" + ); + if (timelineClipData) { + // Timeline clips dropped on the main timeline area (not on a specific track) + // For now, we'll just ignore these - clips should be dropped on specific tracks + return; + } + + // Check if this is an internal media item drop + const mediaItemData = e.dataTransfer.getData("application/x-media-item"); + if (mediaItemData) { + try { + const { id, type, name } = JSON.parse(mediaItemData); + + // Find the full media item from the store + const mediaItem = mediaItems.find((item) => item.id === id); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + // Determine track type based on media type + let trackType: "video" | "audio" | "effects"; + if (type === "video") { + trackType = "video"; + } else if (type === "audio") { + trackType = "audio"; + } else { + // For images, we'll put them on video tracks + trackType = "video"; + } + + // Create a new track and get its ID + const newTrackId = addTrack(trackType); + + // Add the clip to the new track + addClipToTrack(newTrackId, { + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, // Default 5 seconds for images + }); + + toast.success(`Added ${name} to ${trackType} track`); + } catch (error) { + console.error("Error parsing media item data:", error); + toast.error("Failed to add media to timeline"); + } + } else if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + // Handle external file drops + setIsProcessing(true); + + try { + const processedItems = await processMediaFiles(e.dataTransfer.files); + + for (const processedItem of processedItems) { + // Add to media store first + addMediaItem(processedItem); + + // The media item now has an ID, let's get it from the latest state + // Since addMediaItem is synchronous, we can get the latest item + const currentMediaItems = useMediaStore.getState().mediaItems; + const addedItem = currentMediaItems.find( + (item) => + item.name === processedItem.name && item.url === processedItem.url + ); + + if (addedItem) { + // Determine track type based on media type + let trackType: "video" | "audio" | "effects"; + if (processedItem.type === "video") { + trackType = "video"; + } else if (processedItem.type === "audio") { + trackType = "audio"; + } else { + // For images, we'll put them on video tracks + trackType = "video"; + } + + // Create a new track and get its ID + const newTrackId = addTrack(trackType); + + // Add the clip to the new track + addClipToTrack(newTrackId, { + mediaId: addedItem.id, + name: addedItem.name, + duration: addedItem.duration || 5, // Default 5 seconds for images + }); + + toast.success(`Added ${processedItem.name} to timeline`); + } + } + } catch (error) { + console.error("Error processing external files:", error); + toast.error("Failed to process dropped files"); + } finally { + setIsProcessing(false); + } + } + }; + + const dragProps = { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; return (
{/* Toolbar */} @@ -154,6 +305,149 @@ export function Timeline() { } function TimelineTrackComponent({ track }: { track: TimelineTrack }) { + const { mediaItems } = useMediaStore(); + const { moveClipToTrack, reorderClipInTrack } = useTimelineStore(); + const [isDropping, setIsDropping] = useState(false); + + const handleClipDragStart = (e: React.DragEvent, clip: any) => { + // Mark this as an timeline clip drag to differentiate from media items + const dragData = { + clipId: clip.id, + trackId: track.id, + name: clip.name, + }; + + e.dataTransfer.setData( + "application/x-timeline-clip", + JSON.stringify(dragData) + ); + e.dataTransfer.effectAllowed = "move"; + + // Use the entire clip container as the drag image instead of just the content + const target = e.currentTarget as HTMLElement; + e.dataTransfer.setDragImage( + target, + target.offsetWidth / 2, + target.offsetHeight / 2 + ); + }; + + const handleTrackDragOver = (e: React.DragEvent) => { + e.preventDefault(); + + // Only handle timeline clip drags + if (!e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + e.dataTransfer.dropEffect = "move"; + }; + + const handleTrackDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + + // Only handle timeline clip drags + if (!e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + setIsDropping(true); + }; + + const handleTrackDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + // Only handle timeline clip drags + if (!e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + // Check if we're actually leaving the track area + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + + const isActuallyLeaving = + x < rect.left || x > rect.right || y < rect.top || y > rect.bottom; + + if (isActuallyLeaving) { + setIsDropping(false); + } + }; + + const handleTrackDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDropping(false); + + // Only handle timeline clip drags + if (!e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + const timelineClipData = e.dataTransfer.getData( + "application/x-timeline-clip" + ); + + if (!timelineClipData) { + return; + } + + try { + const parsedData = JSON.parse(timelineClipData); + const { clipId, trackId: fromTrackId } = parsedData; + + // Calculate where to insert the clip based on mouse position + const trackContainer = e.currentTarget.querySelector( + ".track-clips-container" + ) as HTMLElement; + + if (!trackContainer) { + return; + } + + const rect = trackContainer.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + + // Calculate insertion index based on position + let insertIndex = 0; + const clipElements = trackContainer.querySelectorAll(".timeline-clip"); + + for (let i = 0; i < clipElements.length; i++) { + const clipRect = clipElements[i].getBoundingClientRect(); + const clipCenterX = clipRect.left + clipRect.width / 2 - rect.left; + + if (mouseX > clipCenterX) { + insertIndex = i + 1; + } else { + break; + } + } + + if (fromTrackId === track.id) { + // Moving within the same track - reorder + const currentIndex = track.clips.findIndex( + (clip) => clip.id === clipId + ); + + if (currentIndex !== -1 && currentIndex !== insertIndex) { + // Adjust index if we're moving to a position after the current one + const adjustedIndex = + insertIndex > currentIndex ? insertIndex - 1 : insertIndex; + + reorderClipInTrack(track.id, clipId, adjustedIndex); + toast.success("Clip reordered"); + } + } else { + // Moving between different tracks + moveClipToTrack(fromTrackId, track.id, clipId, insertIndex); + toast.success("Clip moved to different track"); + } + } catch (error) { + console.error("Error moving clip:", error); + toast.error("Failed to move clip"); + } + }; + const getTrackColor = (type: string) => { switch (type) { case "video": @@ -167,26 +461,93 @@ function TimelineTrackComponent({ track }: { track: TimelineTrack }) { } }; + const renderClipContent = (clip: any) => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + + if (!mediaItem) { + return ( + {clip.name} + ); + } + + if (mediaItem.type === "image") { + return ( +
+
+ +
+ + {clip.name} + +
+ ); + } + + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + return ( +
+
+ {mediaItem.name} +
+ + {clip.name} + +
+ ); + } + + // Fallback for audio or videos without thumbnails + return ( + {clip.name} + ); + }; + return (
{track.name}
-
- {track.clips.length === 0 ? ( -
- Drop media here -
- ) : ( -
- - {track.clips.length} clip{track.clips.length !== 1 ? "s" : ""} - -
- )} +
+
+ {track.clips.length === 0 ? ( +
+ Drop media here +
+ ) : ( + track.clips.map((clip, index) => ( +
handleClipDragStart(e, clip)} + > + {renderClipContent(clip)} +
+ )) + )} +
); -- cgit v1.3.1