diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/timeline | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/timeline')
13 files changed, 2001 insertions, 3141 deletions
diff --git a/apps/web/src/components/editor/timeline/audio-waveform.tsx b/apps/web/src/components/editor/timeline/audio-waveform.tsx new file mode 100644 index 00000000..bbc5cb83 --- /dev/null +++ b/apps/web/src/components/editor/timeline/audio-waveform.tsx @@ -0,0 +1,176 @@ +import { useEffect, useRef, useState } from "react"; +import WaveSurfer from "wavesurfer.js"; + +interface AudioWaveformProps { + audioUrl?: string; + audioBuffer?: AudioBuffer; + height?: number; + className?: string; +} + +function extractPeaks({ + buffer, + length = 512, +}: { + buffer: AudioBuffer; + length?: number; +}): number[][] { + const channels = buffer.numberOfChannels; + const peaks: number[][] = []; + + for (let c = 0; c < channels; c++) { + const data = buffer.getChannelData(c); + const step = Math.floor(data.length / length); + const channelPeaks: number[] = []; + + for (let i = 0; i < length; i++) { + const start = i * step; + const end = Math.min(start + step, data.length); + let max = 0; + for (let j = start; j < end; j++) { + const abs = Math.abs(data[j]); + if (abs > max) max = abs; + } + channelPeaks.push(max); + } + peaks.push(channelPeaks); + } + + return peaks; +} + +export function AudioWaveform({ + audioUrl, + audioBuffer, + height = 32, + className = "", +}: AudioWaveformProps) { + const waveformRef = useRef<HTMLDivElement>(null); + const wavesurfer = useRef<WaveSurfer | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + let mounted = true; + const ws = wavesurfer.current; + + const initWaveSurfer = async () => { + if (!waveformRef.current || (!audioUrl && !audioBuffer)) return; + + try { + if (ws) { + wavesurfer.current = null; + } + + const newWaveSurfer = WaveSurfer.create({ + container: waveformRef.current, + waveColor: "rgba(255, 255, 255, 0.6)", + progressColor: "rgba(255, 255, 255, 0.9)", + cursorColor: "transparent", + barWidth: 2, + barGap: 1, + height, + normalize: true, + interact: false, + }); + + if (mounted) { + wavesurfer.current = newWaveSurfer; + } else { + try { + newWaveSurfer.destroy(); + } catch {} + return; + } + + newWaveSurfer.on("ready", () => { + if (mounted) { + setIsLoading(false); + setError(false); + } + }); + + newWaveSurfer.on("error", (err) => { + if (mounted) { + console.error("WaveSurfer error:", err); + setError(true); + setIsLoading(false); + } + }); + + if (audioBuffer) { + const peaks = extractPeaks({ buffer: audioBuffer }); + newWaveSurfer.load("", peaks, audioBuffer.duration); + } else if (audioUrl) { + await newWaveSurfer.load(audioUrl); + } + } catch (err) { + if (mounted) { + console.error("Failed to initialize WaveSurfer:", err); + setError(true); + setIsLoading(false); + } + } + }; + + if (ws) { + const wsToDestroy = ws; + wavesurfer.current = null; + + requestAnimationFrame(() => { + try { + wsToDestroy.destroy(); + } catch {} + if (mounted) { + initWaveSurfer(); + } + }); + } else { + initWaveSurfer(); + } + + return () => { + mounted = false; + + const wsToDestroy = wavesurfer.current; + + wavesurfer.current = null; + + if (wsToDestroy) { + requestAnimationFrame(() => { + try { + wsToDestroy.destroy(); + } catch {} + }); + } + }; + }, [audioUrl, audioBuffer, height]); + + if (error) { + return ( + <div + className={`flex items-center justify-center ${className}`} + style={{ height }} + > + <span className="text-foreground/60 text-xs">Audio unavailable</span> + </div> + ); + } + + return ( + <div className={`relative ${className}`}> + {isLoading && ( + <div className="absolute inset-0 flex items-center justify-center"> + <span className="text-foreground/60 text-xs">Loading...</span> + </div> + )} + <div + ref={waveformRef} + className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`} + style={{ height }} + /> + </div> + ); +} + +export default AudioWaveform; diff --git a/apps/web/src/components/editor/timeline/bookmarks.tsx b/apps/web/src/components/editor/timeline/bookmarks.tsx new file mode 100644 index 00000000..cfcb1e23 --- /dev/null +++ b/apps/web/src/components/editor/timeline/bookmarks.tsx @@ -0,0 +1,111 @@ +import { useEditor } from "@/hooks/use-editor"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { getSnappedSeekTime } from "@/lib/time"; +import { Bookmark02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +interface TimelineBookmarksRowProps { + zoomLevel: number; + dynamicTimelineWidth: number; + handleWheel: (e: React.WheelEvent) => void; + handleTimelineContentClick: (e: React.MouseEvent) => void; + handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; + handleRulerMouseDown: (e: React.MouseEvent) => void; +} + +export function TimelineBookmarksRow({ + zoomLevel, + dynamicTimelineWidth, + handleWheel, + handleTimelineContentClick, + handleRulerTrackingMouseDown, + handleRulerMouseDown, +}: TimelineBookmarksRowProps) { + const editor = useEditor(); + const activeScene = editor.scenes.getActiveScene(); + + return ( + <div className="relative h-4 flex-1 overflow-hidden"> + <button + className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + aria-label="Timeline ruler" + type="button" + onWheel={handleWheel} + onClick={handleTimelineContentClick} + onMouseDown={(event) => { + handleRulerMouseDown(event); + handleRulerTrackingMouseDown(event); + }} + > + {activeScene.bookmarks.map((time: number) => ( + <TimelineBookmark + key={`bookmark-row-${time}`} + time={time} + zoomLevel={zoomLevel} + /> + ))} + </button> + </div> + ); +} + +export function TimelineBookmark({ + time, + zoomLevel, +}: { + time: number; + zoomLevel: number; +}) { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const duration = editor.timeline.getTotalDuration(); + + const handleBookmarkActivate = ({ + event, + }: { + event: + | React.MouseEvent<HTMLButtonElement> + | React.KeyboardEvent<HTMLButtonElement>; + }) => { + event.stopPropagation(); + const framesPerSecond = activeProject?.settings.fps ?? 30; + const snappedTime = getSnappedSeekTime({ + rawTime: time, + duration, + fps: framesPerSecond, + }); + editor.playback.seek({ time: snappedTime }); + }; + + return ( + <button + className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0" + style={{ + left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, + }} + aria-label={`Seek to bookmark at ${time}s`} + type="button" + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => handleBookmarkActivate({ event })} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + handleBookmarkActivate({ event }); + }} + > + <div className="text-primary absolute top-[-1px] left-[-5px]"> + <HugeiconsIcon + icon={Bookmark02Icon} + aria-hidden="true" + className="fill-primary size-3" + /> + </div> + </button> + ); +} diff --git a/apps/web/src/components/editor/timeline/drag-line.tsx b/apps/web/src/components/editor/timeline/drag-line.tsx new file mode 100644 index 00000000..f31c6e28 --- /dev/null +++ b/apps/web/src/components/editor/timeline/drag-line.tsx @@ -0,0 +1,28 @@ +import { getDropLineY } from "@/lib/timeline/drop-utils"; +import type { TimelineTrack, DropTarget } from "@/types/timeline"; + +interface DragLineProps { + dropTarget: DropTarget | null; + tracks: TimelineTrack[]; + isVisible: boolean; + headerHeight?: number; +} + +export function DragLine({ + dropTarget, + tracks, + isVisible, + headerHeight = 0, +}: DragLineProps) { + if (!isVisible || !dropTarget) return null; + + const y = getDropLineY({ dropTarget, tracks }); + const lineTop = y + headerHeight; + + return ( + <div + className="bg-primary pointer-events-none absolute right-0 left-0 z-50 h-0.5" + style={{ top: `${lineTop}px` }} + /> + ); +} diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 4a59fd21..7e2b2b66 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -1,925 +1,530 @@ "use client"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Button } from "../../ui/button"; import { - Scissors, - ArrowLeftToLine, - ArrowRightToLine, - Trash2, - Snowflake, - Copy, - SplitSquareHorizontal, - Pause, - Play, - SkipBack, - Video, - Music, - TypeIcon, - Magnet, - Link, - ZoomIn, - ZoomOut, - Bookmark, - Eye, - VolumeOff, - Volume2, -} from "lucide-react"; + Delete02Icon, + TaskAdd02Icon, + ViewIcon, + ViewOffSlashIcon, + VolumeHighIcon, + VolumeOffIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; import { - 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"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; - -import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; -import { processMediaFiles } from "@/lib/media-processing"; - -import { toast } from "sonner"; -import { useState, useRef, useEffect, useCallback } from "react"; +import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom"; +import { useState, useRef, useCallback } from "react"; import { TimelineTrackContent } from "./timeline-track"; -import { - TimelinePlayhead, - useTimelinePlayheadRuler, -} from "./timeline-playhead"; +import { TimelinePlayhead } from "./timeline-playhead"; import { SelectionBox } from "../selection-box"; -import { useSelectionBox } from "@/hooks/use-selection-box"; -import { SnapIndicator } from "../snap-indicator"; -import { SnapPoint } from "@/hooks/use-timeline-snapping"; -import type { DragData, TimelineTrack, TrackType } from "@/types/timeline"; -import { TimelineCacheIndicator } from "./timeline-cache-indicator"; -import { TimelineMarker } from "./timeline-marker"; -import { useFrameCache } from "@/hooks/use-frame-cache"; +import { useSelectionBox } from "@/hooks/timeline/use-selection-box"; +import { SnapIndicator } from "./snap-indicator"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { TimelineTrack } from "@/types/timeline"; import { - getTrackHeight, - getCumulativeHeightBefore, - getTotalTracksHeight, - TIMELINE_CONSTANTS, - snapTimeToFrame, + TIMELINE_CONSTANTS, + TRACK_ICONS, } from "@/constants/timeline-constants"; -import { Slider } from "@/components/ui/slider"; -import { formatTimeCode } from "@/lib/time"; -import { EditableTimecode } from "@/components/ui/editable-timecode"; +import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction"; +import { + getTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, + canTracktHaveAudio, + canTrackBeHidden, + getTimelineZoomMin, + getTimelinePaddingPx, + isMainTrack, +} from "@/lib/timeline"; import { TimelineToolbar } from "./timeline-toolbar"; +import { useScrollSync } from "@/hooks/timeline/use-scroll-sync"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; +import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop"; +import { TimelineRuler } from "./timeline-ruler"; +import { TimelineBookmarksRow } from "./bookmarks"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useEditor } from "@/hooks/use-editor"; +import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; +import { DragLine } from "./drag-line"; +import { invokeAction } from "@/lib/actions"; 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, - getTotalDuration, - clearSelectedElements, - snappingEnabled, - setSelectedElements, - toggleTrackMute, - dragState, - } = useTimelineStore(); - const { mediaFiles, addMediaFile } = useMediaStore(); - const { activeProject } = useProjectStore(); - const { currentTime, duration, seek, setDuration } = usePlaybackStore(); - const { getRenderStatus } = useFrameCache(); - const [isDragOver, setIsDragOver] = useState(false); - const { addElementToNewTrack } = useTimelineStore(); - const dragCounterRef = useRef(0); - const timelineRef = useRef<HTMLDivElement>(null); - const rulerRef = useRef<HTMLDivElement>(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<HTMLDivElement>(null); - const tracksScrollRef = useRef<HTMLDivElement>(null); - const trackLabelsRef = useRef<HTMLDivElement>(null); - const playheadRef = useRef<HTMLDivElement>(null); - const trackLabelsScrollRef = useRef<HTMLDivElement>(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<HTMLDivElement>(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<SnapPoint | null>( - 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; - console.log( - JSON.stringify({ - debug_mousedown: "START", - target_class: target.className, - target_parent_class: target.parentElement?.className, - clientX: e.clientX, - clientY: e.clientY, - timeStamp: e.timeStamp, - }) - ); - - const isTimelineBackground = - !target.closest(".timeline-element") && - !playheadRef.current?.contains(target) && - !target.closest("[data-track-labels]"); - - console.log( - JSON.stringify({ - debug_mousedown: "CHECK", - isTimelineBackground, - hasTimelineElement: !!target.closest(".timeline-element"), - hasPlayhead: !!playheadRef.current?.contains(target), - hasTrackLabels: !!target.closest("[data-track-labels]"), - }) - ); - - if (isTimelineBackground) { - mouseTrackingRef.current = { - isMouseDown: true, - downX: e.clientX, - downY: e.clientY, - downTime: e.timeStamp, - }; - console.log( - JSON.stringify({ - debug_mousedown: "TRACKED", - mouseTracking: mouseTrackingRef.current, - }) - ); - } else { - console.log( - JSON.stringify({ - debug_mousedown: "IGNORED - not timeline background", - }) - ); - } - }, []); - - // Timeline content click to seek handler - const handleTimelineContentClick = useCallback( - (e: React.MouseEvent) => { - console.log( - JSON.stringify({ - debug_click: "START", - target: (e.target as HTMLElement).className, - target_parent: (e.target as HTMLElement).parentElement?.className, - mouseTracking: mouseTrackingRef.current, - isSelecting, - justFinishedSelecting, - clickX: e.clientX, - clickY: e.clientY, - timeStamp: e.timeStamp, - }) - ); - - 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({ - debug_click: "REJECTED - no mousedown", - mouseTracking: mouseTrackingRef.current, - }) - ); - 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({ - debug_click: "REJECTED - movement too large", - deltaX, - deltaY, - deltaTime, - }) - ); - return; - } - - // Don't seek if this was a selection box operation - if (isSelecting || justFinishedSelecting) { - console.log( - JSON.stringify({ - debug_click: "REJECTED - selection operation", - isSelecting, - justFinishedSelecting, - }) - ); - return; - } + const tracksContainerHeight = { min: 0, max: 800 }; + const { snappingEnabled } = useTimelineStore(); + const { clearElementSelection, setElementSelection } = useElementSelection(); + const editor = useEditor(); + const timeline = editor.timeline; + const tracks = timeline.getTracks(); + const seek = (time: number) => editor.playback.seek({ time }); - // Don't seek if clicking on timeline elements, but still deselect - if ((e.target as HTMLElement).closest(".timeline-element")) { - console.log( - JSON.stringify({ - debug_click: "REJECTED - clicked timeline element", - }) - ); - return; - } + // refs + const timelineRef = useRef<HTMLDivElement>(null); + const timelineHeaderRef = useRef<HTMLDivElement>(null); + const rulerRef = useRef<HTMLDivElement>(null); + const tracksContainerRef = useRef<HTMLDivElement>(null); + const tracksScrollRef = useRef<HTMLDivElement>(null); + const trackLabelsRef = useRef<HTMLDivElement>(null); + const playheadRef = useRef<HTMLDivElement>(null); + const trackLabelsScrollRef = useRef<HTMLDivElement>(null); - // Don't seek if clicking on playhead - if (playheadRef.current?.contains(e.target as Node)) { - console.log( - JSON.stringify({ - debug_click: "REJECTED - clicked playhead", - }) - ); - return; - } + // state + const [isResizing, setIsResizing] = useState(false); + const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( + null, + ); - // Don't seek if clicking on track labels - if ((e.target as HTMLElement).closest("[data-track-labels]")) { - console.log( - JSON.stringify({ - debug_click: "REJECTED - clicked track labels", - }) - ); - clearSelectedElements(); - return; - } + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); + const handleResizeStateChange = useCallback( + ({ isResizing: nextIsResizing }: { isResizing: boolean }) => { + setIsResizing(nextIsResizing); + if (!nextIsResizing) { + setCurrentSnapPoint(null); + } + }, + [], + ); - // Clear selected elements when clicking empty timeline area - console.log( - JSON.stringify({ - debug_click: "PROCEEDING - clearing elements", - clearingSelectedElements: true, - }) - ); - clearSelectedElements(); + const timelineDuration = timeline.getTotalDuration() || 0; + const minZoomLevel = getTimelineZoomMin({ + duration: timelineDuration, + containerWidth: tracksContainerRef.current?.clientWidth, + }); - // Determine if we're clicking in ruler or tracks area - const isRulerClick = (e.target as HTMLElement).closest( - "[data-ruler-area]" - ); + const savedViewState = editor.project.getTimelineViewState(); - console.log( - JSON.stringify({ - debug_click: "CALCULATING POSITION", - isRulerClick, - clientX: e.clientX, - clientY: e.clientY, - target_element: (e.target as HTMLElement).tagName, - target_class: (e.target as HTMLElement).className, - }) - ); + const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } = + useTimelineZoom({ + containerRef: timelineRef, + minZoom: minZoomLevel, + initialZoom: savedViewState?.zoomLevel, + initialScrollLeft: savedViewState?.scrollLeft, + initialPlayheadTime: savedViewState?.playheadTime, + tracksScrollRef, + rulerScrollRef: tracksScrollRef, + }); - let mouseX: number; - let scrollLeft = 0; + const { + dragState, + dragDropTarget, + handleElementMouseDown, + handleElementClick, + lastMouseXRef, + } = useElementInteraction({ + zoomLevel, + timelineRef, + tracksContainerRef, + tracksScrollRef, + headerRef: timelineHeaderRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); - if (isRulerClick) { - // Calculate based on ruler position - const rulerContent = rulerScrollRef.current; - if (!rulerContent) { - console.log( - JSON.stringify({ - debug_click: "ERROR - no ruler container found", - }) - ); - return; - } - const rect = rulerContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = rulerContent.scrollLeft; - } else { - const tracksContent = tracksScrollRef.current; - if (!tracksContent) { - return; - } - const rect = tracksContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = tracksContent.scrollLeft; - } + const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = + useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef: tracksScrollRef, + tracksScrollRef, + playheadRef, + }); - const rawTime = Math.max( - 0, - Math.min( - duration, - (mouseX + scrollLeft) / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) - ) - ); + const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ + containerRef: tracksContainerRef, + headerRef: timelineHeaderRef, + 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, - ] - ); + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + shouldIgnoreClick, + } = useSelectionBox({ + containerRef: tracksContainerRef, + onSelectionComplete: (elements) => { + setElementSelection({ elements }); + }, + tracksScrollRef, + zoomLevel, + }); - // Update timeline duration when tracks change - useEffect(() => { - const totalDuration = getTotalDuration(); - setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline - }, [tracks, setDuration, getTotalDuration]); + const containerWidth = tracksContainerRef.current?.clientWidth || 1000; + const contentWidth = + timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const paddingPx = getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom: minZoomLevel, + }); + const dynamicTimelineWidth = Math.max( + contentWidth + paddingPx, + containerWidth, + ); - // Old marquee system removed - using new SelectionBox component instead + const showSnapIndicator = + snappingEnabled && + currentSnapPoint !== null && + (dragState.isDragging || isResizing); - 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 { + handleTracksMouseDown, + handleTracksClick, + handleRulerMouseDown, + handleRulerClick, + } = useTimelineSeek({ + playheadRef, + trackLabelsRef, + rulerScrollRef: tracksScrollRef, + tracksScrollRef, + zoomLevel, + duration: timeline.getTotalDuration(), + isSelecting, + clearSelectedElements: clearElementSelection, + seek, + }); - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - }; + useScrollSync({ + tracksScrollRef, + trackLabelsScrollRef, + }); - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); + const timelineHeaderHeight = + timelineHeaderRef.current?.getBoundingClientRect().height ?? 0; - // Don't update state for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } + return ( + <section + className={ + "bg-panel relative flex h-full flex-col overflow-hidden rounded-sm" + } + {...dragProps} + aria-label="Timeline" + > + <TimelineToolbar + zoomLevel={zoomLevel} + minZoom={minZoomLevel} + setZoomLevel={({ zoom }) => setZoomLevel(zoom)} + /> - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) { - setIsDragOver(false); - } - }; + <div + className="relative flex flex-1 flex-col overflow-hidden" + ref={timelineRef} + > + <SnapIndicator + snapPoint={currentSnapPoint} + zoomLevel={zoomLevel} + tracks={tracks} + timelineRef={timelineRef} + trackLabelsRef={trackLabelsRef} + tracksScrollRef={tracksScrollRef} + isVisible={showSnapIndicator} + /> + <div className="flex flex-1 overflow-hidden"> + <div className="bg-panel flex w-28 shrink-0 flex-col border-r"> + <div className="bg-panel flex h-4 items-center justify-between px-3"> + <span className="opacity-0">.</span> + </div> + <div className="bg-panel flex h-4 items-center justify-between px-3"> + <span className="opacity-0">.</span> + </div> + {tracks.length > 0 && ( + <div + ref={trackLabelsRef} + className="bg-panel flex-1 overflow-y-auto" + style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }} + > + <ScrollArea className="size-full" ref={trackLabelsScrollRef}> + <div className="flex flex-col gap-1"> + {tracks.map((track) => ( + <div + key={track.id} + className="group flex items-center px-3" + style={{ + height: `${getTrackHeight({ type: track.type })}px`, + }} + > + <div className="flex min-w-0 flex-1 items-center justify-end gap-2"> + {process.env.NODE_ENV === "development" && + isMainTrack(track) && ( + <div className="bg-red-500 size-1.5 rounded-full" /> + )} + {canTracktHaveAudio(track) && ( + <TrackToggleIcon + isOff={track.muted} + icons={{ + on: VolumeHighIcon, + off: VolumeOffIcon, + }} + onClick={() => + editor.timeline.toggleTrackMute({ + trackId: track.id, + }) + } + /> + )} + {canTrackBeHidden(track) && ( + <TrackToggleIcon + isOff={track.hidden} + icons={{ + on: ViewIcon, + off: ViewOffSlashIcon, + }} + onClick={() => + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + <TrackIcon track={track} /> + </div> + </div> + ))} + </div> + </ScrollArea> + </div> + )} + </div> - 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 - addElementToNewTrack(dragData); - } else { - // Handle media items - const mediaItem = mediaFiles.find( - (item: any) => item.id === dragData.id - ); - if (!mediaItem) { - toast.error("Media item not found"); - return; - } - - addElementToNewTrack(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; - } - - try { - const processedItems = await processMediaFiles(e.dataTransfer.files); - for (const processedItem of processedItems) { - await addMediaFile(activeProject.id, processedItem); - const currentMediaFiles = mediaFiles; - const addedItem = currentMediaFiles.find( - (item) => - item.name === processedItem.name && item.url === processedItem.url - ); - if (addedItem) { - const trackType: TrackType = - addedItem.type === "audio" ? "audio" : "media"; - const targetTrackId = useTimelineStore - .getState() - .insertTrackAt(trackType, 0); - - useTimelineStore.getState().addElementToTrack(targetTrackId, { - type: "media", - mediaId: addedItem.id, - name: addedItem.name, - duration: addedItem.duration || 5, - startTime: currentTime, - trimStart: 0, - trimEnd: 0, - }); - } - } - } catch (error) { - // Show error if file processing fails - console.error("Error processing external files:", error); - toast.error("Failed to process dropped files"); - } - } - }; - - const dragProps = { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }; - - // --- Scroll synchronization effect --- - useEffect(() => { - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - const trackLabelsViewport = trackLabelsScrollRef.current; - - 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 ( - <div - className={ - "h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden" - } - {...dragProps} - onMouseEnter={() => setIsInTimeline(true)} - onMouseLeave={() => setIsInTimeline(false)} - > - <TimelineToolbar zoomLevel={zoomLevel} setZoomLevel={setZoomLevel} /> - - {/* Timeline Container */} - <div - className="flex-1 flex flex-col overflow-hidden relative" - ref={timelineRef} - > - <TimelinePlayhead - currentTime={currentTime} - duration={duration} - zoomLevel={zoomLevel} - tracks={tracks} - seek={seek} - rulerRef={rulerRef} - rulerScrollRef={rulerScrollRef} - tracksScrollRef={tracksScrollRef} - trackLabelsRef={trackLabelsRef} - timelineRef={timelineRef} - playheadRef={playheadRef} - isSnappingToPlayhead={ - showSnapIndicator && currentSnapPoint?.type === "playhead" - } - /> - <SnapIndicator - snapPoint={currentSnapPoint} - zoomLevel={zoomLevel} - tracks={tracks} - timelineRef={timelineRef} - trackLabelsRef={trackLabelsRef} - tracksScrollRef={tracksScrollRef} - isVisible={showSnapIndicator} - /> - {/* Timeline Header with Ruler */} - <div className="flex bg-panel sticky top-0 z-10"> - {/* Track Labels Header */} - <div className="w-28 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2"> - {/* Empty space */} - <span className="text-sm font-medium text-muted-foreground opacity-0"> - . - </span> - </div> - - {/* Timeline Ruler */} - <div - className="flex-1 relative overflow-hidden h-10" - onWheel={(e) => { - // Check if this is horizontal scrolling - if so, don't handle it here - if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { - return; // Let ScrollArea handle horizontal scrolling - } - handleWheel(e); - }} - onMouseDown={handleSelectionMouseDown} - onClick={handleTimelineContentClick} - data-ruler-area - > - <ScrollArea - className="w-full" - ref={rulerScrollRef} - onScroll={(e) => { - if (isUpdatingRef.current) return; - isUpdatingRef.current = true; - const tracksViewport = tracksScrollRef.current; - if (tracksViewport) { - tracksViewport.scrollLeft = ( - e.currentTarget as HTMLDivElement - ).scrollLeft; - } - isUpdatingRef.current = false; - }} - > - <div - ref={rulerRef} - className="relative h-10 select-none cursor-default" - style={{ - width: `${dynamicTimelineWidth}px`, - }} - onMouseDown={handleRulerMouseDown} - > - <TimelineCacheIndicator - duration={duration} - zoomLevel={zoomLevel} - tracks={tracks} - mediaFiles={mediaFiles} - activeProject={activeProject} - getRenderStatus={getRenderStatus} - /> - {/* 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 ( - <TimelineMarker - key={i} - time={time} - zoomLevel={zoomLevel} - interval={interval} - isMainMarker={isMainMarker} - /> - ); - }).filter(Boolean); - })()} - - {/* Bookmark markers */} - {(() => { - const { activeProject } = useProjectStore.getState(); - if (!activeProject?.bookmarks?.length) return null; - - return activeProject.bookmarks.map((bookmarkTime, i) => ( - <div - key={`bookmark-${i}`} - className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer" - style={{ - left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, - }} - onClick={(e) => { - e.stopPropagation(); - usePlaybackStore.getState().seek(bookmarkTime); - }} - > - <div className="absolute top-[-1px] left-[-5px] text-primary"> - <Bookmark className="h-3 w-3 fill-primary" /> - </div> - </div> - )); - })()} - </div> - </ScrollArea> - </div> - </div> - - {/* Tracks Area */} - <div className="flex-1 flex overflow-hidden"> - {/* Track Labels */} - {tracks.length > 0 && ( - <div - ref={trackLabelsRef} - className="w-28 shrink-0 border-r overflow-y-auto z-100 bg-panel" - data-track-labels - > - <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> - <div className="flex flex-col gap-1"> - {tracks.map((track) => ( - <div - key={track.id} - className="flex items-center px-3 group" - style={{ height: `${getTrackHeight(track.type)}px` }} - > - <div className="flex items-center justify-end flex-1 min-w-0 gap-2"> - {track.muted ? ( - <VolumeOff - className="h-4 w-4 text-destructive cursor-pointer" - onClick={() => toggleTrackMute(track.id)} - /> - ) : ( - <Volume2 - className="h-4 w-4 text-muted-foreground cursor-pointer" - onClick={() => toggleTrackMute(track.id)} - /> - )} - <Eye className="h-4 w-4 text-muted-foreground" /> - <TrackIcon track={track} /> - </div> - </div> - ))} - </div> - </ScrollArea> - </div> - )} - - {/* Timeline Tracks Content */} - <div - className="flex-1 relative overflow-hidden" - onWheel={(e) => { - // Check if this is horizontal scrolling - if so, don't handle it here - if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { - return; // Let ScrollArea handle horizontal scrolling - } - handleWheel(e); - }} - onMouseDown={(e) => { - handleTimelineMouseDown(e); - handleSelectionMouseDown(e); - }} - onClick={handleTimelineContentClick} - ref={tracksContainerRef} - > - <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} - /> - <ScrollArea - className="w-full h-full" - ref={tracksScrollRef} - onScroll={(e) => { - if (isUpdatingRef.current) return; - isUpdatingRef.current = true; - const rulerViewport = rulerScrollRef.current; - if (rulerViewport) { - rulerViewport.scrollLeft = ( - e.currentTarget as HTMLDivElement - ).scrollLeft; - } - isUpdatingRef.current = false; - }} - > - <div - className="relative flex-1" - style={{ - height: `${Math.max( - 200, - Math.min(800, getTotalTracksHeight(tracks)) - )}px`, - width: `${dynamicTimelineWidth}px`, - }} - > - {tracks.length === 0 ? ( - <div /> - ) : ( - <> - {tracks.map((track, index) => ( - <ContextMenu key={track.id}> - <ContextMenuTrigger asChild> - <div - className="absolute left-0 right-0" - style={{ - top: `${getCumulativeHeightBefore( - tracks, - index - )}px`, - height: `${getTrackHeight(track.type)}px`, - }} - onClick={(e) => { - // If clicking empty area (not on a element), deselect all elements - if ( - !(e.target as HTMLElement).closest( - ".timeline-element" - ) - ) { - clearSelectedElements(); - } - }} - > - <TimelineTrackContent - track={track} - zoomLevel={zoomLevel} - onSnapPointChange={handleSnapPointChange} - rulerScrollRef={rulerScrollRef} - tracksScrollRef={tracksScrollRef} - /> - </div> - </ContextMenuTrigger> - <ContextMenuContent className="z-200"> - <ContextMenuItem - onClick={(e) => { - e.stopPropagation(); - toggleTrackMute(track.id); - }} - > - {track.muted ? "Unmute Track" : "Mute Track"} - </ContextMenuItem> - <ContextMenuItem onClick={(e) => e.stopPropagation()}> - Track settings (soon) - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ))} - </> - )} - </div> - </ScrollArea> - </div> - </div> - </div> - </div> - ); + <div + className="relative flex flex-1 flex-col overflow-hidden" + ref={tracksContainerRef} + > + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> + <DragLine + dropTarget={dropTarget} + tracks={timeline.getTracks()} + isVisible={isDragOver} + headerHeight={timelineHeaderHeight} + /> + <DragLine + dropTarget={dragDropTarget} + tracks={timeline.getTracks()} + isVisible={dragState.isDragging} + headerHeight={timelineHeaderHeight} + /> + <ScrollArea + className="size-full overflow-y-hidden" + ref={tracksScrollRef} + onMouseDown={(event) => { + const isDirectTarget = event.target === event.currentTarget; + if (!isDirectTarget) return; + event.stopPropagation(); + handleTracksMouseDown(event); + handleSelectionMouseDown(event); + }} + onClick={(event) => { + const isDirectTarget = event.target === event.currentTarget; + if (!isDirectTarget) return; + event.stopPropagation(); + handleTracksClick(event); + }} + onWheel={(event) => { + if ( + event.shiftKey || + Math.abs(event.deltaX) > Math.abs(event.deltaY) + ) { + return; + } + handleWheel(event); + }} + onScroll={() => { + saveScrollPosition(); + }} + > + <div + className="relative" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + > + <div + ref={timelineHeaderRef} + className="bg-panel sticky top-0 z-30 flex flex-col" + > + <TimelineRuler + zoomLevel={zoomLevel} + dynamicTimelineWidth={dynamicTimelineWidth} + rulerRef={rulerRef} + handleWheel={handleWheel} + handleTimelineContentClick={handleRulerClick} + handleRulerTrackingMouseDown={handleRulerMouseDown} + handleRulerMouseDown={handlePlayheadRulerMouseDown} + /> + <TimelineBookmarksRow + zoomLevel={zoomLevel} + dynamicTimelineWidth={dynamicTimelineWidth} + handleWheel={handleWheel} + handleTimelineContentClick={handleRulerClick} + handleRulerTrackingMouseDown={handleRulerMouseDown} + handleRulerMouseDown={handlePlayheadRulerMouseDown} + /> + </div> + <TimelinePlayhead + zoomLevel={zoomLevel} + rulerRef={rulerRef} + rulerScrollRef={tracksScrollRef} + tracksScrollRef={tracksScrollRef} + timelineRef={timelineRef} + playheadRef={playheadRef} + isSnappingToPlayhead={ + showSnapIndicator && currentSnapPoint?.type === "playhead" + } + /> + <div + className="relative" + style={{ + height: `${Math.max( + tracksContainerHeight.min, + Math.min( + tracksContainerHeight.max, + getTotalTracksHeight({ tracks }), + ), + )}px`, + }} + > + {tracks.length === 0 ? ( + <div /> + ) : ( + tracks.map((track, index) => ( + <ContextMenu key={track.id}> + <ContextMenuTrigger asChild> + <div + className="absolute right-0 left-0" + style={{ + top: `${getCumulativeHeightBefore({ + tracks, + trackIndex: index, + })}px`, + height: `${getTrackHeight({ + type: track.type, + })}px`, + }} + > + <TimelineTrackContent + track={track} + zoomLevel={zoomLevel} + dragState={dragState} + rulerScrollRef={tracksScrollRef} + tracksScrollRef={tracksScrollRef} + lastMouseXRef={lastMouseXRef} + onSnapPointChange={handleSnapPointChange} + onResizeStateChange={handleResizeStateChange} + onElementMouseDown={handleElementMouseDown} + onElementClick={handleElementClick} + onTrackMouseDown={handleSelectionMouseDown} + shouldIgnoreClick={shouldIgnoreClick} + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent className="z-200 w-40"> + <ContextMenuItem + icon={<HugeiconsIcon icon={TaskAdd02Icon} />} + onClick={(e) => { + e.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + </ContextMenuItem> + <ContextMenuItem + onClick={(e) => { + e.stopPropagation(); + timeline.toggleTrackMute({ + trackId: track.id, + }); + }} + > + <HugeiconsIcon icon={VolumeHighIcon} /> + <span> + {canTracktHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + </span> + </ContextMenuItem> + <ContextMenuItem + onClick={(e) => { + e.stopPropagation(); + timeline.toggleTrackVisibility({ + trackId: track.id, + }); + }} + > + <HugeiconsIcon icon={ViewIcon} /> + <span> + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + </span> + </ContextMenuItem> + <ContextMenuItem + onClick={(e) => { + e.stopPropagation(); + timeline.removeTrack({ + trackId: track.id, + }); + }} + variant="destructive" + > + <HugeiconsIcon icon={Delete02Icon} /> + Delete track + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + )) + )} + </div> + </div> + </ScrollArea> + </div> + </div> + </div> + </section> + ); } function TrackIcon({ track }: { track: TimelineTrack }) { - return ( - <> - {track.type === "media" && ( - <Video className="w-4 h-4 shrink-0 text-muted-foreground" /> - )} - {track.type === "text" && ( - <TypeIcon className="w-4 h-4 shrink-0 text-muted-foreground" /> - )} - {track.type === "audio" && ( - <Music className="w-4 h-4 shrink-0 text-muted-foreground" /> - )} - </> - ); + return <>{TRACK_ICONS[track.type]}</>; +} + +function TrackToggleIcon({ + isOff, + icons, + onClick, +}: { + isOff: boolean; + icons: { + on: IconSvgElement; + off: IconSvgElement; + }; + onClick: () => void; +}) { + return ( + <> + {isOff ? ( + <HugeiconsIcon + icon={icons.off} + className="text-destructive size-4 cursor-pointer" + onClick={onClick} + /> + ) : ( + <HugeiconsIcon + icon={icons.on} + className="text-muted-foreground size-4 cursor-pointer" + onClick={onClick} + /> + )} + </> + ); } diff --git a/apps/web/src/components/editor/timeline/snap-indicator.tsx b/apps/web/src/components/editor/timeline/snap-indicator.tsx new file mode 100644 index 00000000..6bfdf086 --- /dev/null +++ b/apps/web/src/components/editor/timeline/snap-indicator.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import type { TimelineTrack } from "@/types/timeline"; + +interface SnapIndicatorProps { + snapPoint: SnapPoint | null; + zoomLevel: number; + isVisible: boolean; + tracks: TimelineTrack[]; + timelineRef: React.RefObject<HTMLDivElement>; + trackLabelsRef?: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; +} + +export function SnapIndicator({ + snapPoint, + zoomLevel, + isVisible, + tracks, + timelineRef, + trackLabelsRef, + tracksScrollRef, +}: SnapIndicatorProps) { + const { leftPosition, topPosition, height } = useSnapIndicatorPosition({ + snapPoint, + zoomLevel, + tracks, + timelineRef, + trackLabelsRef, + tracksScrollRef, + }); + + if (!isVisible || !snapPoint) { + return null; + } + + return ( + <div + className="pointer-events-none absolute z-90" + style={{ + left: `${leftPosition}px`, + top: topPosition, + height: `${height}px`, + width: "2px", + }} + > + <div className={"bg-primary/40 h-full w-0.5 opacity-80"} /> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx deleted file mode 100644 index 7def2985..00000000 --- a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx +++ /dev/null @@ -1,126 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { TimelineTrack } from "@/types/timeline"; -import { MediaFile } from "@/types/media"; -import { TProject } from "@/types/project"; -import { useSceneStore } from "@/stores/scene-store"; - -interface CacheSegment { - startTime: number; - endTime: number; - cached: boolean; -} - -interface TimelineCacheIndicatorProps { - duration: number; - zoomLevel: number; - tracks: TimelineTrack[]; - mediaFiles: MediaFile[]; - activeProject: TProject | null; - getRenderStatus: ( - time: number, - tracks: TimelineTrack[], - mediaFiles: MediaFile[], - activeProject: TProject | null, - sceneId?: string - ) => "cached" | "not-cached"; -} - -export function TimelineCacheIndicator({ - duration, - zoomLevel, - tracks, - mediaFiles, - activeProject, - getRenderStatus, -}: TimelineCacheIndicatorProps) { - const { currentScene } = useSceneStore(); - - // Calculate cache segments by sampling the timeline - const calculateCacheSegments = (): CacheSegment[] => { - const segments: CacheSegment[] = []; - const sampleRate = 10; // Sample every 0.1 seconds - const totalSamples = Math.ceil(duration * sampleRate); - - if (totalSamples === 0) { - return [{ startTime: 0, endTime: duration, cached: false }]; - } - - let currentSegment: CacheSegment | null = null; - - for (let i = 0; i <= totalSamples; i++) { - const time = i / sampleRate; - const cached = - getRenderStatus( - time, - tracks, - mediaFiles, - activeProject, - currentScene?.id - ) === "cached"; - - if (!currentSegment) { - // Start first segment - currentSegment = { - startTime: time, - endTime: time, - cached, - }; - } else if (currentSegment.cached === cached) { - // Extend current segment - currentSegment.endTime = time; - } else { - // Finish current segment and start new one - segments.push(currentSegment); - currentSegment = { - startTime: time, - endTime: time, - cached, - }; - } - } - - // Add the last segment - if (currentSegment) { - currentSegment.endTime = duration; - segments.push(currentSegment); - } - - return segments; - }; - - const cacheSegments = calculateCacheSegments(); - - return ( - <div className="absolute top-0 left-0 right-0 h-px z-10 pointer-events-none"> - {cacheSegments.map((segment, index) => { - const startX = - segment.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const endX = - segment.endTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const width = endX - startX; - - return ( - <div - key={index} - className={cn( - "absolute top-0 h-px", - segment.cached ? "bg-primary" : "bg-transparent" - )} - style={{ - left: `${startX}px`, - width: `${width}px`, - }} - title={ - segment.cached - ? "Cached (fast playback)" - : "Not cached (will render)" - } - /> - ); - })} - </div> - ); -} diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index ca47d3ca..fd292ad8 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -1,387 +1,554 @@ "use client"; +import { useEditor } from "@/hooks/use-editor"; +import { useAssetsPanelStore } from "@/stores/assets-panel-store"; +import AudioWaveform from "./audio-waveform"; +import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { - Scissors, - Trash2, - Copy, - Search, - RefreshCw, - EyeOff, - Eye, - Volume2, - VolumeX, -} from "lucide-react"; -import { useMediaStore } from "@/stores/media-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import AudioWaveform from "../audio-waveform"; -import { toast } from "sonner"; -import { TimelineElementProps, MediaElement } from "@/types/timeline"; -import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; + getTrackClasses, + getTrackHeight, + canElementHaveAudio, + canElementBeHidden, + hasMediaId, +} from "@/lib/timeline"; import { - getTrackElementClasses, - TIMELINE_CONSTANTS, - getTrackHeight, -} from "@/constants/timeline-constants"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, } from "../../ui/context-menu"; -import { useMediaPanelStore } from "../media-panel/store"; +import type { + TimelineElement as TimelineElementType, + TimelineTrack, + ElementDragState, +} from "@/types/timeline"; +import type { MediaAsset } from "@/types/assets"; +import { mediaSupportsAudio } from "@/lib/media/media-utils"; +import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import Image from "next/image"; +import { + ScissorIcon, + Delete02Icon, + Copy01Icon, + ViewIcon, + ViewOffSlashIcon, + VolumeHighIcon, + VolumeOffIcon, + VolumeMute02Icon, + Search01Icon, + Exchange01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { uppercase } from "@/utils/string"; +import type { ComponentProps } from "react"; -export function TimelineElement({ - element, - track, - zoomLevel, - isSelected, - onElementMouseDown, - onElementClick, -}: TimelineElementProps) { - const { mediaFiles } = useMediaStore(); - const { - dragState, - copySelected, - selectedElements, - deleteSelected, - splitSelected, - toggleSelectedHidden, - toggleSelectedMuted, - duplicateElement, - revealElementInMedia, - replaceElementWithFile, - getContextMenuState, - } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); +function getDisplayShortcut(action: TAction) { + const { defaultShortcuts } = getActionDefinition(action); + if (!defaultShortcuts?.length) { + return ""; + } - const mediaItem = - element.type === "media" - ? mediaFiles.find((file) => file.id === element.mediaId) - : null; - const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video"; + return uppercase({ + string: defaultShortcuts[0].replace("+", " "), + }); +} - const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } = - useTimelineElementResize({ - element, - track, - zoomLevel, - }); +interface TimelineElementProps { + element: TimelineElementType; + track: TimelineTrack; + zoomLevel: number; + isSelected: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; + onResizeStateChange?: (params: { isResizing: boolean }) => void; + onElementMouseDown: ( + e: React.MouseEvent, + element: TimelineElementType, + ) => void; + onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; + dragState: ElementDragState; +} - const { - isMultipleSelected, - isCurrentElementSelected, - hasAudioElements, - canSplitSelected, - } = getContextMenuState(track.id, element.id); +export function TimelineElement({ + element, + track, + zoomLevel, + isSelected, + onSnapPointChange, + onResizeStateChange, + onElementMouseDown, + onElementClick, + dragState, +}: TimelineElementProps) { + const editor = useEditor(); + const { selectedElements } = useElementSelection(); + const { requestRevealMedia } = useAssetsPanelStore(); - const effectiveDuration = - element.duration - element.trimStart - element.trimEnd; - const elementWidth = Math.max( - TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH, - effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel - ); + const mediaAssets = editor.media.getAssets(); + let mediaAsset: MediaAsset | null = null; - const isBeingDragged = dragState.elementId === element.id; - const elementStartTime = - isBeingDragged && dragState.isDragging - ? dragState.currentTime - : element.startTime; + if (hasMediaId(element)) { + mediaAsset = + mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; + } - const elementLeft = elementStartTime * 50 * zoomLevel; + const hasAudio = mediaSupportsAudio({ media: mediaAsset }); - const handleElementSplitContext = (e: React.MouseEvent) => { - e.stopPropagation(); - splitSelected( - currentTime, - isMultipleSelected && isCurrentElementSelected ? undefined : track.id, - isMultipleSelected && isCurrentElementSelected ? undefined : element.id - ); - }; + const { handleResizeStart, isResizing, currentStartTime, currentDuration } = + useTimelineElementResize({ + element, + track, + zoomLevel, + onSnapPointChange, + onResizeStateChange, + }); - const handleElementDuplicateContext = (e: React.MouseEvent) => { - e.stopPropagation(); - duplicateElement(track.id, element.id); - }; + const isCurrentElementSelected = selectedElements.some( + (selected) => + selected.elementId === element.id && selected.trackId === track.id, + ); - const handleElementCopyContext = (e: React.MouseEvent) => { - e.stopPropagation(); - copySelected(); - }; + const isBeingDragged = dragState.elementId === element.id; + const dragOffsetY = + isBeingDragged && dragState.isDragging + ? dragState.currentMouseY - dragState.startMouseY + : 0; + const elementStartTime = + isBeingDragged && dragState.isDragging + ? dragState.currentTime + : element.startTime; + const displayedStartTime = isResizing ? currentStartTime : elementStartTime; + const displayedDuration = isResizing ? currentDuration : element.duration; + const elementWidth = + displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const elementLeft = displayedStartTime * 50 * zoomLevel; - const handleElementDeleteContext = (e: React.MouseEvent) => { - e.stopPropagation(); - deleteSelected( - isMultipleSelected && isCurrentElementSelected ? undefined : track.id, - isMultipleSelected && isCurrentElementSelected ? undefined : element.id - ); - }; + const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { + event.stopPropagation(); + if (hasMediaId(element)) { + requestRevealMedia(element.mediaId); + } + }; - const handleToggleElementContext = (e: React.MouseEvent) => { - e.stopPropagation(); + const isMuted = canElementHaveAudio(element) && element.muted === true; - if (hasAudio && element.type === "media") { - toggleSelectedMuted( - isMultipleSelected && isCurrentElementSelected ? undefined : track.id, - isMultipleSelected && isCurrentElementSelected ? undefined : element.id - ); - } else { - toggleSelectedHidden( - isMultipleSelected && isCurrentElementSelected ? undefined : track.id, - isMultipleSelected && isCurrentElementSelected ? undefined : element.id - ); - } - }; + return ( + <ContextMenu> + <ContextMenuTrigger asChild> + <div + className={`absolute top-0 h-full select-none ${ + isBeingDragged ? "z-30" : "z-10" + }`} + style={{ + left: `${elementLeft}px`, + width: `${elementWidth}px`, + transform: + isBeingDragged && dragState.isDragging + ? `translate3d(0, ${dragOffsetY}px, 0)` + : undefined, + }} + > + <ElementInner + element={element} + track={track} + isSelected={isSelected} + isBeingDragged={isBeingDragged} + hasAudio={hasAudio} + isMuted={isMuted} + mediaAssets={mediaAssets} + onElementClick={onElementClick} + onElementMouseDown={onElementMouseDown} + handleResizeStart={handleResizeStart} + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent className="z-200 w-64"> + <ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}> + Split + </ActionMenuItem> + <CopyMenuItem /> + {canElementHaveAudio(element) && hasAudio && ( + <MuteMenuItem + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + isMuted={isMuted} + /> + )} + {canElementBeHidden(element) && ( + <VisibilityMenuItem + element={element} + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + /> + )} + {selectedElements.length === 1 && ( + <ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}> + Duplicate + </ActionMenuItem> + )} + {selectedElements.length === 1 && hasMediaId(element) && ( + <> + <ContextMenuItem + icon={<HugeiconsIcon icon={Search01Icon} />} + onClick={(event) => handleRevealInMedia({ event })} + > + Reveal media + </ContextMenuItem> + <ContextMenuItem + icon={<HugeiconsIcon icon={Exchange01Icon} />} + disabled + > + Replace media + </ContextMenuItem> + </> + )} + <ContextMenuSeparator /> + <DeleteMenuItem + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + elementType={element.type} + selectedCount={selectedElements.length} + /> + </ContextMenuContent> + </ContextMenu> + ); +} - const handleReplaceClip = (e: React.MouseEvent) => { - e.stopPropagation(); +function ElementInner({ + element, + track, + isSelected, + isBeingDragged, + hasAudio, + isMuted, + mediaAssets, + onElementClick, + onElementMouseDown, + handleResizeStart, +}: { + element: TimelineElementType; + track: TimelineTrack; + isSelected: boolean; + isBeingDragged: boolean; + hasAudio: boolean; + isMuted: boolean; + mediaAssets: MediaAsset[]; + onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void; + onElementMouseDown: ( + e: React.MouseEvent, + element: TimelineElementType, + ) => void; + handleResizeStart: (params: { + e: React.MouseEvent; + elementId: string; + side: "left" | "right"; + }) => void; +}) { + return ( + <div + className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses( + { + type: track.type, + }, + )} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`} + > + <button + type="button" + className="absolute inset-0 size-full cursor-pointer" + onClick={(e) => onElementClick(e, element)} + onMouseDown={(e) => onElementMouseDown(e, element)} + > + <div className="absolute inset-0 flex h-full items-center"> + <ElementContent + element={element} + track={track} + isSelected={isSelected} + mediaAssets={mediaAssets} + /> + </div> - const input = document.createElement("input"); - input.type = "file"; - input.accept = "video/*,audio/*,image/*"; - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (file) { - await replaceElementWithFile(track.id, element.id, file); - } - }; - input.click(); - }; + {(hasAudio + ? isMuted + : canElementBeHidden(element) && element.hidden) && ( + <div className="bg-opacity-50 pointer-events-none absolute inset-0 flex items-center justify-center bg-black"> + {hasAudio ? ( + <HugeiconsIcon + icon={VolumeHighIcon} + className="size-6 text-white" + /> + ) : ( + <HugeiconsIcon + icon={VolumeOffIcon} + className="size-6 text-white" + /> + )} + </div> + )} + </button> - const handleRevealInMedia = (e: React.MouseEvent) => { - e.stopPropagation(); - revealElementInMedia(element.id); - }; + {isSelected && ( + <> + <ResizeHandle + side="left" + elementId={element.id} + handleResizeStart={handleResizeStart} + /> + <ResizeHandle + side="right" + elementId={element.id} + handleResizeStart={handleResizeStart} + /> + </> + )} + </div> + ); +} + +function ResizeHandle({ + side, + elementId, + handleResizeStart, +}: { + side: "left" | "right"; + elementId: string; + handleResizeStart: (params: { + e: React.MouseEvent; + elementId: string; + side: "left" | "right"; + }) => void; +}) { + const isLeft = side === "left"; + return ( + <button + type="button" + className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`} + onMouseDown={(e) => handleResizeStart({ e, elementId, side })} + aria-label={`${isLeft ? "Left" : "Right"} resize handle`} + > + <div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" /> + </button> + ); +} - const renderElementContent = () => { - if (element.type === "text") { - return ( - <div className="w-full h-full flex items-center justify-start pl-2"> - <span className="text-xs text-white truncate">{element.content}</span> - </div> - ); - } +function ElementContent({ + element, + track, + isSelected, + mediaAssets, +}: { + element: TimelineElementType; + track: TimelineTrack; + isSelected: boolean; + mediaAssets: MediaAsset[]; +}) { + if (element.type === "text") { + return ( + <div className="flex size-full items-center justify-start pl-2"> + <span className="truncate text-xs text-white">{element.content}</span> + </div> + ); + } - const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); - if (!mediaItem) { - return ( - <span className="text-xs text-foreground/80 truncate"> - {element.name} - </span> - ); - } + if (element.type === "sticker") { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`} + alt={element.name} + className="size-5 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); + } - if ( - mediaItem.type === "image" || - (mediaItem.type === "video" && mediaItem.thumbnailUrl) - ) { - const trackHeight = getTrackHeight(track.type); - const tileWidth = trackHeight * (16 / 9); + if (element.type === "audio") { + const audioBuffer = + element.sourceType === "library" ? element.buffer : undefined; - const imageUrl = - mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl; + const audioUrl = + element.sourceType === "library" + ? element.sourceUrl + : mediaAssets.find((asset) => asset.id === element.mediaId)?.url; - return ( - <div className="w-full h-full flex items-center justify-center"> - <div - className={`w-full h-full relative ${ - isSelected ? "bg-primary" : "bg-transparent" - }`} - > - <div - className={`absolute top-[0.25rem] bottom-[0.25rem] left-0 right-0`} - style={{ - backgroundImage: imageUrl ? `url(${imageUrl})` : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`} - /> - </div> - </div> - ); - } + if (audioBuffer || audioUrl) { + return ( + <div className="flex size-full items-center gap-2"> + <div className="min-w-0 flex-1"> + <AudioWaveform + audioBuffer={audioBuffer} + audioUrl={audioUrl} + height={24} + className="w-full" + /> + </div> + </div> + ); + } - if (mediaItem.type === "audio") { - return ( - <div className="w-full h-full flex items-center gap-2"> - <div className="flex-1 min-w-0"> - <AudioWaveform - audioUrl={mediaItem.url || ""} - height={24} - className="w-full" - /> - </div> - </div> - ); - } + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } - return ( - <span className="text-xs text-foreground/80 truncate"> - {element.name} - </span> - ); - }; + const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); + if (!mediaAsset) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } - const handleElementMouseDown = (e: React.MouseEvent) => { - if (onElementMouseDown) { - onElementMouseDown(e, element); - } - }; + if ( + mediaAsset.type === "image" || + (mediaAsset.type === "video" && mediaAsset.thumbnailUrl) + ) { + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * (16 / 9); + const imageUrl = + mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl; - const isMuted = element.type === "media" && element.muted; + return ( + <div className="flex size-full items-center justify-center"> + <div + className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`} + > + <div + className="absolute right-0 left-0" + style={{ + backgroundImage: imageUrl ? `url(${imageUrl})` : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${trackHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + top: isSelected ? "0.25rem" : "0rem", + bottom: isSelected ? "0.25rem" : "0rem", + }} + /> + </div> + </div> + ); + } - return ( - <ContextMenu> - <ContextMenuTrigger asChild> - <div - className={`absolute top-0 h-full select-none timeline-element ${ - isBeingDragged ? "z-50" : "z-10" - }`} - style={{ - left: `${elementLeft}px`, - width: `${elementWidth}px`, - }} - data-element-id={element.id} - data-track-id={track.id} - onMouseMove={resizing ? handleResizeMove : undefined} - onMouseUp={resizing ? handleResizeEnd : undefined} - onMouseLeave={resizing ? handleResizeEnd : undefined} - > - <div - className={`relative h-full rounded-[0.5rem] cursor-pointer overflow-hidden ${getTrackElementClasses( - track.type - )} ${isSelected ? "" : ""} ${ - isBeingDragged ? "z-50" : "z-10" - } ${element.hidden ? "opacity-50" : ""}`} - onClick={(e) => onElementClick && onElementClick(e, element)} - onMouseDown={handleElementMouseDown} - onContextMenu={(e) => - onElementMouseDown && onElementMouseDown(e, element) - } - > - <div className="absolute inset-0 flex items-center h-full"> - {renderElementContent()} - </div> + return ( + <span className="text-foreground/80 truncate text-xs">{element.name}</span> + ); +} - {(hasAudio ? isMuted : element.hidden) && ( - <div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none"> - {hasAudio ? ( - <VolumeX className="h-6 w-6 text-white" /> - ) : ( - <EyeOff className="h-6 w-6 text-white" /> - )} - </div> - )} +function CopyMenuItem() { + return ( + <ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}> + Copy + </ActionMenuItem> + ); +} - {isSelected && ( - <> - <div - className="absolute left-0 top-0 bottom-0 w-[0.6rem] cursor-w-resize bg-primary z-50 flex items-center justify-center" - onMouseDown={(e) => handleResizeStart(e, element.id, "left")} - > - <div className="w-[0.2rem] h-[1.5rem] bg-foreground/75 rounded-full" /> - </div> - <div - className="absolute right-0 top-0 bottom-0 w-[0.6rem] cursor-e-resize bg-primary z-50 flex items-center justify-center" - onMouseDown={(e) => handleResizeStart(e, element.id, "right")} - > - <div className="w-[0.2rem] h-[1.5rem] bg-foreground/75 rounded-full" /> - </div> - </> - )} - </div> - </div> - </ContextMenuTrigger> - <ContextMenuContent className="z-200"> - {(!isMultipleSelected || - (isMultipleSelected && - isCurrentElementSelected && - canSplitSelected)) && ( - <ContextMenuItem onClick={handleElementSplitContext}> - <Scissors className="h-4 w-4 mr-2" /> - {isMultipleSelected && isCurrentElementSelected - ? `Split ${selectedElements.length} elements at playhead` - : "Split at playhead"} - </ContextMenuItem> - )} +function MuteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + isMuted, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + isMuted: boolean; +}) { + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return <HugeiconsIcon icon={VolumeMute02Icon} />; + } + return isMuted ? ( + <HugeiconsIcon icon={VolumeHighIcon} /> + ) : ( + <HugeiconsIcon icon={VolumeOffIcon} /> + ); + }; - <ContextMenuItem onClick={handleElementCopyContext}> - <Copy className="h-4 w-4 mr-2" /> - {isMultipleSelected && isCurrentElementSelected - ? `Copy ${selectedElements.length} elements` - : "Copy element"} - </ContextMenuItem> + return ( + <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}> + {isMuted ? "Unmute" : "Mute"} + </ActionMenuItem> + ); +} - <ContextMenuItem onClick={handleToggleElementContext}> - {isMultipleSelected && isCurrentElementSelected ? ( - hasAudioElements ? ( - <VolumeX className="h-4 w-4 mr-2" /> - ) : ( - <EyeOff className="h-4 w-4 mr-2" /> - ) - ) : hasAudio ? ( - isMuted ? ( - <Volume2 className="h-4 w-4 mr-2" /> - ) : ( - <VolumeX className="h-4 w-4 mr-2" /> - ) - ) : element.hidden ? ( - <Eye className="h-4 w-4 mr-2" /> - ) : ( - <EyeOff className="h-4 w-4 mr-2" /> - )} - <span> - {isMultipleSelected && isCurrentElementSelected - ? hasAudioElements - ? `Toggle mute ${selectedElements.length} elements` - : `Toggle visibility ${selectedElements.length} elements` - : hasAudio - ? isMuted - ? "Unmute" - : "Mute" - : element.hidden - ? "Show" - : "Hide"}{" "} - {!isMultipleSelected && (element.type === "text" ? "text" : "clip")} - </span> - </ContextMenuItem> +function VisibilityMenuItem({ + element, + isMultipleSelected, + isCurrentElementSelected, +}: { + element: TimelineElementType; + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; +}) { + const isHidden = canElementBeHidden(element) && element.hidden; - {!isMultipleSelected && ( - <ContextMenuItem onClick={handleElementDuplicateContext}> - <Copy className="h-4 w-4 mr-2" /> - Duplicate {element.type === "text" ? "text" : "clip"} - </ContextMenuItem> - )} + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return <HugeiconsIcon icon={ViewOffSlashIcon} />; + } + return isHidden ? ( + <HugeiconsIcon icon={ViewIcon} /> + ) : ( + <HugeiconsIcon icon={ViewOffSlashIcon} /> + ); + }; - {!isMultipleSelected && element.type === "media" && ( - <> - <ContextMenuItem onClick={handleRevealInMedia}> - <Search className="h-4 w-4 mr-2" /> - Reveal in media - </ContextMenuItem> - <ContextMenuItem onClick={handleReplaceClip}> - <RefreshCw className="h-4 w-4 mr-2" /> - Replace clip - </ContextMenuItem> - </> - )} + return ( + <ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}> + {isHidden ? "Show" : "Hide"} + </ActionMenuItem> + ); +} - <ContextMenuSeparator /> +function DeleteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + elementType, + selectedCount, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + elementType: TimelineElementType["type"]; + selectedCount: number; +}) { + return ( + <ActionMenuItem + action="delete-selected" + variant="destructive" + icon={<HugeiconsIcon icon={Delete02Icon} />} + > + {isMultipleSelected && isCurrentElementSelected + ? `Delete ${selectedCount} elements` + : `Delete ${elementType === "text" ? "text" : "clip"}`} + </ActionMenuItem> + ); +} - <ContextMenuItem - onClick={handleElementDeleteContext} - className="text-destructive focus:text-destructive" - > - <Trash2 className="h-4 w-4 mr-2" /> - {isMultipleSelected && isCurrentElementSelected - ? `Delete ${selectedElements.length} elements` - : `Delete ${element.type === "text" ? "text" : "clip"}`} - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ); +function ActionMenuItem({ + action, + children, + ...props +}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { + action: TAction; +}) { + return ( + <ContextMenuItem + onClick={(event) => { + event.stopPropagation(); + invokeAction(action); + }} + textRight={getDisplayShortcut(action)} + {...props} + > + {children} + </ContextMenuItem> + ); } diff --git a/apps/web/src/components/editor/timeline/timeline-marker.tsx b/apps/web/src/components/editor/timeline/timeline-marker.tsx deleted file mode 100644 index 1351a457..00000000 --- a/apps/web/src/components/editor/timeline/timeline-marker.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client";
-
-import { cn } from "@/lib/utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-
-interface TimelineMarkerProps {
- time: number;
- zoomLevel: number;
- interval: number;
- isMainMarker: boolean;
-}
-
-export function TimelineMarker({
- time,
- zoomLevel,
- interval,
- isMainMarker,
-}: TimelineMarkerProps) {
- return (
- <div
- className={cn(
- "absolute top-0 h-4",
- isMainMarker
- ? "border-l border-muted-foreground/40"
- : "border-l border-muted-foreground/20"
- )}
- style={{
- left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
- }}
- >
- <span
- className={cn(
- "absolute top-1 left-1 text-[0.6rem]",
- isMainMarker
- ? "text-muted-foreground font-medium"
- : "text-muted-foreground/70"
- )}
- >
- {(() => {
- 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")}`;
- }
- if (minutes > 0) {
- return `${minutes}:${Math.floor(secs)
- .toString()
- .padStart(2, "0")}`;
- }
- if (interval >= 1) {
- return `${Math.floor(secs)}s`;
- }
- return `${secs.toFixed(1)}s`;
- };
- return formatTime(time);
- })()}
- </span>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index a1be5b26..58d0adb0 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -1,172 +1,92 @@ "use client"; -import { useRef, useState, useEffect } from "react"; -import { TimelineTrack } from "@/types/timeline"; +import { useRef } from "react"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; +import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; +import { useEditor } from "@/hooks/use-editor"; interface TimelinePlayheadProps { - currentTime: number; - duration: number; - zoomLevel: number; - tracks: TimelineTrack[]; - seek: (time: number) => void; - rulerRef: React.RefObject<HTMLDivElement>; - rulerScrollRef: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; - trackLabelsRef?: React.RefObject<HTMLDivElement>; - timelineRef: React.RefObject<HTMLDivElement>; - playheadRef?: React.RefObject<HTMLDivElement>; - isSnappingToPlayhead?: boolean; + zoomLevel: number; + rulerRef: React.RefObject<HTMLDivElement>; + rulerScrollRef: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + timelineRef: React.RefObject<HTMLDivElement>; + playheadRef?: React.RefObject<HTMLDivElement>; + isSnappingToPlayhead?: boolean; } export function TimelinePlayhead({ - currentTime, - duration, - zoomLevel, - tracks, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - trackLabelsRef, - timelineRef, - playheadRef: externalPlayheadRef, - isSnappingToPlayhead = false, + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + timelineRef, + playheadRef: externalPlayheadRef, + isSnappingToPlayhead = false, }: TimelinePlayheadProps) { - const internalPlayheadRef = useRef<HTMLDivElement>(null); - const playheadRef = externalPlayheadRef || internalPlayheadRef; - const [scrollLeft, setScrollLeft] = useState(0); + const editor = useEditor(); + const duration = editor.timeline.getTotalDuration(); + const internalPlayheadRef = useRef<HTMLDivElement>(null); + const playheadRef = externalPlayheadRef || internalPlayheadRef; - const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); + const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); - // Track scroll position to lock playhead to frame - useEffect(() => { - const tracksViewport = tracksScrollRef.current; + const timelineContainerHeight = + tracksScrollRef.current?.clientHeight ?? + timelineRef.current?.clientHeight ?? + 400; + const totalHeight = Math.max(0, timelineContainerHeight - 4); - if (!tracksViewport) return; + const timelinePosition = + playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const leftPosition = timelinePosition; - const handleScroll = () => { - setScrollLeft(tracksViewport.scrollLeft); - }; + const handlePlayheadKeyDown = ( + event: React.KeyboardEvent<HTMLDivElement>, + ) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; - // Set initial scroll position - setScrollLeft(tracksViewport.scrollLeft); + event.preventDefault(); + const step = 1 / Math.max(1, editor.project.getActive().settings.fps); + const direction = event.key === "ArrowRight" ? 1 : -1; + const nextTime = Math.max( + 0, + Math.min(duration, playheadPosition + direction * step), + ); - tracksViewport.addEventListener("scroll", handleScroll); - return () => tracksViewport.removeEventListener("scroll", handleScroll); - }, [tracksScrollRef]); + editor.playback.seek({ time: nextTime }); + }; - // Use timeline container height minus a few pixels for breathing room - const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; - const totalHeight = timelineContainerHeight - 4; + return ( + <div + ref={playheadRef} + role="slider" + aria-label="Timeline playhead" + aria-valuemin={0} + aria-valuemax={duration} + aria-valuenow={playheadPosition} + tabIndex={0} + className="pointer-events-auto absolute z-60" + style={{ + left: `${leftPosition}px`, + top: 0, + height: `${totalHeight}px`, + width: "2px", + }} + onMouseDown={handlePlayheadMouseDown} + onKeyDown={handlePlayheadKeyDown} + > + <div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" /> - // Get dynamic track labels width, fallback to 0 if no tracks or no ref - const trackLabelsWidth = - tracks.length > 0 && trackLabelsRef?.current - ? trackLabelsRef.current.offsetWidth - : 0; - - // Calculate position locked to timeline content (accounting for scroll) - const timelinePosition = - playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft; - - // Get the timeline content width and viewport width for right boundary - const timelineContentWidth = - duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const tracksViewport = tracksScrollRef.current; - const viewportWidth = tracksViewport?.clientWidth || 1000; - - // Constrain playhead to never appear outside the timeline area - const leftBoundary = trackLabelsWidth; - const rightBoundary = Math.min( - trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content - trackLabelsWidth + viewportWidth // Don't go beyond viewport - ); - - const leftPosition = Math.max( - leftBoundary, - Math.min(rightBoundary, rawLeftPosition) - ); - - // Debug logging when playhead might go outside - if (rawLeftPosition < leftBoundary || rawLeftPosition > rightBoundary) { - console.log( - "PLAYHEAD VISUAL DEBUG:", - JSON.stringify({ - playheadPosition, - timelinePosition, - trackLabelsWidth, - scrollLeft, - rawLeftPosition, - constrainedLeftPosition: leftPosition, - leftBoundary, - rightBoundary, - timelineContentWidth, - viewportWidth, - zoomLevel, - }) - ); - } - - return ( - <div - ref={playheadRef} - className="absolute pointer-events-auto z-40" - style={{ - left: `${leftPosition}px`, - top: 0, - height: `${totalHeight}px`, - width: "2px", // Slightly wider for better click target - }} - onMouseDown={handlePlayheadMouseDown} - > - {/* The playhead line spanning full height */} - <div - className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`} - /> - - {/* Playhead dot indicator at the top (in ruler area) */} - <div - className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} - /> - </div> - ); + <div + className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`} + /> + </div> + ); } - -// Also export a hook for getting ruler handlers -export function useTimelinePlayheadRuler({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, -}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) { - const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - return { handleRulerMouseDown, isDraggingRuler }; -} - -export { TimelinePlayhead as default }; diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/timeline/timeline-ruler.tsx new file mode 100644 index 00000000..87702a4f --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-ruler.tsx @@ -0,0 +1,83 @@ +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { DEFAULT_FPS } from "@/constants/project-constants"; +import { useEditor } from "@/hooks/use-editor"; +import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils"; +import { TimelineTick } from "./timeline-tick"; + +interface TimelineRulerProps { + zoomLevel: number; + dynamicTimelineWidth: number; + rulerRef: React.RefObject<HTMLDivElement>; + handleWheel: (e: React.WheelEvent) => void; + handleTimelineContentClick: (e: React.MouseEvent) => void; + handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; + handleRulerMouseDown: (e: React.MouseEvent) => void; +} + +export function TimelineRuler({ + zoomLevel, + dynamicTimelineWidth, + rulerRef, + handleWheel, + handleTimelineContentClick, + handleRulerTrackingMouseDown, + handleRulerMouseDown, +}: TimelineRulerProps) { + const editor = useEditor(); + const duration = editor.timeline.getTotalDuration(); + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const visibleDuration = dynamicTimelineWidth / pixelsPerSecond; + const effectiveDuration = Math.max(duration, visibleDuration); + const project = editor.project.getActive(); + const fps = project?.settings.fps ?? DEFAULT_FPS; + const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({ + zoomLevel, + fps, + }); + const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1; + + const timelineTicks: Array<JSX.Element> = []; + for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) { + const time = tickIndex * tickIntervalSeconds; + if (time > effectiveDuration) break; + + const showLabel = shouldShowLabel({ time, labelIntervalSeconds }); + timelineTicks.push( + <TimelineTick + key={tickIndex} + time={time} + zoomLevel={zoomLevel} + fps={fps} + showLabel={showLabel} + />, + ); + } + + return ( + <div + role="slider" + tabIndex={0} + aria-label="Timeline ruler" + aria-valuemin={0} + aria-valuemax={effectiveDuration} + aria-valuenow={0} + className="relative h-4 flex-1 overflow-x-visible" + onWheel={handleWheel} + onClick={handleTimelineContentClick} + onMouseDown={handleRulerTrackingMouseDown} + onKeyDown={() => {}} + > + <div + role="none" + ref={rulerRef} + className="relative h-4 cursor-default select-none" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + onMouseDown={handleRulerMouseDown} + > + {timelineTicks} + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-tick.tsx b/apps/web/src/components/editor/timeline/timeline-tick.tsx new file mode 100644 index 00000000..9402d25e --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-tick.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { formatRulerLabel } from "@/lib/timeline/ruler-utils"; + +interface TimelineTickProps { + time: number; + zoomLevel: number; + fps: number; + showLabel: boolean; +} + +export function TimelineTick({ + time, + zoomLevel, + fps, + showLabel, +}: TimelineTickProps) { + const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + if (showLabel) { + const label = formatRulerLabel({ timeInSeconds: time, fps }); + return ( + <span + className="text-muted-foreground/85 absolute bottom-0 select-none text-[10px] leading-none" + style={{ left: `${leftPosition}px` }} + > + {label} + </span> + ); + } + + return ( + <div + className="border-muted-foreground/25 absolute bottom-0.5 h-1.5 border-l" + style={{ left: `${leftPosition}px` }} + /> + ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index 80c6ce08..822bb438 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -1,397 +1,355 @@ -import { usePlaybackStore } from "@/stores/playback-store"; -import { useProjectStore } from "@/stores/project-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useSceneStore } from "@/stores/scene-store"; -import { toast } from "sonner"; +import { useEditor } from "@/hooks/use-editor"; import { - TooltipProvider, - Tooltip, - TooltipTrigger, - TooltipContent, + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; +import { SkipBack, SplitSquareHorizontal } from "lucide-react"; import { - Pause, - Play, - SkipBack, - Bookmark, - Magnet, - Link, - ZoomOut, - ZoomIn, - Copy, - Trash2, - Snowflake, - ArrowLeftToLine, - ArrowRightToLine, - SplitSquareHorizontal, - Scissors, - LayersIcon, -} from "lucide-react"; -import { - SplitButton, - SplitButtonLeft, - SplitButtonRight, - SplitButtonSeparator, + SplitButton, + SplitButtonLeft, + SplitButtonRight, + SplitButtonSeparator, } from "@/components/ui/split-button"; import { Slider } from "@/components/ui/slider"; -import { DEFAULT_FPS } from "@/stores/project-store"; import { formatTimeCode } from "@/lib/time"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { EditableTimecode } from "@/components/ui/editable-timecode"; +import { EditableTimecode } from "@/components/editable-timecode"; import { ScenesView } from "../scenes-view"; +import { type TAction, invokeAction } from "@/lib/actions"; +import { cn } from "@/utils/ui"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Bookmark02Icon, + Delete02Icon, + SnowIcon, + ScissorIcon, + MagnetIcon, + Link04Icon, + SearchAddIcon, + SearchMinusIcon, + PauseIcon, + PlayIcon, + Copy01Icon, + AlignLeftIcon, + AlignRightIcon, + Layers01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; export function TimelineToolbar({ - zoomLevel, - setZoomLevel, + zoomLevel, + minZoom, + setZoomLevel, }: { - zoomLevel: number; - setZoomLevel: (zoom: number) => void; + zoomLevel: number; + minZoom: number; + setZoomLevel: ({ zoom }: { zoom: number }) => void; }) { - const { - tracks, - addTrack, - addElementToTrack, - selectedElements, - clearSelectedElements, - deleteSelected, - splitSelected, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - } = useTimelineStore(); - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); - const { scenes, currentScene } = useSceneStore(); + const handleZoom = ({ direction }: { direction: "in" | "out" }) => { + const newZoomLevel = + direction === "in" + ? Math.min( + TIMELINE_CONSTANTS.ZOOM_MAX, + zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP, + ) + : Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP); + setZoomLevel({ zoom: newZoomLevel }); + }; + + return ( + <ScrollArea className="scrollbar-hidden"> + <div className="flex h-10 items-center justify-between border-b px-2 py-1"> + <ToolbarLeftSection /> + + <SceneSelector /> + + <ToolbarRightSection + zoomLevel={zoomLevel} + minZoom={minZoom} + onZoomChange={(zoom) => setZoomLevel({ zoom })} + onZoom={handleZoom} + /> + </div> + </ScrollArea> + ); +} + +function ToolbarLeftSection() { + const editor = useEditor(); + const currentTime = editor.playback.getCurrentTime(); + const isPlaying = editor.playback.getIsPlaying(); + const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime }); + + const handleAction = ({ + action, + event, + }: { + action: TAction; + event: React.MouseEvent; + }) => { + event.stopPropagation(); + invokeAction(action); + }; + + return ( + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <ToolbarButton + icon={ + isPlaying ? ( + <HugeiconsIcon icon={PauseIcon} /> + ) : ( + <HugeiconsIcon icon={PlayIcon} /> + ) + } + tooltip={isPlaying ? "Pause" : "Play"} + onClick={({ event }) => + handleAction({ action: "toggle-play", event }) + } + /> + + <ToolbarButton + icon={<SkipBack />} + tooltip="Go to start" + onClick={({ event }) => handleAction({ action: "goto-start", event })} + /> + + <div className="bg-border mx-1 h-6 w-px" /> + + <TimeDisplay /> - const handleSplitSelected = () => { - splitSelected(currentTime); - }; + <div className="bg-border mx-1 h-6 w-px" /> - const handleDuplicateSelected = () => { - if (selectedElements.length === 0) return; - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; + <ToolbarButton + icon={<HugeiconsIcon icon={ScissorIcon} />} + tooltip="Split element" + onClick={({ event }) => + handleAction({ action: "split", event }) + } + /> - 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; - const { id, ...elementWithoutId } = element; - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - } - }); - clearSelectedElements(); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={AlignLeftIcon} />} + tooltip="Split left" + onClick={({ event }) => + handleAction({ action: "split-left", event }) + } + /> - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={AlignRightIcon} />} + tooltip="Split right" + onClick={({ event }) => + handleAction({ action: "split-right", event }) + } + /> - 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); - }; + <ToolbarButton + icon={<SplitSquareHorizontal />} + tooltip="Coming soon" /* separate audio */ + disabled={true} + onClick={({ event: _event }) => {}} + /> - 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); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={Copy01Icon} />} + tooltip="Duplicate element" + onClick={({ event }) => + handleAction({ action: "duplicate-selected", event }) + } + /> - 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); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={SnowIcon} />} + tooltip="Coming soon" /* freeze frame */ + disabled={true} + onClick={({ event: _event }) => {}} + /> - const handleDeleteSelected = () => { - deleteSelected(); - }; + <ToolbarButton + icon={<HugeiconsIcon icon={Delete02Icon} />} + tooltip="Delete element" + onClick={({ event }) => + handleAction({ action: "delete-selected", event }) + } + /> - const handleZoomIn = () => { - setZoomLevel(Math.min(4, zoomLevel + 0.25)); - }; + <div className="bg-border mx-1 h-6 w-px" /> - const handleZoomOut = () => { - setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); - }; + <Tooltip> + <ToolbarButton + icon={ + <HugeiconsIcon + icon={Bookmark02Icon} + className={currentBookmarked ? "fill-primary text-primary" : ""} + /> + } + tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"} + onClick={({ event }) => + handleAction({ action: "toggle-bookmark", event }) + } + /> + </Tooltip> + </TooltipProvider> + </div> + ); +} + +function TimeDisplay() { + const editor = useEditor(); + const currentTime = editor.playback.getCurrentTime(); + const totalDuration = editor.timeline.getTotalDuration(); + const fps = editor.project.getActive().settings.fps; + + return ( + <div className="flex flex-row items-center justify-center px-2"> + <EditableTimecode + time={currentTime} + duration={totalDuration} + format="HH:MM:SS:FF" + fps={fps} + onTimeChange={({ time }) => editor.playback.seek({ time })} + className="text-center" + /> + <div className="text-muted-foreground px-2 font-mono text-xs">/</div> + <div className="text-muted-foreground text-center font-mono text-xs"> + {formatTimeCode({ + timeInSeconds: totalDuration, + format: "HH:MM:SS:FF", + fps, + })} + </div> + </div> + ); +} + +function SceneSelector() { + const editor = useEditor(); + const currentScene = editor.scenes.getActiveScene(); + + return ( + <div> + <SplitButton className="border-foreground/10 border"> + <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> + <SplitButtonSeparator /> + <ScenesView> + <SplitButtonRight onClick={() => {}} type="button"> + <HugeiconsIcon icon={Layers01Icon} className="size-4" /> + </SplitButtonRight> + </ScenesView> + </SplitButton> + </div> + ); +} - const handleZoomSliderChange = (values: number[]) => { - setZoomLevel(values[0]); - }; +function ToolbarRightSection({ + zoomLevel, + minZoom, + onZoomChange, + onZoom, +}: { + zoomLevel: number; + minZoom: number; + onZoomChange: (zoom: number) => void; + onZoom: (options: { direction: "in" | "out" }) => void; +}) { + const { + snappingEnabled, + rippleEditingEnabled, + toggleSnapping, + toggleRippleEditing, + } = useTimelineStore(); - const handleToggleBookmark = async () => { - await toggleBookmark(currentTime); - }; + return ( + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <ToolbarButton + icon={ + <HugeiconsIcon + icon={MagnetIcon} + className={cn(snappingEnabled ? "text-primary" : "")} + /> + } + tooltip="Auto snapping" + onClick={() => toggleSnapping()} + /> - const currentBookmarked = isBookmarked(currentTime); - return ( - <div className="flex items-center justify-between px-2 py-1 border-b h-10"> - <div className="flex items-center gap-1"> - <TooltipProvider delayDuration={500}> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={toggle}> - {isPlaying ? ( - <Pause className="h-4 w-4" /> - ) : ( - <Play className="h-4 w-4" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent> - {isPlaying ? "Pause (Space)" : "Play (Space)"} - </TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={() => seek(0)}> - <SkipBack className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Return to Start (Home / Enter)</TooltipContent> - </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - {/* Time Display */} - <div className="flex flex-row items-center justify-center px-2"> - <EditableTimecode - time={currentTime} - duration={duration} - format="HH:MM:SS:FF" - fps={activeProject?.fps ?? DEFAULT_FPS} - onTimeChange={seek} - className="text-center" - /> - <div className="text-xs text-muted-foreground font-mono px-2"> - / - </div> - <div className="text-xs text-muted-foreground font-mono text-center"> - {formatTimeCode(duration, "HH:MM:SS:FF")} - </div> - </div> - {tracks.length === 0 && ( - <> - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size="sm" - onClick={() => { - const trackId = addTrack("media"); - addElementToTrack(trackId, { - type: "media", - mediaId: "test", - name: "Test Clip", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); - }} - className="text-xs" - > - Add Test Clip - </Button> - </TooltipTrigger> - <TooltipContent>Add a test clip to try playback</TooltipContent> - </Tooltip> - </> - )} - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleSplitSelected}> - <Scissors className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split element (Ctrl+S)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepLeft} - > - <ArrowLeftToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepRight} - > - <ArrowRightToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleSeparateAudio}> - <SplitSquareHorizontal className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleDuplicateSelected} - > - <Copy className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleFreezeSelected}> - <Snowflake className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Freeze frame (F)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleDeleteSelected}> - <Trash2 className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Delete element (Delete)</TooltipContent> - </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={handleToggleBookmark}> - <Bookmark - className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`} - /> - </Button> - </TooltipTrigger> - <TooltipContent> - {currentBookmarked ? "Remove bookmark" : "Add bookmark"} - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - <div> - <SplitButton className="border border-foreground/10"> - <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> - <SplitButtonSeparator /> - <ScenesView> - <SplitButtonRight disabled={scenes.length === 1} onClick={() => {}}> - <LayersIcon /> - </SplitButtonRight> - </ScenesView> - </SplitButton> - </div> - <div className="flex items-center gap-1"> - <TooltipProvider delayDuration={500}> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={toggleSnapping}> - {snappingEnabled ? ( - <Magnet className="h-4 w-4 text-primary" /> - ) : ( - <Magnet className="h-4 w-4" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent>Auto snapping</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={toggleRippleEditing}> - <Link - className={`h-4 w-4 ${ - rippleEditingEnabled ? "text-primary" : "" - }`} - /> - </Button> - </TooltipTrigger> - <TooltipContent> - {rippleEditingEnabled - ? "Disable Ripple Editing" - : "Enable Ripple Editing"} - </TooltipContent> - </Tooltip> - </TooltipProvider> + <ToolbarButton + icon={ + <HugeiconsIcon + icon={Link04Icon} + className={cn( + rippleEditingEnabled ? "text-primary" : "", + "scale-110", + )} + /> + } + tooltip="Ripple editing" + onClick={() => toggleRippleEditing()} + /> + </TooltipProvider> - <div className="h-6 w-px bg-border mx-1" /> - <div className="flex items-center gap-1"> - <Button variant="text" size="icon" onClick={handleZoomOut}> - <ZoomOut className="h-4 w-4" /> - </Button> - <Slider - className="w-24" - value={[zoomLevel]} - onValueChange={handleZoomSliderChange} - min={0.25} - max={4} - step={0.25} - /> - <Button variant="text" size="icon" onClick={handleZoomIn}> - <ZoomIn className="h-4 w-4" /> - </Button> - </div> - </div> - </div> - ); + <div className="bg-border mx-1 h-6 w-px" /> + + <div className="flex items-center gap-1"> + <Button + variant="text" + size="icon" + type="button" + onClick={() => onZoom({ direction: "out" })} + > + <HugeiconsIcon icon={SearchAddIcon} /> + </Button> + <Slider + className="w-24" + value={[zoomLevel]} + onValueChange={(values) => onZoomChange(values[0])} + min={minZoom} + max={TIMELINE_CONSTANTS.ZOOM_MAX} + step={TIMELINE_CONSTANTS.ZOOM_STEP} + /> + <Button + variant="text" + size="icon" + type="button" + onClick={() => onZoom({ direction: "in" })} + > + <HugeiconsIcon icon={SearchMinusIcon} /> + </Button> + </div> + </div> + ); +} + +function ToolbarButton({ + icon, + tooltip, + onClick, + disabled, +}: { + icon: React.ReactNode; + tooltip: string; + onClick: ({ event }: { event: React.MouseEvent }) => void; + disabled?: boolean; +}) { + return ( + <Tooltip delayDuration={200}> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + type="button" + onClick={(event) => onClick({ event })} + className={disabled ? "cursor-not-allowed opacity-50" : ""} + > + {icon} + </Button> + </TooltipTrigger> + <TooltipContent>{tooltip}</TooltipContent> + </Tooltip> + ); } diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 7cfde931..29d58315 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -1,1199 +1,113 @@ "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 { processMediaFiles } from "@/lib/media-processing"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { TimelineElement } from "./timeline-element"; -import { - TimelineTrack, - getMainTrack, - canElementGoOnTrack, -} from "@/types/timeline"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; -import type { - TimelineElement as TimelineElementType, - DragData, - TrackType, -} from "@/types/timeline"; -import { - snapTimeToFrame, - TIMELINE_CONSTANTS, -} from "@/constants/timeline-constants"; -import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; -import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping"; -import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll"; +import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineElement as TimelineElementType } from "@/types/timeline"; +import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; +import type { ElementDragState } from "@/types/timeline"; +import { useEditor } from "@/hooks/use-editor"; +import { cn } from "@/utils/ui"; -export function TimelineTrackContent({ - track, - zoomLevel, - onSnapPointChange, - rulerScrollRef, - tracksScrollRef, -}: { - track: TimelineTrack; - zoomLevel: number; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; - rulerScrollRef: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; -}) { - const { mediaFiles } = useMediaStore(); - const { - tracks, - addTrack, - moveElementToTrack, - updateElementStartTime, - updateElementStartTimeWithRipple, - addElementToTrack, - selectedElements, - selectElement, - dragState, - startDrag: startDragAction, - updateDragTime, - endDrag: endDragAction, - clearSelectedElements, - insertTrackAt, - snappingEnabled, - rippleEditingEnabled, - } = useTimelineStore(); - - const { currentTime, duration } = usePlaybackStore(); - - // Initialize snapping hook - const { snapElementPosition, snapElementEdge } = useTimelineSnapping({ - snapThreshold: 10, - enableElementSnapping: snappingEnabled, - enablePlayheadSnapping: snappingEnabled, - }); - - // Helper function for drop snapping that tries both edges - const getDropSnappedTime = ( - dropTime: number, - elementDuration: number, - excludeElementId?: string - ) => { - // Always apply frame snapping first - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS; - let finalTime = snapTimeToFrame(dropTime, projectFps); - - // Additionally apply element snapping if enabled - if (snappingEnabled) { - // Try snapping both start and end edges for drops - const startSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - true // snap to start edge - ); - - const endSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - false // snap to end edge - ); - - // Choose the snap result with the smaller distance (closer snap) - let bestSnapResult = startSnapResult; - if ( - endSnapResult.snapPoint && - (!startSnapResult.snapPoint || - endSnapResult.snapDistance < startSnapResult.snapDistance) - ) { - bestSnapResult = endSnapResult; - } - - // Only use element snapping if it found a snap point, otherwise keep frame-snapped time - if (bestSnapResult.snapPoint) { - finalTime = bestSnapResult.snappedTime; - } - } - - return finalTime; - }; - - 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); - - const lastMouseXRef = useRef(0); - - // Set up mouse event listeners for drag - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseMove = (e: MouseEvent) => { - if (!timelineRef.current) return; - lastMouseXRef.current = e.clientX; - - // On first mouse move during drag, ensure the element is selected - if (dragState.elementId && dragState.trackId) { - const isSelected = selectedElements.some( - (c) => - c.trackId === dragState.trackId && - c.elementId === dragState.elementId - ); - - if (!isSelected) { - // Select this element (replacing other selections) since we're dragging it - selectElement(dragState.trackId, dragState.elementId, false); - } - } - - const timelineRect = timelineRef.current.getBoundingClientRect(); - const mouseX = e.clientX - timelineRect.left; - const mouseTime = Math.max( - 0, - mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) - ); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - - // Always apply frame snapping first - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS; - let finalTime = snapTimeToFrame(adjustedTime, projectFps); - let snapPoint = null; - - // Additionally apply element snapping if enabled - if (snappingEnabled) { - // Find the element being dragged to get its duration - let elementDuration = 5; // fallback duration - if (dragState.elementId && dragState.trackId) { - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const element = sourceTrack?.elements.find( - (e) => e.id === dragState.elementId - ); - if (element) { - elementDuration = - element.duration - element.trimStart - element.trimEnd; - } - } - - // Try snapping both start and end edges - const startSnapResult = snapElementEdge( - adjustedTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - dragState.elementId || undefined, - true // snap to start edge - ); - - const endSnapResult = snapElementEdge( - adjustedTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - dragState.elementId || undefined, - false // snap to end edge - ); - - // Choose the snap result with the smaller distance (closer snap) - let bestSnapResult = startSnapResult; - if ( - endSnapResult.snapPoint && - (!startSnapResult.snapPoint || - endSnapResult.snapDistance < startSnapResult.snapDistance) - ) { - bestSnapResult = endSnapResult; - } - - // Only use element snapping if it found a snap point, otherwise keep frame-snapped time - if (bestSnapResult.snapPoint) { - finalTime = bestSnapResult.snappedTime; - snapPoint = bestSnapResult.snapPoint; - } - - // Notify parent component about snap point change - onSnapPointChange?.(snapPoint); - } else { - // Clear snap point when element snapping is disabled - onSnapPointChange?.(null); - } - - updateDragTime(finalTime); - }; - - const handleMouseUp = (e: MouseEvent) => { - if (!dragState.elementId || !dragState.trackId) return; - - // If this track initiated the drag, we should handle the mouse up regardless of where it occurs - const isTrackThatStartedDrag = dragState.trackId === track.id; - - const timelineRect = timelineRef.current?.getBoundingClientRect(); - if (!timelineRect) { - if (isTrackThatStartedDrag) { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - dragState.elementId, - dragState.currentTime - ); - } else { - updateElementStartTime( - track.id, - dragState.elementId, - dragState.currentTime - ); - } - endDragAction(); - // Clear snap point when drag ends - onSnapPointChange?.(null); - } - return; - } - - const isMouseOverThisTrack = - e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom; - - if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return; - - const finalTime = dragState.currentTime; - - if (isMouseOverThisTrack) { - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const movingElement = sourceTrack?.elements.find( - (c) => c.id === dragState.elementId - ); - - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const movingElementEnd = finalTime + movingElementDuration; - - const targetTrack = tracks.find((t) => t.id === track.id); - const hasOverlap = targetTrack?.elements.some((existingElement) => { - if ( - dragState.trackId === track.id && - existingElement.id === dragState.elementId - ) { - return false; - } - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return finalTime < existingEnd && movingElementEnd > existingStart; - }); - - if (!hasOverlap) { - if (dragState.trackId === track.id) { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - dragState.elementId, - finalTime - ); - } else { - updateElementStartTime( - track.id, - dragState.elementId, - finalTime - ); - } - } else { - moveElementToTrack( - dragState.trackId, - track.id, - dragState.elementId - ); - requestAnimationFrame(() => { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - dragState.elementId!, - finalTime - ); - } else { - updateElementStartTime( - track.id, - dragState.elementId!, - finalTime - ); - } - }); - } - } - } - } else if (isTrackThatStartedDrag) { - // Mouse is not over this track, but this track started the drag - // This means user released over ruler/outside - update position within same track - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const movingElement = sourceTrack?.elements.find( - (c) => c.id === dragState.elementId - ); - - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const movingElementEnd = finalTime + movingElementDuration; - - const hasOverlap = track.elements.some((existingElement) => { - if (existingElement.id === dragState.elementId) { - return false; - } - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return finalTime < existingEnd && movingElementEnd > existingStart; - }); - - if (!hasOverlap) { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - dragState.elementId, - finalTime - ); - } else { - updateElementStartTime(track.id, dragState.elementId, finalTime); - } - } - } - } - - if (isTrackThatStartedDrag) { - endDragAction(); - // Clear snap point when drag ends - onSnapPointChange?.(null); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - dragState.isDragging, - dragState.clickOffsetTime, - dragState.elementId, - dragState.trackId, - dragState.currentTime, - zoomLevel, - tracks, - track.id, - updateDragTime, - updateElementStartTime, - moveElementToTrack, - endDragAction, - selectedElements, - selectElement, - onSnapPointChange, - ]); - - useEdgeAutoScroll({ - isActive: dragState.isDragging, - getMouseClientX: () => lastMouseXRef.current, - rulerScrollRef, - tracksScrollRef, - contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, - }); - - const handleElementMouseDown = ( - e: React.MouseEvent, - element: TimelineElementType - ) => { - setMouseDownLocation({ x: e.clientX, y: e.clientY }); - - // Detect right-click (button 2) and handle selection without starting drag - const isRightClick = e.button === 2; - const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey; - - if (isRightClick) { - // Handle right-click selection - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id - ); - - // If element is not selected, select it (keep other selections if multi-select) - if (!isSelected) { - selectElement(track.id, element.id, isMultiSelect); - } - // If element is already selected, keep it selected - - // Don't start drag action for right-clicks - return; - } - - // Handle multi-selection for left-click with modifiers - if (isMultiSelect) { - selectElement(track.id, element.id, true); - } - - // Calculate the offset from the left edge of the element to where the user clicked - const elementElement = e.currentTarget as HTMLElement; - const elementRect = elementElement.getBoundingClientRect(); - const clickOffsetX = e.clientX - elementRect.left; - const clickOffsetTime = - clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - - startDragAction( - element.id, - track.id, - e.clientX, - element.startTime, - clickOffsetTime - ); - }; - - const handleElementClick = ( - e: React.MouseEvent, - element: TimelineElementType - ) => { - 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 - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id - ); - - if (!isSelected) { - // If element is not selected, select it (replacing other selections) - selectElement(track.id, element.id, false); - } - // If element is already selected, keep it selected (do nothing) - }; - - const handleTrackDragOver = (e: React.DragEvent) => { - e.preventDefault(); - - // Handle both timeline elements and media items - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - // Calculate drop position for overlap checking - const trackContainer = e.currentTarget.querySelector( - ".track-elements-container" - ) as HTMLElement; - let dropTime = 0; - if (trackContainer) { - const rect = trackContainer.getBoundingClientRect(); - const mouseX = Math.max(0, e.clientX - rect.left); - dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * 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 dragData: DragData = JSON.parse(mediaItemData); - - if (dragData.type === "text") { - // Text elements have default duration of 5 seconds - const newElementDuration = 5; - const snappedTime = getDropSnappedTime( - dropTime, - newElementDuration - ); - const newElementEnd = snappedTime + newElementDuration; - - wouldOverlap = track.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return snappedTime < existingEnd && newElementEnd > existingStart; - }); - } else { - // Media elements - const mediaItem = mediaFiles.find( - (item) => item.id === dragData.id - ); - if (mediaItem) { - const newElementDuration = mediaItem.duration || 5; - const snappedTime = getDropSnappedTime( - dropTime, - newElementDuration - ); - const newElementEnd = snappedTime + newElementDuration; - - wouldOverlap = track.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return ( - snappedTime < existingEnd && newElementEnd > existingStart - ); - }); - } - } - } - } catch (error) { - // Continue with default behavior - } - } else if (hasTimelineElement) { - try { - const timelineElementData = e.dataTransfer.getData( - "application/x-timeline-element" - ); - if (timelineElementData) { - const { elementId, trackId: fromTrackId } = - JSON.parse(timelineElementData); - const sourceTrack = tracks.find( - (t: TimelineTrack) => t.id === fromTrackId - ); - const movingElement = sourceTrack?.elements.find( - (c: any) => c.id === elementId - ); - - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const snappedTime = getDropSnappedTime( - dropTime, - movingElementDuration, - elementId - ); - const movingElementEnd = snappedTime + movingElementDuration; - - wouldOverlap = track.elements.some((existingElement) => { - if (fromTrackId === track.id && existingElement.id === elementId) - return false; - - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return ( - snappedTime < existingEnd && movingElementEnd > existingStart - ); - }); - } - } - } catch (error) { - // Continue with default behavior - } - } - - if (wouldOverlap) { - e.dataTransfer.dropEffect = "none"; - setWouldOverlap(true); - // Use default duration for position indicator - setDropPosition(getDropSnappedTime(dropTime, 5)); - return; - } - - e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; - setWouldOverlap(false); - // Use default duration for position indicator - setDropPosition(getDropSnappedTime(dropTime, 5)); - }; - - const handleTrackDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - dragCounterRef.current++; - setIsDropping(true); - }; - - const handleTrackDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - dragCounterRef.current--; - - if (dragCounterRef.current === 0) { - setIsDropping(false); - setWouldOverlap(false); - setDropPosition(null); - } - }; - - const handleTrackDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // Debug logging - console.log( - JSON.stringify({ - message: "Drop event started in timeline track", - dataTransferTypes: Array.from(e.dataTransfer.types), - trackId: track.id, - trackType: track.type, - }) - ); - - // Reset all drag states - dragCounterRef.current = 0; - setIsDropping(false); - setWouldOverlap(false); - - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item" - ); - const hasFiles = e.dataTransfer.files?.length > 0; - - if (!hasTimelineElement && !hasMediaItem && !hasFiles) return; - - const trackContainer = e.currentTarget.querySelector( - ".track-elements-container" - ) as HTMLElement; - if (!trackContainer) return; - - const rect = trackContainer.getBoundingClientRect(); - const mouseX = Math.max(0, e.clientX - rect.left); - const mouseY = e.clientY - rect.top; // Get Y position relative to this track - const newStartTime = - mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS; - const snappedTime = snapTimeToFrame(newStartTime, projectFps); - - // Calculate drop position relative to tracks - const currentTrackIndex = tracks.findIndex((t) => t.id === track.id); - - // Determine drop zone within the track (top 20px, middle 20px, bottom 20px) - let dropPosition: "above" | "on" | "below"; - if (mouseY < 20) { - dropPosition = "above"; - } else if (mouseY > 40) { - dropPosition = "below"; - } else { - dropPosition = "on"; - } - - try { - if (hasTimelineElement) { - // Handle timeline element movement - const timelineElementData = e.dataTransfer.getData( - "application/x-timeline-element" - ); - if (!timelineElementData) return; - - const { - elementId, - trackId: fromTrackId, - clickOffsetTime = 0, - } = JSON.parse(timelineElementData); - - // Find the element being moved - const sourceTrack = tracks.find( - (t: TimelineTrack) => t.id === fromTrackId - ); - const movingElement = sourceTrack?.elements.find( - (c: TimelineElementType) => c.id === elementId - ); - - if (!movingElement) { - toast.error("Element not found"); - return; - } - - // Check for overlaps with existing elements (excluding the moving element itself) - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - - // Adjust position based on where user clicked on the element - const adjustedStartTime = newStartTime - clickOffsetTime; - const snappedStartTime = getDropSnappedTime( - adjustedStartTime, - movingElementDuration, - elementId - ); - const finalStartTime = Math.max(0, snappedStartTime); - const movingElementEnd = finalStartTime + movingElementDuration; - - const hasOverlap = track.elements.some((existingElement) => { - // Skip the element being moved if it's on the same track - if (fromTrackId === track.id && existingElement.id === elementId) - return false; - - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - // Check if elements overlap - return ( - finalStartTime < existingEnd && movingElementEnd > existingStart - ); - }); - - if (hasOverlap) { - toast.error( - "Cannot move element here - it would overlap with existing elements" - ); - return; - } - - if (fromTrackId === track.id) { - // Moving within same track - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - elementId, - finalStartTime - ); - } else { - updateElementStartTime(track.id, elementId, finalStartTime); - } - } else { - // Moving to different track - moveElementToTrack(fromTrackId, track.id, elementId); - requestAnimationFrame(() => { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - elementId, - finalStartTime - ); - } else { - updateElementStartTime(track.id, elementId, finalStartTime); - } - }); - } - } else if (hasMediaItem) { - // Handle media item drop - const mediaItemData = e.dataTransfer.getData( - "application/x-media-item" - ); - if (!mediaItemData) return; - - const dragData: DragData = JSON.parse(mediaItemData); - - if (dragData.type === "text") { - let targetTrackId = track.id; - let targetTrack = track; - - // Handle position-aware track creation for text - if (track.type !== "text" || dropPosition !== "on") { - // Text tracks should go above the main track - const mainTrack = getMainTrack(tracks); - let insertIndex: number; - - if (dropPosition === "above") { - insertIndex = currentTrackIndex; - } else if (dropPosition === "below") { - insertIndex = currentTrackIndex + 1; - } else { - // dropPosition === "on" but track is not text type - // Insert above main track if main track exists, otherwise at top - if (mainTrack) { - const mainTrackIndex = tracks.findIndex( - (t) => t.id === mainTrack.id - ); - insertIndex = mainTrackIndex; - } else { - insertIndex = 0; // Top of timeline - } - } - - targetTrackId = insertTrackAt("text", insertIndex); - // Get the updated tracks array after creating the new track - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } - - // Check for overlaps with existing elements in target track - const newElementDuration = 5; // Default text duration - const textSnappedTime = getDropSnappedTime( - newStartTime, - newElementDuration - ); - const newElementEnd = textSnappedTime + newElementDuration; - - const hasOverlap = targetTrack.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - // Check if elements overlap - return ( - textSnappedTime < existingEnd && newElementEnd > existingStart - ); - }); - - if (hasOverlap) { - toast.error( - "Cannot place element here - it would overlap with existing elements" - ); - return; - } - - addElementToTrack(targetTrackId, { - ...DEFAULT_TEXT_ELEMENT, - name: dragData.name || DEFAULT_TEXT_ELEMENT.name, - content: dragData.content || DEFAULT_TEXT_ELEMENT.content, - startTime: textSnappedTime, - }); - } else { - // Handle media items - const mediaItem = mediaFiles.find((item) => item.id === dragData.id); - - if (!mediaItem) { - toast.error("Media item not found"); - return; - } - - let targetTrackId = track.id; - - // Check if track type is compatible - const isVideoOrImage = - dragData.type === "video" || dragData.type === "image"; - const isAudio = dragData.type === "audio"; - const isCompatible = isVideoOrImage - ? canElementGoOnTrack("media", track.type) - : isAudio - ? canElementGoOnTrack("media", track.type) - : false; - - let targetTrack = tracks.find((t) => t.id === targetTrackId); - - // Handle position-aware track creation for media elements - if (!isCompatible || dropPosition !== "on") { - if (isVideoOrImage) { - // For video/image, check if we need a main track or additional media track - const mainTrack = getMainTrack(tracks); - - if (!mainTrack) { - // No main track exists, create it - targetTrackId = addTrack("media"); - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } else if ( - mainTrack.elements.length === 0 && - dropPosition === "on" - ) { - // Main track exists and is empty, use it - targetTrackId = mainTrack.id; - targetTrack = mainTrack; - } else { - // Create new media track - let insertIndex: number; - - if (dropPosition === "above") { - insertIndex = currentTrackIndex; - } else if (dropPosition === "below") { - insertIndex = currentTrackIndex + 1; - } else { - // Insert above main track - const mainTrackIndex = tracks.findIndex( - (t) => t.id === mainTrack.id - ); - insertIndex = mainTrackIndex; - } - - targetTrackId = insertTrackAt("media", insertIndex); - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } - } else if (isAudio) { - // Audio tracks go at the bottom - const mainTrack = getMainTrack(tracks); - let insertIndex: number; - - if (dropPosition === "above") { - insertIndex = currentTrackIndex; - } else if (dropPosition === "below") { - insertIndex = currentTrackIndex + 1; - } else { - // Insert after main track (bottom area) - if (mainTrack) { - const mainTrackIndex = tracks.findIndex( - (t) => t.id === mainTrack.id - ); - insertIndex = mainTrackIndex + 1; - } else { - insertIndex = tracks.length; // Bottom of timeline - } - } - - targetTrackId = insertTrackAt("audio", insertIndex); - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } - } - - if (!targetTrack) return; - - // Check for overlaps with existing elements in target track - const newElementDuration = mediaItem.duration || 5; - const mediaSnappedTime = getDropSnappedTime( - newStartTime, - newElementDuration - ); - const newElementEnd = mediaSnappedTime + newElementDuration; - - const hasOverlap = targetTrack.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - // Check if elements overlap - return ( - mediaSnappedTime < existingEnd && newElementEnd > existingStart - ); - }); - - if (hasOverlap) { - toast.error( - "Cannot place element here - it would overlap with existing elements" - ); - return; - } - - addElementToTrack(targetTrackId, { - type: "media", - mediaId: mediaItem.id, - name: mediaItem.name, - duration: mediaItem.duration || 5, - startTime: mediaSnappedTime, - trimStart: 0, - trimEnd: 0, - }); - } - } else if (hasFiles) { - // External file drops - const { activeProject } = useProjectStore.getState(); - const { addMediaFile } = useMediaStore.getState(); - const { addElementToTrack } = useTimelineStore.getState(); - - if (!activeProject) { - toast.error("No active project"); - return; - } - - // Process and add files to new timeline tracks at playhead position - processMediaFiles(e.dataTransfer.files) - .then(async (processedItems) => { - for (const processedItem of processedItems) { - await addMediaFile(activeProject.id, processedItem); - const currentMediaFiles = mediaFiles; - const addedItem = currentMediaFiles.find( - (item) => - item.name === processedItem.name && - item.url === processedItem.url - ); - - if (addedItem) { - const trackType: TrackType = - addedItem.type === "audio" ? "audio" : "media"; - const targetTrackId = insertTrackAt(trackType, 0); - - addElementToTrack(targetTrackId, { - type: "media", - mediaId: addedItem.id, - name: addedItem.name, - duration: addedItem.duration || 5, - startTime: currentTime, - trimStart: 0, - trimEnd: 0, - }); - } - } - }) - .catch((error) => { - console.error("Error processing external files:", error); - toast.error("Failed to process dropped files"); - }); - } - } catch (error) { - console.error("Error handling drop:", error); - toast.error("Failed to add media to track"); - } - }; +interface TimelineTrackContentProps { + track: TimelineTrack; + zoomLevel: number; + dragState: ElementDragState; + rulerScrollRef: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + lastMouseXRef: React.RefObject<number>; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; + onResizeStateChange?: (params: { isResizing: boolean }) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + }) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + }) => void; + onTrackMouseDown?: (event: React.MouseEvent) => void; + shouldIgnoreClick?: () => boolean; +} - return ( - <div - className="w-full h-full hover:bg-muted/20" - onClick={(e) => { - // If clicking empty area (not on an element), deselect all elements - if (!(e.target as HTMLElement).closest(".timeline-element")) { - clearSelectedElements(); - } - }} - onDragOver={handleTrackDragOver} - onDragEnter={handleTrackDragEnter} - onDragLeave={handleTrackDragLeave} - onDrop={handleTrackDrop} - > - <div - ref={timelineRef} - className="h-full relative track-elements-container min-w-full" - > - {track.elements.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 element here" - : ""} - </div> - ) : ( - <> - {track.elements.map((element) => { - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id - ); +export function TimelineTrackContent({ + track, + zoomLevel, + dragState, + rulerScrollRef, + tracksScrollRef, + lastMouseXRef, + onSnapPointChange, + onResizeStateChange, + onElementMouseDown, + onElementClick, + onTrackMouseDown, + shouldIgnoreClick, +}: TimelineTrackContentProps) { + const editor = useEditor(); + const { isElementSelected, clearElementSelection } = useElementSelection(); - const handleElementSplit = () => { - const { currentTime } = usePlaybackStore(); - const { splitSelected } = useTimelineStore(); - const splitTime = currentTime; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + const duration = editor.timeline.getTotalDuration(); - if (splitTime > effectiveStart && splitTime < effectiveEnd) { - splitSelected(splitTime, track.id, element.id); - } else { - toast.error("Playhead must be within element to split"); - } - }; + useEdgeAutoScroll({ + isActive: dragState.isDragging, + getMouseClientX: () => lastMouseXRef.current ?? 0, + rulerScrollRef, + tracksScrollRef, + contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, + }); - const handleElementDuplicate = () => { - const { addElementToTrack } = useTimelineStore.getState(); - const { id, ...elementWithoutId } = element; - addElementToTrack(track.id, { - ...elementWithoutId, - name: element.name + " (copy)", - startTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1, - }); - }; + const hasSelectedElements = track.elements.some((element) => + isElementSelected({ trackId: track.id, elementId: element.id }), + ); - const handleElementDelete = () => { - const { deleteSelected } = useTimelineStore.getState(); - deleteSelected(track.id, element.id); - }; + return ( + <button + className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")} + onClick={() => { + if (shouldIgnoreClick?.()) return; + clearElementSelection(); + }} + onMouseDown={(event) => { + onTrackMouseDown?.(event); + }} + type="button" + > + <div className="relative h-full min-w-full"> + {track.elements.length === 0 ? ( + <div className="text-muted-foreground border-muted/30 flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" /> + ) : ( + track.elements.map((element) => { + const isSelected = isElementSelected({ + trackId: track.id, + elementId: element.id, + }); - return ( - <TimelineElement - key={element.id} - element={element} - track={track} - zoomLevel={zoomLevel} - isSelected={isSelected} - onElementMouseDown={handleElementMouseDown} - onElementClick={handleElementClick} - /> - ); - })} - </> - )} - </div> - </div> - ); + return ( + <TimelineElement + key={element.id} + element={element} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onSnapPointChange={onSnapPointChange} + onResizeStateChange={onResizeStateChange} + onElementMouseDown={(event, element) => + onElementMouseDown({ event, element, track }) + } + onElementClick={(event, element) => + onElementClick({ event, element, track }) + } + dragState={dragState} + /> + ); + }) + )} + </div> + </button> + ); } |
