import { useRef, useMemo, useCallback, useState, memo } from "react"; import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis"; import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements"; import { defaultTimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; import { useTimelineZoom } from "./useTimelineZoom"; import { useTimelineAssetDrop } from "./timelineDragDrop"; import { TimelineEmptyState } from "./TimelineEmptyState"; import { TimelineCanvas } from "./TimelineCanvas"; import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { TimelineOverlays, type ClipContextMenuState } from "./TimelineOverlays"; import { useTimelineEditPinning } from "./useTimelineEditPinning"; import { useTimelineStackingSync } from "./useTimelineStackingSync"; import { useTimelineGeometry } from "./useTimelineGeometry"; import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout"; import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; import { getTrackStyle, useTimelineDisplayLayout, useTimelineTrackLayout, } from "./useTimelineTrackLayout"; import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; import { useTrackGapMenu } from "./useTrackGapMenu"; import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction"; import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry"; import { getEffectiveTimelineDuration, getTimelinePreviewElement, hasKeyframedTimelineClips, } from "./timelineViewModel"; import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle"; import { useTimelineShiftModifier } from "./useTimelineShiftModifier"; import { useTimelineTicks } from "./useTimelineTicks"; import { getTimelineElementIndexes } from "../lib/timelineElementIndexes"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization"; import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow"; import { useTimelineActiveClips } from "./useTimelineActiveClips"; import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh"; export { shouldAutoScrollTimeline, getTimelineScrollLeftForZoomTransition, getTimelineScrollLeftForZoomAnchor, getTimelinePlaybackFollowScrollLeft, getTimelinePlayheadLeft, getTimelineCanvasHeight, shouldShowTimelineShortcutHint, resolveTimelineAssetDrop, shouldHandleTimelineDeleteKey, getDefaultDroppedTrack, } from "./timelineLayout"; export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry"; export { getTimelineScrollTopForGeometryChange, getTimelineVisibleTimeRange, } from "./timelineViewportGeometry"; export const Timeline = memo(function Timeline({ onSeek, onDrillDown, renderClipContent, renderClipOverlay, onFileDrop, onAssetDrop, onBlockDrop, onCompositionDrop, onDeleteElement: _onDeleteElement, onMoveElement: onMoveElementOverride, onMoveElements: onMoveElementsOverride, onResizeElement: onResizeElementOverride, onResizeElements: onResizeElementsOverride, onBlockedEditAttempt: onBlockedEditAttemptOverride, onSplitElement: onSplitElementOverride, onSelectElement, theme: themeOverrides, sessionEpoch = 0, }: TimelineProps = {}) { const { onMoveElement, onMoveElements, onResizeElement, onResizeElements, onBlockedEditAttempt, onSplitElement, onRazorSplitAll, onDeleteKeyframe, onDeleteAllKeyframes, onMoveKeyframeToPlayhead, onMoveKeyframe, } = useResolvedTimelineEditCallbacks({ onMoveElement: onMoveElementOverride, onMoveElements: onMoveElementsOverride, onResizeElement: onResizeElementOverride, onResizeElements: onResizeElementsOverride, onBlockedEditAttempt: onBlockedEditAttemptOverride, onSplitElement: onSplitElementOverride, }); const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); const refreshAfterLaneMove = useTimelineLaneMoveRefresh(); useMusicBeatAnalysis(); const rawElements = usePlayerStore((s) => s.elements); const expandedElements = useExpandedTimelineElements(); const beatAnalysis = usePlayerStore((s) => s.beatAnalysis); const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement); const beatEdits = usePlayerStore((s) => s.beatEdits); const adjustedBeatAnalysis = useMemo( () => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits), [beatAnalysis, musicElement, beatEdits], ); const duration = usePlayerStore((s) => s.duration); const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode); const timelineReady = usePlayerStore((s) => s.timelineReady); const selectedElementId = usePlayerStore((s) => s.selectedElementId); const selectedElementIds = usePlayerStore((s) => s.selectedElementIds); const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest); const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment); const gsapAnimations = usePlayerStore((s) => s.gsapAnimations); const labelMode = useMemo(() => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations]); // The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter. const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD; const contentGutter = labelMode ? GUTTER : 0; const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); const currentTime = usePlayerStore((s) => s.currentTime); const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); const playheadRef = useRef(null); const containerRef = useRef(null); const scrollRef = useRef(null); const activeTool = usePlayerStore((s) => s.activeTool); const [hoveredClip, setHoveredClip] = useState(null); const isDragging = useRef(false); const shiftHeld = useTimelineShiftModifier(); const [showPopover, setShowPopover] = useState(false); const [kfContextMenu, setKfContextMenu] = useState(null); const [clipContextMenu, setClipContextMenu] = useState(null); const setContainerRef = useCallback((el: HTMLDivElement | null) => { containerRef.current = el; }, []); const lastScrollLeftRef = useRef(0); const effectiveDuration = useMemo( () => getEffectiveTimelineDuration(duration, rawElements), [duration, rawElements], ); const keyframeCache = usePlayerStore((s) => s.keyframeCache); useAutoExpandKeyframedClips(gsapAnimations); const { tracks, trackStyles, trackOrder, trackOrderRef, laneCounts, rowGeometry, rowGeometryRef, } = useTimelineTrackLayout( expandedElements, gsapAnimations, selectedElementId, selectedElementIds, ); const expandedElementsRef = useRef(expandedElements); expandedElementsRef.current = expandedElements; const ppsRef = useRef(100); const durationRef = useRef(effectiveDuration); durationRef.current = effectiveDuration; // Declared before the fitPps derivation so the edit-pin wrappers can close over it. const fitPpsRef = useRef(100); const { pinZoomBeforeEdit, setRangeSelectionRef, pinnedOnMoveElement, pinnedOnMoveElements, pinnedOnResizeElement, pinnedOnResizeElements, pinnedOnFileDrop, pinnedOnAssetDrop, pinnedOnBlockDrop, pinnedOnCompositionDrop, } = useTimelineEditPinning({ ppsRef, fitPpsRef, onMoveElement, onMoveElements, onResizeElement, onResizeElements, onFileDrop, onAssetDrop, onBlockDrop, onCompositionDrop, }); const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({ expandedElementsRef, }); const { gapMenuModel, gapHighlight, setHoveredGapAction, openGapMenu, dismissGapMenu, closeTrackGap, closeAllTrackGaps, } = useTrackGapMenu({ tracks, expandedElementsRef, trackOrderRef, onMoveElement: pinnedOnMoveElement, onMoveElements: pinnedOnMoveElements, }); const { draggedClip, setDraggedClip, resizingClip, setResizingClip, blockedClipRef, suppressClickRef, } = useTimelineClipDrag({ scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, onMoveElement: pinnedOnMoveElement, onMoveElements: pinnedOnMoveElements, onResizeElement: pinnedOnResizeElement, onResizeElements: pinnedOnResizeElements, onBlockedEditAttempt, setShowPopover, setRangeSelectionRef, readZIndex: zSyncEnabled ? readClipZIndex : undefined, onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined, refreshAfterLaneMove, sessionEpoch, }); const assetDrop = useTimelineAssetDrop({ scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, contentOrigin, onFileDrop: pinnedOnFileDrop, onAssetDrop: pinnedOnAssetDrop, onBlockDrop: pinnedOnBlockDrop, onCompositionDrop: pinnedOnCompositionDrop, sessionEpoch, }); const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry); const resizingElementIds = resizingClip?.groupPreview?.map((change) => change.key) ?? (resizingClip ? [getTimelineElementIdentity(resizingClip.element)] : undefined); const { recordTimelineScroll } = useTimelinePerformanceTelemetry({ totalClipCount: expandedElements.length, totalRowCount: displayLayout.displayTrackOrder.length, zoomMode, }); const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } = useTimelineScrollViewport(scrollRef, [ timelineReady, expandedElements.length, displayLayout.totalH, ]); const rowWindow = useTimelineRowVirtualization({ scrollRef, viewport, rowGeometry: displayLayout.rowGeometry, sessionEpoch, elements: expandedElements, selectedElementId, revealElementId: clipRevealRequest?.elementId ?? null, draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined, resizingElementIds, clipContextMenuRowKey: clipContextMenu?.element.track, keyframeContextMenuRowKey: kfContextMenu?.element.track, lastScrollLeftRef, syncScrollViewport, }); const { enabled: rowVirtualizationActive, virtualRows, focusedElementId } = rowWindow; const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } = useTimelineKeyframeHandlers({ expandedElements, keyframeCache, onSelectElement, onSeek, setSelectedElementId, setKfContextMenu, toggleSelectedKeyframe, }); const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } = useTimelineGeometry({ viewportWidth: viewport.clientWidth, effectiveDuration, zoomMode, manualZoomPercent, ppsRef, fitPpsRef, draggedClip, resizingClip, expandedElements, isDragging, scrollRef, lastScrollLeftRef, contentOrigin, }); const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({ tracks, viewport, pixelsPerSecond: pps, contentOrigin, duration: displayDuration, selectedElementId: selectedElementId ?? undefined, draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined, resizingElementIds, revealElementId: clipRevealRequest?.elementId, focusedEaseElementId: focusedEaseSegment?.elementId, clipContextMenuElementId: clipContextMenu ? getTimelineElementIdentity(clipContextMenu.element) : undefined, keyframeContextMenuElementId: kfContextMenu ? getTimelineElementIdentity(kfContextMenu.element) : undefined, focusedElementId, scrollRef, elements: expandedElements, rowGeometry: displayLayout.rowGeometry, allowHorizontalReveal: zoomMode === "manual", rowVirtualizationActive, sessionEpoch, }); useTimelineActiveClips({ scrollRef, currentTime, clipStateVersion: renderTimeRange, elementStateVersion: expandedElements, }); const laneGapStrips = useTimelineGapHighlights({ gapHighlight, tracks, selectedElementId, selectedElementIds, expandedElements, dragActive: draggedClip?.started === true || resizingClip != null, displayDuration, }); const { seekFromX, autoScrollDuringDrag, dragScrollRaf } = useTimelinePlayhead({ playheadRef, scrollRef, ppsRef, durationRef, isDragging, currentTime, zoomMode, manualZoomPercent, zoomModeRef, manualZoomPercentRef, fitPps, fitPpsRef, effectiveDuration, pps, timelineReady, elementsLength: expandedElements.length, setZoomMode, setManualZoomPercent, onSeek, contentOrigin, }); const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } = useTimelineRazorInteraction({ active: activeTool === "razor", scrollRef, contentOrigin, pixelsPerSecond: pps, onSplitAll: onRazorSplitAll, }); const { rangeSelection, setRangeSelection, shiftClickClipRef, marqueeRect, isScrubbing, handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel, } = useTimelineRangeSelection({ scrollRef, ppsRef, effectiveDuration, pps, onSeek, seekFromX, autoScrollDuringDrag, dragScrollRaf, isDragging, setShowPopover, elementsRef: expandedElementsRef, clipIndex, rowGeometryRef, onSelectElement, contentOrigin, sessionEpoch, }); setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag useTimelineSelectionLifecycle(expandedElements, selectedElementId, setShowPopover, () => setRangeSelection(null), ); const { major, minor, majorTickInterval } = useTimelineTicks( displayDuration, pps, timeDisplayMode, rowVirtualizationActive ? renderTimeRange : undefined, ); const getPreviewElement = useCallback( (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip), [resizingClip], ); if (!timelineReady || expandedElements.length === 0) { return ( ); } return (
{ lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload recordTimelineScroll(e.currentTarget); syncScrollViewport(e.currentTarget, true); }} {...rowWindow.timelineFocusProps} onDragOver={assetDrop.handleAssetDragOver} onDragLeave={assetDrop.handleAssetDragLeave} onDrop={assetDrop.handleAssetDrop} onPointerDown={(e) => { // Let interactive controls (keyframe nav/toggle, caret, inputs) handle // their own clicks — scrubbing here would preventDefault and eat them. if (e.target instanceof Element && e.target.closest("button, input, select, a")) return; if (splitAllAtPointer(e)) return; handlePointerDown(e); }} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerCancel} onLostPointerCapture={handlePointerCancel} > { e.preventDefault(); setSelectedElementId(el.key ?? el.id); onSelectElement?.(el); dismissGapMenu(); setClipContextMenu({ x: e.clientX, y: e.clientY, element: el, sessionEpoch: usePlayerStore.getState().timelineSessionEpoch, }); }} onContextMenuLane={(e, track, time) => { if (draggedClip?.started || resizingClip) return; setClipContextMenu(null); openGapMenu({ x: e.clientX, y: e.clientY, track, time }); }} /> {activeTool === "razor" && razorGuideX !== null && }
); });