From be1ae20d9089e81cb84a8294be76fe680a24b60f Mon Sep 17 00:00:00 2001 From: Hyteq Date: Sun, 20 Jul 2025 11:00:22 +0300 Subject: format --- apps/web/src/components/editor/timeline.tsx | 2123 ++++++++++++++------------- 1 file changed, 1062 insertions(+), 1061 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 9e68fd4d..a02eaaad 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -3,33 +3,33 @@ import { ScrollArea } from "../ui/scroll-area"; import { Button } from "../ui/button"; import { - Scissors, - ArrowLeftToLine, - ArrowRightToLine, - Trash2, - Snowflake, - Copy, - SplitSquareHorizontal, - Pause, - Play, - Video, - Music, - TypeIcon, - Lock, - LockOpen, - Link, + Scissors, + ArrowLeftToLine, + ArrowRightToLine, + Trash2, + Snowflake, + Copy, + SplitSquareHorizontal, + Pause, + Play, + Video, + Music, + TypeIcon, + Lock, + LockOpen, + Link, } from "lucide-react"; import { - Tooltip, - TooltipContent, - TooltipTrigger, - TooltipProvider, + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, } from "../ui/tooltip"; import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, } from "../ui/context-menu"; import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; @@ -41,8 +41,8 @@ import { toast } from "sonner"; import { useState, useRef, useEffect, useCallback } from "react"; import { TimelineTrackContent } from "./timeline-track"; import { - TimelinePlayhead, - useTimelinePlayheadRuler, + TimelinePlayhead, + useTimelinePlayheadRuler, } from "./timeline-playhead"; import { SelectionBox } from "./selection-box"; import { useSelectionBox } from "@/hooks/use-selection-box"; @@ -50,1046 +50,1047 @@ import { SnapIndicator } from "./snap-indicator"; import { SnapPoint } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack } from "@/types/timeline"; import { - getTrackHeight, - getCumulativeHeightBefore, - getTotalTracksHeight, - TIMELINE_CONSTANTS, - snapTimeToFrame, + getTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, + TIMELINE_CONSTANTS, + snapTimeToFrame, } from "@/constants/timeline-constants"; export function Timeline() { - // Timeline shows all tracks (video, audio, effects) and their elements. - // You can drag media here to add it to your project. - // elements can be trimmed, deleted, and moved. - - const { - tracks, - addTrack, - addElementToTrack, - removeElementFromTrack, - removeElementFromTrackWithRipple, - getTotalDuration, - selectedElements, - clearSelectedElements, - setSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - toggleTrackMute, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - dragState, - } = useTimelineStore(); - const { mediaItems, addMediaItem } = useMediaStore(); - const { activeProject } = useProjectStore(); - const { currentTime, duration, seek, setDuration, isPlaying, toggle } = - usePlaybackStore(); - const [isDragOver, setIsDragOver] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); - const dragCounterRef = useRef(0); - const timelineRef = useRef(null); - const rulerRef = useRef(null); - const [isInTimeline, setIsInTimeline] = useState(false); - - // Track mouse down/up for distinguishing clicks from drag/resize ends - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); - - // Timeline zoom functionality - const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ - containerRef: timelineRef, - isInTimeline, - }); - - // Old marquee selection removed - using new SelectionBox component instead - - // Dynamic timeline width calculation based on playhead position and duration - const dynamicTimelineWidth = Math.max( - (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration - (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer - timelineRef.current?.clientWidth || 1000 // Minimum width - ); - - // Scroll synchronization and auto-scroll to playhead - const rulerScrollRef = useRef(null); - const tracksScrollRef = useRef(null); - const trackLabelsRef = useRef(null); - const playheadRef = useRef(null); - const trackLabelsScrollRef = useRef(null); - const isUpdatingRef = useRef(false); - const lastRulerSync = useRef(0); - const lastTracksSync = useRef(0); - const lastVerticalSync = useRef(0); - - // Timeline playhead ruler handlers - const { handleRulerMouseDown } = useTimelinePlayheadRuler({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - // Selection box functionality - const tracksContainerRef = useRef(null); - const { - selectionBox, - handleMouseDown: handleSelectionMouseDown, - isSelecting, - justFinishedSelecting, - } = useSelectionBox({ - containerRef: tracksContainerRef, - playheadRef, - onSelectionComplete: (elements) => { - console.log(JSON.stringify({ onSelectionComplete: elements.length })); - setSelectedElements(elements); - }, - }); - - // Calculate snap indicator state - const [currentSnapPoint, setCurrentSnapPoint] = useState( - null - ); - const showSnapIndicator = - dragState.isDragging && snappingEnabled && currentSnapPoint !== null; - - // Callback to handle snap point changes from TimelineTrackContent - const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { - setCurrentSnapPoint(snapPoint); - }, []); - - // Track mouse down to distinguish real clicks from drag/resize ends - const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { - // Only track mouse down on timeline background areas (not elements) - const target = e.target as HTMLElement; - const isTimelineBackground = - !target.closest(".timeline-element") && - !playheadRef.current?.contains(target) && - !target.closest("[data-track-labels]"); - - if (isTimelineBackground) { - mouseTrackingRef.current = { - isMouseDown: true, - downX: e.clientX, - downY: e.clientY, - downTime: e.timeStamp, - }; - } - }, []); - - // Timeline content click to seek handler - const handleTimelineContentClick = useCallback( - (e: React.MouseEvent) => { - const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; - - // Reset mouse tracking - mouseTrackingRef.current = { - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }; - - // Only process as click if we tracked a mouse down on timeline background - if (!isMouseDown) { - console.log( - JSON.stringify({ - ignoredClickWithoutMouseDown: true, - timeStamp: e.timeStamp, - }) - ); - return; - } - - // Check if mouse moved significantly (indicates drag, not click) - const deltaX = Math.abs(e.clientX - downX); - const deltaY = Math.abs(e.clientY - downY); - const deltaTime = e.timeStamp - downTime; - - if (deltaX > 5 || deltaY > 5 || deltaTime > 500) { - console.log( - JSON.stringify({ - ignoredDragNotClick: true, - deltaX, - deltaY, - deltaTime, - timeStamp: e.timeStamp, - }) - ); - return; - } - - // Don't seek if this was a selection box operation - if (isSelecting || justFinishedSelecting) { - return; - } - - // Don't seek if clicking on timeline elements, but still deselect - if ((e.target as HTMLElement).closest(".timeline-element")) { - return; - } - - // Don't seek if clicking on playhead - if (playheadRef.current?.contains(e.target as Node)) { - return; - } - - // Don't seek if clicking on track labels - if ((e.target as HTMLElement).closest("[data-track-labels]")) { - clearSelectedElements(); - return; - } - - // Clear selected elements when clicking empty timeline area - console.log(JSON.stringify({ clearingSelectedElements: true })); - clearSelectedElements(); - - // Determine if we're clicking in ruler or tracks area - const isRulerClick = (e.target as HTMLElement).closest( - "[data-ruler-area]" - ); - - let mouseX: number; - let scrollLeft = 0; - - if (isRulerClick) { - // Calculate based on ruler position - const rulerContent = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - if (!rulerContent) return; - const rect = rulerContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = rulerContent.scrollLeft; - } else { - // Calculate based on tracks content position - const tracksContent = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - if (!tracksContent) return; - const rect = tracksContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = tracksContent.scrollLeft; - } - - const rawTime = Math.max( - 0, - Math.min( - duration, - (mouseX + scrollLeft) / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) - ) - ); - - // Use frame snapping for timeline clicking - const projectFps = activeProject?.fps || 30; - const time = snapTimeToFrame(rawTime, projectFps); - - seek(time); - }, - [ - duration, - zoomLevel, - seek, - rulerScrollRef, - tracksScrollRef, - clearSelectedElements, - isSelecting, - justFinishedSelecting, - ] - ); - - // Update timeline duration when tracks change - useEffect(() => { - const totalDuration = getTotalDuration(); - setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline - }, [tracks, setDuration, getTotalDuration]); - - // Old marquee system removed - using new SelectionBox component instead - - const handleDragEnter = (e: React.DragEvent) => { - // When something is dragged over the timeline, show overlay - e.preventDefault(); - // Don't show overlay for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - 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 elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } - - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) { - setIsDragOver(false); - } - }; - - const handleDrop = async (e: React.DragEvent) => { - // When media is dropped, add it as a new track/element - e.preventDefault(); - setIsDragOver(false); - dragCounterRef.current = 0; - - // Ignore timeline element drags - they're handled by track-specific handlers - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - if (hasTimelineElement) { - return; - } - - const itemData = e.dataTransfer.getData("application/x-media-item"); - if (itemData) { - try { - const dragData: DragData = JSON.parse(itemData); - - if (dragData.type === "text") { - // Always create new text track to avoid overlaps - useTimelineStore.getState().addTextToNewTrack(dragData); - } else { - // Handle media items - const mediaItem = mediaItems.find( - (item: any) => item.id === dragData.id - ); - if (!mediaItem) { - toast.error("Media item not found"); - return; - } - - useTimelineStore.getState().addMediaToNewTrack(mediaItem); - } - } catch (error) { - console.error("Error parsing dropped item data:", error); - toast.error("Failed to add item to timeline"); - } - } else if (e.dataTransfer.files?.length > 0) { - // Handle file drops by creating new tracks - if (!activeProject) { - toast.error("No active project"); - return; - } - - setIsProcessing(true); - setProgress(0); - try { - const processedItems = await processMediaFiles( - e.dataTransfer.files, - (p) => setProgress(p) - ); - for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( - (item) => - item.name === processedItem.name && item.url === processedItem.url - ); - if (addedItem) { - useTimelineStore.getState().addMediaToNewTrack(addedItem); - } - } - } catch (error) { - // Show error if file processing fails - console.error("Error processing external files:", error); - toast.error("Failed to process dropped files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - } - }; - - const dragProps = { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }; - - // Action handlers for toolbar - const handleSplitSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - let splitCount = 0; - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (element && track) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newElementId = splitElement(trackId, elementId, currentTime); - if (newElementId) splitCount++; - } - } - }); - if (splitCount === 0) { - toast.error("Playhead must be within selected elements to split"); - } - }; - - const handleDuplicateSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; - - const newSelections: { trackId: string; elementId: string }[] = []; - - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((el) => el.id === elementId); - - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - - // Create element without id (will be generated by store) - const { id, ...elementWithoutId } = element; - - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - - // We can't predict the new id, so just clear selection for now - // TODO: addElementToTrack could return the new element id - } - }); - - clearSelectedElements(); - }; - - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; - - const handleSplitAndKeepLeft = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepLeft(trackId, elementId, currentTime); - }; - - const handleSplitAndKeepRight = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepRight(trackId, elementId, currentTime); - }; - - const handleSeparateAudio = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } - separateAudio(trackId, elementId); - }; - - const handleDeleteSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - selectedElements.forEach(({ trackId, elementId }) => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(trackId, elementId); - } else { - removeElementFromTrack(trackId, elementId); - } - }); - clearSelectedElements(); - }; - - // --- Scroll synchronization effect --- - useEffect(() => { - const rulerViewport = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]" - ) as HTMLElement; - - if (!rulerViewport || !tracksViewport) return; - - // Horizontal scroll synchronization between ruler and tracks - const handleRulerScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; - lastRulerSync.current = now; - isUpdatingRef.current = true; - tracksViewport.scrollLeft = rulerViewport.scrollLeft; - isUpdatingRef.current = false; - }; - const handleTracksScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; - lastTracksSync.current = now; - isUpdatingRef.current = true; - rulerViewport.scrollLeft = tracksViewport.scrollLeft; - isUpdatingRef.current = false; - }; - - rulerViewport.addEventListener("scroll", handleRulerScroll); - tracksViewport.addEventListener("scroll", handleTracksScroll); - - // Vertical scroll synchronization between track labels and tracks content - if (trackLabelsViewport) { - const handleTrackLabelsScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastVerticalSync.current < 16) - return; - lastVerticalSync.current = now; - isUpdatingRef.current = true; - tracksViewport.scrollTop = trackLabelsViewport.scrollTop; - isUpdatingRef.current = false; - }; - const handleTracksVerticalScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastVerticalSync.current < 16) - return; - lastVerticalSync.current = now; - isUpdatingRef.current = true; - trackLabelsViewport.scrollTop = tracksViewport.scrollTop; - isUpdatingRef.current = false; - }; - - trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); - tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); - - return () => { - rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); - trackLabelsViewport.removeEventListener( - "scroll", - handleTrackLabelsScroll - ); - tracksViewport.removeEventListener( - "scroll", - handleTracksVerticalScroll - ); - }; - } - - return () => { - rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); - }; - }, []); - - return ( -
setIsInTimeline(true)} - onMouseLeave={() => setIsInTimeline(false)} - > - {/* Toolbar */} -
-
- - {/* Play/Pause Button */} - - - - - - {isPlaying ? "Pause (Space)" : "Play (Space)"} - - -
- {/* Time Display */} -
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s -
- {/* Test Clip Button - for debugging */} - {tracks.length === 0 && ( - <> -
- - - - - - Add a test clip to try playback - - - - )} -
- - - - - Split element (Ctrl+S) - - - - - - Split and keep left (Ctrl+Q) - - - - - - Split and keep right (Ctrl+W) - - - - - - Separate audio (Ctrl+D) - - - - - - Duplicate element (Ctrl+D) - - - - - - Freeze frame (F) - - - - - - Delete element (Delete) - - -
-
- - - - - - Auto snapping - - - - - - - {rippleEditingEnabled - ? "Disable Ripple Editing" - : "Enable Ripple Editing"} - - - -
-
- - {/* Timeline Container */} -
- - - {/* Timeline Header with Ruler */} -
- {/* Track Labels Header */} -
- {/* Empty space */} - - . - -
- - {/* Timeline Ruler */} -
- -
- {/* Time markers */} - {(() => { - // Calculate appropriate time interval based on zoom level - const getTimeInterval = (zoom: number) => { - const pixelsPerSecond = - TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; - if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in - if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in - if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom - if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out - if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out - if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out - return 30; // Every 30s when extremely zoomed out - }; - - const interval = getTimeInterval(zoomLevel); - const markerCount = Math.ceil(duration / interval) + 1; - - return Array.from({ length: markerCount }, (_, i) => { - const time = i * interval; - if (time > duration) return null; - - const isMainMarker = - time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; - - return ( -
- - {(() => { - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (minutes > 0) { - return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (interval >= 1) { - return `${Math.floor(secs)}s`; - } else { - return `${secs.toFixed(1)}s`; - } - }; - return formatTime(time); - })()} - -
- ); - }).filter(Boolean); - })()} -
-
-
-
- - {/* Tracks Area */} -
- {/* Track Labels */} - {tracks.length > 0 && ( -
- -
- {tracks.map((track) => ( -
-
- -
- {track.muted && ( - - Muted - - )} -
- ))} -
-
-
- )} - - {/* Timeline Tracks Content */} -
{ - handleTimelineMouseDown(e); - handleSelectionMouseDown(e); - }} - onClick={handleTimelineContentClick} - ref={tracksContainerRef} - > - - -
- {tracks.length === 0 ? ( -
- ) : ( - <> - {tracks.map((track, index) => ( - - -
{ - // If clicking empty area (not on a element), deselect all elements - if ( - !(e.target as HTMLElement).closest( - ".timeline-element" - ) - ) { - clearSelectedElements(); - } - }} - > - -
-
- - toggleTrackMute(track.id)} - > - {track.muted ? "Unmute Track" : "Mute Track"} - - - Track settings (soon) - - -
- ))} - - )} -
-
-
-
-
-
- ); + // Timeline shows all tracks (video, audio, effects) and their elements. + // You can drag media here to add it to your project. + // elements can be trimmed, deleted, and moved. + + const { + tracks, + addTrack, + addElementToTrack, + removeElementFromTrack, + removeElementFromTrackWithRipple, + getTotalDuration, + selectedElements, + clearSelectedElements, + setSelectedElements, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + toggleTrackMute, + separateAudio, + snappingEnabled, + toggleSnapping, + rippleEditingEnabled, + toggleRippleEditing, + dragState, + } = useTimelineStore(); + const { mediaItems, addMediaItem } = useMediaStore(); + const { activeProject } = useProjectStore(); + const { currentTime, duration, seek, setDuration, isPlaying, toggle } = + usePlaybackStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const dragCounterRef = useRef(0); + const timelineRef = useRef(null); + const rulerRef = useRef(null); + const [isInTimeline, setIsInTimeline] = useState(false); + + // Track mouse down/up for distinguishing clicks from drag/resize ends + const mouseTrackingRef = useRef({ + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }); + + // Timeline zoom functionality + const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ + containerRef: timelineRef, + isInTimeline, + }); + + // Old marquee selection removed - using new SelectionBox component instead + + // Dynamic timeline width calculation based on playhead position and duration + const dynamicTimelineWidth = Math.max( + (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration + (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer + timelineRef.current?.clientWidth || 1000, // Minimum width + ); + + // Scroll synchronization and auto-scroll to playhead + const rulerScrollRef = useRef(null); + const tracksScrollRef = useRef(null); + const trackLabelsRef = useRef(null); + const playheadRef = useRef(null); + const trackLabelsScrollRef = useRef(null); + const isUpdatingRef = useRef(false); + const lastRulerSync = useRef(0); + const lastTracksSync = useRef(0); + const lastVerticalSync = useRef(0); + + // Timeline playhead ruler handlers + const { handleRulerMouseDown } = useTimelinePlayheadRuler({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + // Selection box functionality + const tracksContainerRef = useRef(null); + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + justFinishedSelecting, + } = useSelectionBox({ + containerRef: tracksContainerRef, + playheadRef, + onSelectionComplete: (elements) => { + console.log(JSON.stringify({ onSelectionComplete: elements.length })); + setSelectedElements(elements); + }, + }); + + // Calculate snap indicator state + const [currentSnapPoint, setCurrentSnapPoint] = useState( + null, + ); + const showSnapIndicator = + dragState.isDragging && snappingEnabled && currentSnapPoint !== null; + + // Callback to handle snap point changes from TimelineTrackContent + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); + + // Track mouse down to distinguish real clicks from drag/resize ends + const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { + // Only track mouse down on timeline background areas (not elements) + const target = e.target as HTMLElement; + const isTimelineBackground = + !target.closest(".timeline-element") && + !playheadRef.current?.contains(target) && + !target.closest("[data-track-labels]"); + + if (isTimelineBackground) { + mouseTrackingRef.current = { + isMouseDown: true, + downX: e.clientX, + downY: e.clientY, + downTime: e.timeStamp, + }; + } + }, []); + + // Timeline content click to seek handler + const handleTimelineContentClick = useCallback( + (e: React.MouseEvent) => { + const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; + + // Reset mouse tracking + mouseTrackingRef.current = { + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }; + + // Only process as click if we tracked a mouse down on timeline background + if (!isMouseDown) { + console.log( + JSON.stringify({ + ignoredClickWithoutMouseDown: true, + timeStamp: e.timeStamp, + }), + ); + return; + } + + // Check if mouse moved significantly (indicates drag, not click) + const deltaX = Math.abs(e.clientX - downX); + const deltaY = Math.abs(e.clientY - downY); + const deltaTime = e.timeStamp - downTime; + + if (deltaX > 5 || deltaY > 5 || deltaTime > 500) { + console.log( + JSON.stringify({ + ignoredDragNotClick: true, + deltaX, + deltaY, + deltaTime, + timeStamp: e.timeStamp, + }), + ); + return; + } + + // Don't seek if this was a selection box operation + if (isSelecting || justFinishedSelecting) { + return; + } + + // Don't seek if clicking on timeline elements, but still deselect + if ((e.target as HTMLElement).closest(".timeline-element")) { + return; + } + + // Don't seek if clicking on playhead + if (playheadRef.current?.contains(e.target as Node)) { + return; + } + + // Don't seek if clicking on track labels + if ((e.target as HTMLElement).closest("[data-track-labels]")) { + clearSelectedElements(); + return; + } + + // Clear selected elements when clicking empty timeline area + console.log(JSON.stringify({ clearingSelectedElements: true })); + clearSelectedElements(); + + // Determine if we're clicking in ruler or tracks area + const isRulerClick = (e.target as HTMLElement).closest( + "[data-ruler-area]", + ); + + let mouseX: number; + let scrollLeft = 0; + + if (isRulerClick) { + // Calculate based on ruler position + const rulerContent = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + if (!rulerContent) return; + const rect = rulerContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = rulerContent.scrollLeft; + } else { + // Calculate based on tracks content position + const tracksContent = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + if (!tracksContent) return; + const rect = tracksContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = tracksContent.scrollLeft; + } + + const rawTime = Math.max( + 0, + Math.min( + duration, + (mouseX + scrollLeft) / + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), + ), + ); + + // Use frame snapping for timeline clicking + const projectFps = activeProject?.fps || 30; + const time = snapTimeToFrame(rawTime, projectFps); + + seek(time); + }, + [ + duration, + zoomLevel, + seek, + rulerScrollRef, + tracksScrollRef, + clearSelectedElements, + isSelecting, + justFinishedSelecting, + ], + ); + + // Update timeline duration when tracks change + useEffect(() => { + const totalDuration = getTotalDuration(); + setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline + }, [tracks, setDuration, getTotalDuration]); + + // Old marquee system removed - using new SelectionBox component instead + + const handleDragEnter = (e: React.DragEvent) => { + // When something is dragged over the timeline, show overlay + e.preventDefault(); + // Don't show overlay for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { + 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 elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { + return; + } + + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + // When media is dropped, add it as a new track/element + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; + + // Ignore timeline element drags - they're handled by track-specific handlers + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element", + ); + if (hasTimelineElement) { + return; + } + + const itemData = e.dataTransfer.getData("application/x-media-item"); + if (itemData) { + try { + const dragData: DragData = JSON.parse(itemData); + + if (dragData.type === "text") { + // Always create new text track to avoid overlaps + useTimelineStore.getState().addTextToNewTrack(dragData); + } else { + // Handle media items + const mediaItem = mediaItems.find( + (item: any) => item.id === dragData.id, + ); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + useTimelineStore.getState().addMediaToNewTrack(mediaItem); + } + } catch (error) { + console.error("Error parsing dropped item data:", error); + toast.error("Failed to add item to timeline"); + } + } else if (e.dataTransfer.files?.length > 0) { + // Handle file drops by creating new tracks + if (!activeProject) { + toast.error("No active project"); + return; + } + + setIsProcessing(true); + setProgress(0); + try { + const processedItems = await processMediaFiles( + e.dataTransfer.files, + (p) => setProgress(p), + ); + for (const processedItem of processedItems) { + await addMediaItem(activeProject.id, processedItem); + const currentMediaItems = useMediaStore.getState().mediaItems; + const addedItem = currentMediaItems.find( + (item) => + item.name === processedItem.name && + item.url === processedItem.url, + ); + if (addedItem) { + useTimelineStore.getState().addMediaToNewTrack(addedItem); + } + } + } catch (error) { + // Show error if file processing fails + console.error("Error processing external files:", error); + toast.error("Failed to process dropped files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + } + }; + + const dragProps = { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; + + // Action handlers for toolbar + const handleSplitSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + let splitCount = 0; + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (element && track) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const newElementId = splitElement(trackId, elementId, currentTime); + if (newElementId) splitCount++; + } + } + }); + if (splitCount === 0) { + toast.error("Playhead must be within selected elements to split"); + } + }; + + const handleDuplicateSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; + + const newSelections: { trackId: string; elementId: string }[] = []; + + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((el) => el.id === elementId); + + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + + // Create element without id (will be generated by store) + const { id, ...elementWithoutId } = element; + + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + + // We can't predict the new id, so just clear selection for now + // TODO: addElementToTrack could return the new element id + } + }); + + clearSelectedElements(); + }; + + const handleFreezeSelected = () => { + toast.info("Freeze frame functionality coming soon!"); + }; + + const handleSplitAndKeepLeft = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepLeft(trackId, elementId, currentTime); + }; + + const handleSplitAndKeepRight = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepRight(trackId, elementId, currentTime); + }; + + const handleSeparateAudio = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one media element to separate audio"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + if (!track || track.type !== "media") { + toast.error("Select a media element to separate audio"); + return; + } + separateAudio(trackId, elementId); + }; + + const handleDeleteSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + selectedElements.forEach(({ trackId, elementId }) => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(trackId, elementId); + } else { + removeElementFromTrack(trackId, elementId); + } + }); + clearSelectedElements(); + }; + + // --- Scroll synchronization effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]", + ) as HTMLElement; + + if (!rulerViewport || !tracksViewport) return; + + // Horizontal scroll synchronization between ruler and tracks + const handleRulerScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; + lastRulerSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollLeft = rulerViewport.scrollLeft; + isUpdatingRef.current = false; + }; + const handleTracksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; + lastTracksSync.current = now; + isUpdatingRef.current = true; + rulerViewport.scrollLeft = tracksViewport.scrollLeft; + isUpdatingRef.current = false; + }; + + rulerViewport.addEventListener("scroll", handleRulerScroll); + tracksViewport.addEventListener("scroll", handleTracksScroll); + + // Vertical scroll synchronization between track labels and tracks content + if (trackLabelsViewport) { + const handleTrackLabelsScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollTop = trackLabelsViewport.scrollTop; + isUpdatingRef.current = false; + }; + const handleTracksVerticalScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + trackLabelsViewport.scrollTop = tracksViewport.scrollTop; + isUpdatingRef.current = false; + }; + + trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); + tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); + + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + trackLabelsViewport.removeEventListener( + "scroll", + handleTrackLabelsScroll, + ); + tracksViewport.removeEventListener( + "scroll", + handleTracksVerticalScroll, + ); + }; + } + + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + }; + }, []); + + return ( +
setIsInTimeline(true)} + onMouseLeave={() => setIsInTimeline(false)} + > + {/* Toolbar */} +
+
+ + {/* Play/Pause Button */} + + + + + + {isPlaying ? "Pause (Space)" : "Play (Space)"} + + +
+ {/* Time Display */} +
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s +
+ {/* Test Clip Button - for debugging */} + {tracks.length === 0 && ( + <> +
+ + + + + + Add a test clip to try playback + + + + )} +
+ + + + + Split element (Ctrl+S) + + + + + + Split and keep left (Ctrl+Q) + + + + + + Split and keep right (Ctrl+W) + + + + + + Separate audio (Ctrl+D) + + + + + + Duplicate element (Ctrl+D) + + + + + + Freeze frame (F) + + + + + + Delete element (Delete) + + +
+
+ + + + + + Auto snapping + + + + + + + {rippleEditingEnabled + ? "Disable Ripple Editing" + : "Enable Ripple Editing"} + + + +
+
+ + {/* Timeline Container */} +
+ + + {/* Timeline Header with Ruler */} +
+ {/* Track Labels Header */} +
+ {/* Empty space */} + + . + +
+ + {/* Timeline Ruler */} +
+ +
+ {/* Time markers */} + {(() => { + // Calculate appropriate time interval based on zoom level + const getTimeInterval = (zoom: number) => { + const pixelsPerSecond = + TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; + if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in + if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in + if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom + if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out + if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out + if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out + return 30; // Every 30s when extremely zoomed out + }; + + const interval = getTimeInterval(zoomLevel); + const markerCount = Math.ceil(duration / interval) + 1; + + return Array.from({ length: markerCount }, (_, i) => { + const time = i * interval; + if (time > duration) return null; + + const isMainMarker = + time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; + + return ( +
+ + {(() => { + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (minutes > 0) { + return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (interval >= 1) { + return `${Math.floor(secs)}s`; + } else { + return `${secs.toFixed(1)}s`; + } + }; + return formatTime(time); + })()} + +
+ ); + }).filter(Boolean); + })()} +
+
+
+
+ + {/* Tracks Area */} +
+ {/* Track Labels */} + {tracks.length > 0 && ( +
+ +
+ {tracks.map((track) => ( +
+
+ +
+ {track.muted && ( + + Muted + + )} +
+ ))} +
+
+
+ )} + + {/* Timeline Tracks Content */} +
{ + handleTimelineMouseDown(e); + handleSelectionMouseDown(e); + }} + onClick={handleTimelineContentClick} + ref={tracksContainerRef} + > + + +
+ {tracks.length === 0 ? ( +
+ ) : ( + <> + {tracks.map((track, index) => ( + + +
{ + // If clicking empty area (not on a element), deselect all elements + if ( + !(e.target as HTMLElement).closest( + ".timeline-element", + ) + ) { + clearSelectedElements(); + } + }} + > + +
+
+ + toggleTrackMute(track.id)} + > + {track.muted ? "Unmute Track" : "Mute Track"} + + + Track settings (soon) + + +
+ ))} + + )} +
+
+
+
+
+
+ ); } function TrackIcon({ track }: { track: TimelineTrack }) { - return ( - <> - {track.type === "media" && ( -