From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- .../components/editor/timeline/audio-waveform.tsx | 176 +++ .../src/components/editor/timeline/bookmarks.tsx | 111 ++ .../src/components/editor/timeline/drag-line.tsx | 28 + apps/web/src/components/editor/timeline/index.tsx | 1421 +++++++------------- .../components/editor/timeline/snap-indicator.tsx | 52 + .../editor/timeline/timeline-cache-indicator.tsx | 126 -- .../editor/timeline/timeline-element.tsx | 923 +++++++------ .../components/editor/timeline/timeline-marker.tsx | 67 - .../editor/timeline/timeline-playhead.tsx | 244 ++-- .../components/editor/timeline/timeline-ruler.tsx | 83 ++ .../components/editor/timeline/timeline-tick.tsx | 39 + .../editor/timeline/timeline-toolbar.tsx | 696 +++++----- .../components/editor/timeline/timeline-track.tsx | 1300 ++---------------- 13 files changed, 2063 insertions(+), 3203 deletions(-) create mode 100644 apps/web/src/components/editor/timeline/audio-waveform.tsx create mode 100644 apps/web/src/components/editor/timeline/bookmarks.tsx create mode 100644 apps/web/src/components/editor/timeline/drag-line.tsx create mode 100644 apps/web/src/components/editor/timeline/snap-indicator.tsx delete mode 100644 apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx delete mode 100644 apps/web/src/components/editor/timeline/timeline-marker.tsx create mode 100644 apps/web/src/components/editor/timeline/timeline-ruler.tsx create mode 100644 apps/web/src/components/editor/timeline/timeline-tick.tsx (limited to 'apps/web/src/components/editor/timeline') 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(null); + const wavesurfer = useRef(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 ( +
+ Audio unavailable +
+ ); + } + + return ( +
+ {isLoading && ( +
+ Loading... +
+ )} +
+
+ ); +} + +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 ( +
+ +
+ ); +} + +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 + | React.KeyboardEvent; + }) => { + event.stopPropagation(); + const framesPerSecond = activeProject?.settings.fps ?? 30; + const snappedTime = getSnappedSeekTime({ + rawTime: time, + duration, + fps: framesPerSecond, + }); + editor.playback.seek({ time: snappedTime }); + }; + + return ( + + ); +} 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 ( +
+ ); +} 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(null); - const rulerRef = useRef(null); - const [isInTimeline, setIsInTimeline] = useState(false); - - // Track mouse down/up for distinguishing clicks from drag/resize ends - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); - - // Timeline zoom functionality - const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ - containerRef: timelineRef, - isInTimeline, - }); - - // Old marquee selection removed - using new SelectionBox component instead - - // Dynamic timeline width calculation based on playhead position and duration - const dynamicTimelineWidth = Math.max( - (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration - (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer - timelineRef.current?.clientWidth || 1000 // Minimum width - ); - - // Scroll synchronization and auto-scroll to playhead - const rulerScrollRef = useRef(null); - const tracksScrollRef = useRef(null); - const trackLabelsRef = useRef(null); - const playheadRef = useRef(null); - const trackLabelsScrollRef = useRef(null); - const isUpdatingRef = useRef(false); - const lastRulerSync = useRef(0); - const lastTracksSync = useRef(0); - const lastVerticalSync = useRef(0); - - // Timeline playhead ruler handlers - const { handleRulerMouseDown } = useTimelinePlayheadRuler({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - // Selection box functionality - const tracksContainerRef = useRef(null); - const { - selectionBox, - handleMouseDown: handleSelectionMouseDown, - isSelecting, - justFinishedSelecting, - } = useSelectionBox({ - containerRef: tracksContainerRef, - playheadRef, - onSelectionComplete: (elements) => { - console.log(JSON.stringify({ onSelectionComplete: elements.length })); - setSelectedElements(elements); - }, - }); - - // Calculate snap indicator state - const [currentSnapPoint, setCurrentSnapPoint] = useState( - null - ); - const showSnapIndicator = - dragState.isDragging && snappingEnabled && currentSnapPoint !== null; - - // Callback to handle snap point changes from TimelineTrackContent - const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { - setCurrentSnapPoint(snapPoint); - }, []); - - // Track mouse down to distinguish real clicks from drag/resize ends - const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { - // Only track mouse down on timeline background areas (not elements) - const target = e.target as HTMLElement; - 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; - } - - // 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; - } - - // 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; - } - - // 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; - } - - // Clear selected elements when clicking empty timeline area - console.log( - JSON.stringify({ - debug_click: "PROCEEDING - clearing elements", - clearingSelectedElements: true, - }) - ); - clearSelectedElements(); - - // Determine if we're clicking in ruler or tracks area - const isRulerClick = (e.target as HTMLElement).closest( - "[data-ruler-area]" - ); - - 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, - }) - ); - - let mouseX: number; - let scrollLeft = 0; - - 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 rawTime = Math.max( - 0, - Math.min( - duration, - (mouseX + scrollLeft) / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) - ) - ); - - // Use frame snapping for timeline clicking - const projectFps = activeProject?.fps || 30; - const time = snapTimeToFrame(rawTime, projectFps); - seek(time); - }, - [ - duration, - zoomLevel, - seek, - rulerScrollRef, - tracksScrollRef, - clearSelectedElements, - isSelecting, - justFinishedSelecting, - ] - ); - - // Update timeline duration when tracks change - useEffect(() => { - const totalDuration = getTotalDuration(); - setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline - }, [tracks, setDuration, getTotalDuration]); - - // Old marquee system removed - using new SelectionBox component instead - - const handleDragEnter = (e: React.DragEvent) => { - // When something is dragged over the timeline, show overlay - e.preventDefault(); - // Don't show overlay for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } - dragCounterRef.current += 1; - if (!isDragOver) { - setIsDragOver(true); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - - // Don't update state for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } - - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) { - setIsDragOver(false); - } - }; - - const handleDrop = async (e: React.DragEvent) => { - // When media is dropped, add it as a new track/element - e.preventDefault(); - setIsDragOver(false); - dragCounterRef.current = 0; - - // Ignore timeline element drags - they're handled by track-specific handlers - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element" - ); - if (hasTimelineElement) { - return; - } - - const itemData = e.dataTransfer.getData("application/x-media-item"); - if (itemData) { - try { - const dragData: DragData = JSON.parse(itemData); - - if (dragData.type === "text") { - // Always create new text track to avoid overlaps - 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 ( -
setIsInTimeline(true)} - onMouseLeave={() => setIsInTimeline(false)} - > - - - {/* Timeline Container */} -
- - - {/* Timeline Header with Ruler */} -
- {/* Track Labels Header */} -
- {/* Empty space */} - - . - -
- - {/* Timeline Ruler */} -
{ - // 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 - > - { - if (isUpdatingRef.current) return; - isUpdatingRef.current = true; - const tracksViewport = tracksScrollRef.current; - if (tracksViewport) { - tracksViewport.scrollLeft = ( - e.currentTarget as HTMLDivElement - ).scrollLeft; - } - isUpdatingRef.current = false; - }} - > -
- - {/* 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 ( - - ); - }).filter(Boolean); - })()} - - {/* Bookmark markers */} - {(() => { - const { activeProject } = useProjectStore.getState(); - if (!activeProject?.bookmarks?.length) return null; - - return activeProject.bookmarks.map((bookmarkTime, i) => ( -
{ - e.stopPropagation(); - usePlaybackStore.getState().seek(bookmarkTime); - }} - > -
- -
-
- )); - })()} -
-
-
-
- - {/* Tracks Area */} -
- {/* Track Labels */} - {tracks.length > 0 && ( -
- -
- {tracks.map((track) => ( -
-
- {track.muted ? ( - toggleTrackMute(track.id)} - /> - ) : ( - toggleTrackMute(track.id)} - /> - )} - - -
-
- ))} -
-
-
- )} - - {/* Timeline Tracks Content */} -
{ - // 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} - > - - { - if (isUpdatingRef.current) return; - isUpdatingRef.current = true; - const rulerViewport = rulerScrollRef.current; - if (rulerViewport) { - rulerViewport.scrollLeft = ( - e.currentTarget as HTMLDivElement - ).scrollLeft; - } - isUpdatingRef.current = false; - }} - > -
- {tracks.length === 0 ? ( -
- ) : ( - <> - {tracks.map((track, index) => ( - - -
{ - // If clicking empty area (not on a element), deselect all elements - if ( - !(e.target as HTMLElement).closest( - ".timeline-element" - ) - ) { - clearSelectedElements(); - } - }} - > - -
-
- - { - e.stopPropagation(); - toggleTrackMute(track.id); - }} - > - {track.muted ? "Unmute Track" : "Mute Track"} - - e.stopPropagation()}> - Track settings (soon) - - -
- ))} - - )} -
- -
-
-
-
- ); + 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 }); + + // refs + const timelineRef = useRef(null); + const timelineHeaderRef = useRef(null); + const rulerRef = useRef(null); + const tracksContainerRef = useRef(null); + const tracksScrollRef = useRef(null); + const trackLabelsRef = useRef(null); + const playheadRef = useRef(null); + const trackLabelsScrollRef = useRef(null); + + // state + const [isResizing, setIsResizing] = useState(false); + const [currentSnapPoint, setCurrentSnapPoint] = useState( + null, + ); + + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); + const handleResizeStateChange = useCallback( + ({ isResizing: nextIsResizing }: { isResizing: boolean }) => { + setIsResizing(nextIsResizing); + if (!nextIsResizing) { + setCurrentSnapPoint(null); + } + }, + [], + ); + + const timelineDuration = timeline.getTotalDuration() || 0; + const minZoomLevel = getTimelineZoomMin({ + duration: timelineDuration, + containerWidth: tracksContainerRef.current?.clientWidth, + }); + + const savedViewState = editor.project.getTimelineViewState(); + + const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } = + useTimelineZoom({ + containerRef: timelineRef, + minZoom: minZoomLevel, + initialZoom: savedViewState?.zoomLevel, + initialScrollLeft: savedViewState?.scrollLeft, + initialPlayheadTime: savedViewState?.playheadTime, + tracksScrollRef, + rulerScrollRef: tracksScrollRef, + }); + + const { + dragState, + dragDropTarget, + handleElementMouseDown, + handleElementClick, + lastMouseXRef, + } = useElementInteraction({ + zoomLevel, + timelineRef, + tracksContainerRef, + tracksScrollRef, + headerRef: timelineHeaderRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + + const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = + useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef: tracksScrollRef, + tracksScrollRef, + playheadRef, + }); + + const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ + containerRef: tracksContainerRef, + headerRef: timelineHeaderRef, + zoomLevel, + }); + + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + shouldIgnoreClick, + } = useSelectionBox({ + containerRef: tracksContainerRef, + onSelectionComplete: (elements) => { + setElementSelection({ elements }); + }, + tracksScrollRef, + zoomLevel, + }); + + 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, + ); + + const showSnapIndicator = + snappingEnabled && + currentSnapPoint !== null && + (dragState.isDragging || isResizing); + + const { + handleTracksMouseDown, + handleTracksClick, + handleRulerMouseDown, + handleRulerClick, + } = useTimelineSeek({ + playheadRef, + trackLabelsRef, + rulerScrollRef: tracksScrollRef, + tracksScrollRef, + zoomLevel, + duration: timeline.getTotalDuration(), + isSelecting, + clearSelectedElements: clearElementSelection, + seek, + }); + + useScrollSync({ + tracksScrollRef, + trackLabelsScrollRef, + }); + + const timelineHeaderHeight = + timelineHeaderRef.current?.getBoundingClientRect().height ?? 0; + + return ( +
+ setZoomLevel(zoom)} + /> + +
+ +
+
+
+ . +
+
+ . +
+ {tracks.length > 0 && ( +
+ +
+ {tracks.map((track) => ( +
+
+ {process.env.NODE_ENV === "development" && + isMainTrack(track) && ( +
+ )} + {canTracktHaveAudio(track) && ( + + editor.timeline.toggleTrackMute({ + trackId: track.id, + }) + } + /> + )} + {canTrackBeHidden(track) && ( + + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + +
+
+ ))} +
+ +
+ )} +
+ +
+ + + + { + 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(); + }} + > +
+
+ + +
+ +
+ {tracks.length === 0 ? ( +
+ ) : ( + tracks.map((track, index) => ( + + +
+ +
+
+ + } + onClick={(e) => { + e.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + + { + e.stopPropagation(); + timeline.toggleTrackMute({ + trackId: track.id, + }); + }} + > + + + {canTracktHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + + + { + e.stopPropagation(); + timeline.toggleTrackVisibility({ + trackId: track.id, + }); + }} + > + + + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + + + { + e.stopPropagation(); + timeline.removeTrack({ + trackId: track.id, + }); + }} + variant="destructive" + > + + Delete track + + +
+ )) + )} +
+
+ +
+
+
+
+ ); } function TrackIcon({ track }: { track: TimelineTrack }) { - return ( - <> - {track.type === "media" && ( -