"use client"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Delete02Icon, MagicWand05Icon, MusicNote03Icon, TaskAdd02Icon, TextIcon, ViewIcon, ViewOffSlashIcon, VolumeHighIcon, VolumeOffIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; import { OcShapesIcon, OcVideoIcon } from "@/components/icons"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { useTimelineZoom } from "@/timeline/hooks/use-timeline-zoom"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import type { MediaTime } from "@/wasm"; import type { ElementDragView, DropTarget } from "@/timeline"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; import { SelectionBox } from "@/selection/selection-box"; import { useBoxSelect } from "@/selection/hooks/use-box-select"; import { SnapIndicator } from "./snap-indicator"; import type { SnapPoint } from "@/timeline/snapping"; import type { TimelineTrack } from "@/timeline"; import { TIMELINE_SCROLLBAR_SIZE_PX, TIMELINE_CONTENT_TOP_PADDING_PX, TIMELINE_TRACK_GAP_PX, TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX, KEYFRAME_LANE_HEIGHT_PX, } from "./layout"; import { useElementInteraction } from "@/timeline/hooks/element/use-element-interaction"; import { canTrackHaveAudio, canTrackBeHidden, getTimelineZoomMin, getTimelinePaddingPx, } from "@/timeline"; import { timelineTimeToPixels } from "@/timeline/pixel-utils"; import { getTrackHeight, getCumulativeHeightBefore, getTotalTracksHeight, } from "./track-layout"; import { SELECTED_TRACK_ROW_CLASS } from "./theme"; import { computeTrackExpansionHeight, getTrackExpandedRows, getPropertyLabel, type ExpandedRow, } from "./expanded-layout"; import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction"; import { TimelineToolbar } from "./timeline-toolbar"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useTimelineSeek } from "@/timeline/hooks/use-timeline-seek"; import { useTimelineDragDrop } from "@/timeline/hooks/use-timeline-drag-drop"; import { TimelineRuler } from "./timeline-ruler"; import { TimelineBookmarksRow, useBookmarkDrag, } from "@/timeline/bookmarks/index"; import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bottom"; import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize"; import { useTimelineStore } from "@/timeline/timeline-store"; import { useEditor } from "@/editor/use-editor"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; import { DragLine } from "./drag-line"; import { invokeAction } from "@/actions"; import { resolveTimelineElementIntersections } from "./selection-hit-testing"; import { cn } from "@/utils/ui"; const TRACKS_CONTAINER_MAX_HEIGHT = 800; const FALLBACK_CONTAINER_WIDTH = 1000; const TRACKS_CONTAINER_HEIGHT = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; const TRACK_ICONS: Record = { video: , text: ( ), audio: ( ), graphic: , effect: ( ), }; export function Timeline() { const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); const { selectedElements, clearElementSelection, setElementSelection, mergeElementsIntoSelection, } = useElementSelection(); const editor = useEditor(); const timeline = editor.timeline; const scene = useEditor((currentEditor) => currentEditor.scenes.getActiveSceneOrNull(), ); const tracks = useMemo( () => scene ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] : [], [scene], ); const mainTrackId = scene?.tracks.main.id ?? null; const seek = (time: MediaTime) => editor.playback.seek({ time }); const timelineRef = useRef(null); const timelineHeaderRef = useRef(null); const rulerRef = useRef(null); const rulerScrollRef = useRef(null); const tracksContainerRef = useRef(null); const tracksScrollRef = useRef(null); const trackLabelsRef = useRef(null); const playheadRef = useRef(null); const trackLabelsScrollRef = useRef(null); const [currentSnapPoint, setCurrentSnapPoint] = useState( null, ); const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { setCurrentSnapPoint(snapPoint); }, []); 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, }); const { isResizing, handleResizeStart } = useTimelineResize({ zoomLevel, onSnapPointChange: handleSnapPointChange, }); const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); const getTrackExpansionHeight = useCallback( (trackIndex: number) => { const track = tracks[trackIndex]; if (!track) return 0; return computeTrackExpansionHeight({ track, expandedElementIds }); }, [tracks, expandedElementIds], ); // Stable refs so the wheel listener never goes stale const setZoomLevelRef = useRef(setZoomLevel); useEffect(() => { setZoomLevelRef.current = setZoomLevel; }, [setZoomLevel]); const saveScrollPositionRef = useRef(saveScrollPosition); useEffect(() => { saveScrollPositionRef.current = saveScrollPosition; }, [saveScrollPosition]); const minZoomLevelRef = useRef(minZoomLevel); useEffect(() => { minZoomLevelRef.current = minZoomLevel; }, [minZoomLevel]); // Pushes tracks scroll position to the two overflow:hidden followers // (ruler and track labels). Called from the wheel handler (before paint, // zero lag) and from onScroll on the tracks area (covers scrollbar drag). const syncFollowers = useCallback(() => { const tracks = tracksScrollRef.current; if (!tracks) return; if (rulerScrollRef.current) { rulerScrollRef.current.scrollLeft = tracks.scrollLeft; } if (trackLabelsScrollRef.current) { trackLabelsScrollRef.current.scrollTop = tracks.scrollTop; } }, []); // Single non-passive capture listener owns all wheel input. Prevents any // native scroll or browser zoom from firing inside the timeline. useEffect(() => { const container = timelineRef.current; if (!container) return; let pendingZoomDelta = 0; let zoomRafId: ReturnType | null = null; const onWheel = (e: WheelEvent) => { const isZoom = e.ctrlKey || e.metaKey; if (isZoom) { e.preventDefault(); const normalizedDelta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; pendingZoomDelta += normalizedDelta; if (zoomRafId === null) { zoomRafId = requestAnimationFrame(() => { const frameRawDelta = pendingZoomDelta; const cappedDelta = Math.sign(frameRawDelta) * Math.min(Math.abs(frameRawDelta), 30); const zoomFactor = Math.exp(-cappedDelta / 300); setZoomLevelRef.current((prev) => prev * zoomFactor); pendingZoomDelta = 0; zoomRafId = null; }); } return; } const tracks = tracksScrollRef.current; if (!tracks) return; const isHorizontal = e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY); e.preventDefault(); if (isHorizontal) { const raw = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; const clamped = Math.sign(raw) * Math.min(Math.abs(raw), TIMELINE_HORIZONTAL_WHEEL_STEP_PX); tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped); } else { tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY); } syncFollowers(); saveScrollPositionRef.current(); }; container.addEventListener("wheel", onWheel, { passive: false, capture: true, }); return () => { container.removeEventListener("wheel", onWheel, { capture: true }); if (zoomRafId !== null) cancelAnimationFrame(zoomRafId); }; }, [syncFollowers]); useInitialScrollBottom({ tracksScrollRef, trackLabelsScrollRef, onAfterScroll: () => saveScrollPositionRef.current(), isReady: tracks.length > 0, }); const { dragView, handleElementMouseDown, handleElementClick } = useElementInteraction({ zoomLevel, tracksContainerRef, tracksScrollRef, snappingEnabled, onSnapPointChange: handleSnapPointChange, }); const isElementDragging = dragView.kind === "dragging"; const { dragState: bookmarkDragState, handleBookmarkMouseDown, lastMouseXRef: bookmarkLastMouseXRef, } = useBookmarkDrag({ zoomLevel, scrollRef: tracksScrollRef, snappingEnabled, onSnapPointChange: handleSnapPointChange, }); const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = useTimelinePlayhead({ zoomLevel, rulerRef, rulerScrollRef, tracksScrollRef, playheadRef, }); const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ containerRef: tracksContainerRef, tracksScrollRef, zoomLevel, }); const { selectionBox, handleMouseDown: handleSelectionMouseDown, isSelecting, shouldIgnoreClick, } = useBoxSelect({ containerRef: tracksContainerRef, selectedIds: selectedElements, anchorId: null, getIsAdditiveSelection: (event) => event.shiftKey || event.ctrlKey || event.metaKey, resolveIntersections: ({ startPos, currentPos }) => { if (!tracksContainerRef.current) { return []; } return resolveTimelineElementIntersections({ container: tracksContainerRef.current, scrollContainer: tracksScrollRef.current, tracks, zoomLevel, startPos, currentPos, }); }, onSelectionChange: ({ intersectedIds, isAdditive }) => { if (isAdditive) { mergeElementsIntoSelection({ elements: intersectedIds }); } else { setElementSelection({ elements: intersectedIds }); } }, }); const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel, }); const paddingPx = getTimelinePaddingPx({ containerWidth, zoomLevel, minZoom: minZoomLevel, }); const dynamicTimelineWidth = Math.max( contentWidth + paddingPx, containerWidth, ); const tracksViewportWidth = tracksScrollRef.current?.clientWidth ?? tracksContainerRef.current?.clientWidth ?? containerWidth; const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth; useEdgeAutoScroll({ isActive: bookmarkDragState.isDragging, getMouseClientX: () => bookmarkLastMouseXRef.current, rulerScrollRef, tracksScrollRef, contentWidth: dynamicTimelineWidth, }); useEdgeAutoScroll({ isActive: isElementDragging, getMouseClientX: () => dragView.kind === "dragging" ? dragView.currentMouseX : 0, rulerScrollRef, tracksScrollRef, contentWidth: dynamicTimelineWidth, }); const showSnapIndicator = snappingEnabled && currentSnapPoint !== null && (isElementDragging || bookmarkDragState.isDragging || isResizing); const { handleTracksMouseDown, handleTracksClick, handleRulerMouseDown, handleRulerClick, } = useTimelineSeek({ playheadRef, trackLabelsRef, rulerScrollRef, tracksScrollRef, zoomLevel, duration: timeline.getTotalDuration(), isSelecting, clearSelectedElements: clearElementSelection, seek, }); const timelineHeaderHeight = (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + TIMELINE_CONTENT_TOP_PADDING_PX || 0; return (
setZoomLevel(zoom)} />
{ syncFollowers(); saveScrollPosition(); }} >
{/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */} {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
{ 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); }} > {tracks.length > 0 && ( { handleSelectionMouseDown(event); handleTracksMouseDown(event); }} onTrackMouseUp={handleTracksClick} shouldIgnoreClick={shouldIgnoreClick} isDragOver={isDragOver} dropTarget={dropTarget} /> )}
{ handleTracksMouseDown(event); handleSelectionMouseDown(event); }} onClick={handleTracksClick} />
); } function TrackLabelsPanel({ trackLabelsRef, trackLabelsScrollRef, timelineHeaderHeight, hasHorizontalScrollbar, getTrackExpansionHeight, }: { trackLabelsRef: React.RefObject; trackLabelsScrollRef: React.RefObject; timelineHeaderHeight: number; hasHorizontalScrollbar: boolean; getTrackExpansionHeight: (trackIndex: number) => number; }) { const editor = useEditor(); const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); const tracks = useMemo( () => scene ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] : [], [scene], ); const { selectedElements } = useElementSelection(); const tracksWithSelection = useMemo( () => new Set(selectedElements.map((el) => el.trackId)), [selectedElements], ); const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); const trackExpandedRowsMap = useMemo( () => tracks.map((track) => getTrackExpandedRows({ track, expandedElementIds }), ), [tracks, expandedElementIds], ); return (
{tracks.length > 0 && (
{tracks.map((track, index) => { const expandedRows = trackExpandedRowsMap[index]; const baseHeight = getTrackHeight({ type: track.type }); return (
{canTrackHaveAudio(track) && ( editor.timeline.toggleTrackMute({ trackId: track.id, }) } /> )} {canTrackBeHidden(track) && ( editor.timeline.toggleTrackVisibility({ trackId: track.id, }) } /> )}
{expandedRows.length > 0 && ( )}
); })}
)}
); } function TimelineTrackRows({ mainTrackId, zoomLevel, dragView, onResizeStart, onElementMouseDown, onElementClick, onTrackMouseDown, onTrackMouseUp, shouldIgnoreClick, isDragOver, dropTarget, }: { mainTrackId: string | null; zoomLevel: number; dragView: ElementDragView; onResizeStart: React.ComponentProps< typeof TimelineTrackContent >["onResizeStart"]; onElementMouseDown: React.ComponentProps< typeof TimelineTrackContent >["onElementMouseDown"]; onElementClick: React.ComponentProps< typeof TimelineTrackContent >["onElementClick"]; onTrackMouseDown: (event: React.MouseEvent) => void; onTrackMouseUp: (event: React.MouseEvent) => void; shouldIgnoreClick: () => boolean; isDragOver: boolean; dropTarget: DropTarget | null; }) { const timeline = useEditor((e) => e.timeline); const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); const tracks = useMemo( () => scene ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] : [], [scene], ); const { selectedElements } = useElementSelection(); const tracksWithSelection = useMemo( () => new Set(selectedElements.map((el) => el.trackId)), [selectedElements], ); const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); const getTrackExpansionHeight = useCallback( (trackIndex: number) => { const track = tracks[trackIndex]; if (!track) return 0; return computeTrackExpansionHeight({ track, expandedElementIds }); }, [tracks, expandedElementIds], ); const draggingElementIds = useMemo( () => dragView.kind === "dragging" ? dragView.memberTimeOffsets : (null as ReadonlyMap | null), [dragView], ); const sortedTracks = useMemo(() => { if (!draggingElementIds) return tracks.map((track, index) => ({ track, index })); return [...tracks] .map((track, index) => ({ track, index })) .sort((a, b) => { const aHasDragged = a.track.elements.some((element) => draggingElementIds.has(element.id), ); const bHasDragged = b.track.elements.some((element) => draggingElementIds.has(element.id), ); if (aHasDragged) return 1; if (bHasDragged) return -1; return 0; }); }, [tracks, draggingElementIds]); return ( <> {sortedTracks.map(({ track, index }) => (
} onClick={(event: React.MouseEvent) => { event.stopPropagation(); invokeAction("paste-copied"); }} > Paste elements } onClick={(event: React.MouseEvent) => { event.stopPropagation(); timeline.toggleTrackMute({ trackId: track.id }); }} > {canTrackHaveAudio(track) && track.muted ? "Unmute track" : "Mute track"} } onClick={(event: React.MouseEvent) => { event.stopPropagation(); timeline.toggleTrackVisibility({ trackId: track.id }); }} > {canTrackBeHidden(track) && track.hidden ? "Show track" : "Hide track"} {track.id !== mainTrackId && ( } onClick={(event: React.MouseEvent) => { event.stopPropagation(); timeline.removeTrack({ trackId: track.id }); }} variant="destructive" > Delete track )}
))} ); } function TimelineGutter({ onMouseDown, onClick, }: { onMouseDown: (event: React.MouseEvent) => void; onClick: (event: React.MouseEvent) => void; }) { // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system return
; } function TrackIcon({ track }: { track: TimelineTrack }) { return <>{TRACK_ICONS[track.type]}; } function TrackToggleIcon({ isOff, icons, onClick, }: { isOff: boolean; icons: { on: IconSvgElement; off: IconSvgElement; }; onClick: () => void; }) { return ( <> {isOff ? ( ) : ( )} ); } function PropertyTree({ rows }: { rows: ExpandedRow[] }) { return (
{rows.map((row) => (
{getPropertyLabel(row.propertyPath)}
))}
); }