import { memo, type ReactNode } from "react"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelineRuler } from "./TimelineRuler"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; import { PlayheadIndicator } from "./PlayheadIndicator"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent, type TimelineRangeSelection, } from "./timelineEditing"; import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme"; import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; import { usePlayerStore, type TimelineElement, type KeyframeCacheEntry, } from "../store/playerStore"; import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag"; import type { TrackVisualStyle } from "./timelineIcons"; import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { isMusicTrack } from "../../utils/timelineInspector"; function ClipLabel({ element, color }: { element: TimelineElement; color: string }) { const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id)); return ( {element.label || element.id || element.tag} {lint && lint.count > 0 && ( )} ); } interface TimelineCanvasProps { major: number[]; minor: number[]; pps: number; trackContentWidth: number; totalH: number; effectiveDuration: number; majorTickInterval: number; shiftHeld: boolean; rangeSelection: TimelineRangeSelection | null; theme: TimelineTheme; displayTrackOrder: number[]; trackOrder: number[]; tracks: [number, TimelineElement[]][]; trackStyles: Map; selectedElementId: string | null; hoveredClip: string | null; draggedClip: DraggedClipState | null; resizingClip: ResizingClipState | null; blockedClipRef: React.RefObject; suppressClickRef: React.RefObject; scrollRef: React.RefObject; renderClipContent?: ( element: TimelineElement, style: { clip: string; label: string }, ) => ReactNode; renderClipOverlay?: (element: TimelineElement) => ReactNode; playheadRef: React.RefObject; onDrillDown?: (element: TimelineElement) => void; onSelectElement?: (element: TimelineElement | null) => void; setHoveredClip: (key: string | null) => void; setShowPopover: (v: boolean) => void; setRangeSelection: (v: null) => void; setResizingClip: (v: ResizingClipState | null) => void; setDraggedClip: (v: DraggedClipState | null) => void; setSelectedElementId: (id: string | null) => void; syncClipDragAutoScroll: (x: number, y: number) => void; shiftClickClipRef: React.RefObject<{ element: TimelineElement; anchorX: number; anchorY: number; } | null>; getPreviewElement: (element: TimelineElement) => TimelineElement; getTrackStyle: (tag: string) => TrackVisualStyle; keyframeCache?: Map; selectedKeyframes: Set; currentTime: number; onClickKeyframe?: (element: TimelineElement, percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; /** Snap a keyframe's clip-relative % to the nearest beat (returns unchanged when none in range). */ onSnapKeyframePct?: (element: TimelineElement, pct: number) => number; /** Select the element when a keyframe drag starts (loads its GSAP session). */ onPickKeyframeElement?: (element: TimelineElement) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; beatAnalysis?: MusicBeatAnalysis | null; } export const TimelineCanvas = memo(function TimelineCanvas({ major, minor, pps, trackContentWidth, totalH, effectiveDuration, majorTickInterval, shiftHeld, rangeSelection, theme, displayTrackOrder, trackOrder, tracks, trackStyles, selectedElementId, hoveredClip, draggedClip, resizingClip: _resizingClip, blockedClipRef, suppressClickRef, scrollRef, renderClipContent, renderClipOverlay, playheadRef, onDrillDown, onSelectElement, setHoveredClip, setShowPopover, setRangeSelection, setResizingClip, setDraggedClip, setSelectedElementId, syncClipDragAutoScroll, shiftClickClipRef, getPreviewElement, getTrackStyle, keyframeCache, selectedKeyframes, currentTime, onClickKeyframe, onShiftClickKeyframe, onDragKeyframe, onSnapKeyframePct, onPickKeyframeElement, onContextMenuKeyframe, onContextMenuClip, beatAnalysis, }: TimelineCanvasProps) { const { onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll } = useTimelineEditContextOptional(); const beatDragging = usePlayerStore((s) => s.beatDragging); const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = draggedClip?.started === true && draggedElement ? getRenderedTimelineElement({ element: draggedElement, draggedElementId: draggedElement.key ?? draggedElement.id, previewStart: draggedClip.previewStart, previewTrack: draggedClip.previewTrack, }) : null; const activeDraggedPosition = draggedClip?.started === true && activeDraggedElement && scrollRef.current ? { left: draggedClip.pointerClientX - scrollRef.current.getBoundingClientRect().left + scrollRef.current.scrollLeft - draggedClip.pointerOffsetX, top: draggedClip.pointerClientY - scrollRef.current.getBoundingClientRect().top + scrollRef.current.scrollTop - draggedClip.pointerOffsetY, } : null; const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => ( <> {renderClipOverlay?.(element)}
{renderClipContent?.(element, clipStyle) ?? ( )}
); return (
{displayTrackOrder.map((trackNum) => { const els = tracks.find(([t]) => t === trackNum)?.[1] ?? []; const ts = trackStyles.get(trackNum) ?? getTrackStyle(""); const isPendingTrack = draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0; // The beat-dot strip occupies the top of this track's lane (active track, // or the music track when nothing is selected). When shown, keyframe // diamonds shrink + drop to the bottom half so they don't collide with it. const beatStripOnTrack = (beatAnalysis?.beatTimes?.length ?? 0) >= 2 && (selectedElementId ? els.some((e) => (e.key ?? e.id) === selectedElementId) : els.some(isMusicTrack)); return (
{ts.icon}
{/* Faint beat lines in every track's background (behind the clips); the active move-snap target is highlighted. */} {/* Beat dots on the active track (the one holding the selection), falling back to the music track when nothing is selected. */} {beatStripOnTrack && ( )} {isPendingTrack && (
New track
)} {els.map((el, i) => { const clipStyle = getTrackStyle(el.tag); const elementKey = el.key ?? el.id; const capabilities = getTimelineEditCapabilities(el); const isSelected = selectedElementId === elementKey; const isComposition = !!el.compositionSrc; const clipKey = `${elementKey}-${i}`; const isDraggingClip = draggedClip?.started === true && (draggedElement?.key ?? draggedElement?.id) === elementKey; if (isDraggingClip) return null; const previewElement = getPreviewElement(el); return ( { e.preventDefault(); onContextMenuClip?.(e, el); }} el={previewElement} pps={pps} clipY={CLIP_Y} isSelected={isSelected} isHovered={hoveredClip === clipKey} isDragging={false} hasCustomContent={!!renderClipContent} capabilities={capabilities} theme={theme} trackStyle={clipStyle} isComposition={isComposition} onHoverStart={() => setHoveredClip(clipKey)} onHoverEnd={() => setHoveredClip(null)} onResizeStart={(edge, e) => { if (e.button !== 0 || e.shiftKey || !onResizeElement) return; if (edge === "start" && !capabilities.canTrimStart) return; if (edge === "end" && !capabilities.canTrimEnd) return; e.stopPropagation(); blockedClipRef.current = null; setShowPopover(false); setRangeSelection(null); setResizingClip({ element: el, edge, originClientX: e.clientX, previewStart: el.start, previewDuration: el.duration, previewPlaybackStart: el.playbackStart, started: false, }); }} onPointerDown={(e) => { if (e.button !== 0) return; if (usePlayerStore.getState().activeTool === "razor") return; if (e.shiftKey) { shiftClickClipRef.current = { element: el, anchorX: e.clientX, anchorY: e.clientY, }; return; } const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); const blockedIntent = resolveBlockedTimelineEditIntent({ width: rect.width, offsetX: e.clientX - rect.left, handleWidth: CLIP_HANDLE_W, capabilities, }); if ( blockedIntent && ((blockedIntent === "move" && onMoveElement) || (blockedIntent !== "move" && onResizeElement)) ) { blockedClipRef.current = { element: el, intent: blockedIntent, originClientX: e.clientX, originClientY: e.clientY, started: false, }; return; } if (!onMoveElement || !capabilities.canMove) return; blockedClipRef.current = null; setShowPopover(false); setRangeSelection(null); setDraggedClip({ element: el, originClientX: e.clientX, originClientY: e.clientY, originScrollLeft: scrollRef.current?.scrollLeft ?? 0, originScrollTop: scrollRef.current?.scrollTop ?? 0, pointerClientX: e.clientX, pointerClientY: e.clientY, pointerOffsetX: e.clientX - rect.left, pointerOffsetY: e.clientY - rect.top, previewStart: el.start, previewTrack: el.track, snapBeatTime: null, started: false, }); syncClipDragAutoScroll(e.clientX, e.clientY); }} onClick={(e) => { e.stopPropagation(); if (suppressClickRef.current) return; const { activeTool } = usePlayerStore.getState(); if (activeTool === "razor" && onRazorSplit) { const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const clickOffsetX = e.clientX - clipRect.left; const splitTime = previewElement.start + clickOffsetX / pps; const clampedTime = Math.max( previewElement.start + SPLIT_BOUNDARY_EPSILON_S, Math.min( previewElement.start + previewElement.duration - SPLIT_BOUNDARY_EPSILON_S, splitTime, ), ); if (e.shiftKey && onRazorSplitAll) { onRazorSplitAll(clampedTime); } else { onRazorSplit(el, clampedTime); } return; } const nextElement = isSelected ? null : el; setSelectedElementId(nextElement ? elementKey : null); onSelectElement?.(nextElement); }} onDoubleClick={(e) => { e.stopPropagation(); if (suppressClickRef.current) return; if (isComposition && onDrillDown) onDrillDown(el); }} > {renderClipChildren(previewElement, clipStyle)} {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && ( 0 ? ((currentTime - previewElement.start) / previewElement.duration) * 100 : 0 } elementId={elementKey} selectedKeyframes={selectedKeyframes} onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)} onShiftClickKeyframe={onShiftClickKeyframe} onDragKeyframe={(oldPct, newPct) => onDragKeyframe?.(previewElement, oldPct, newPct) } snapPct={(pct) => onSnapKeyframePct?.(previewElement, pct) ?? pct} onPickForDrag={() => onPickKeyframeElement?.(previewElement)} onContextMenuKeyframe={onContextMenuKeyframe} /> )} ); })}
); })} {/* Drag ghost */} {activeDraggedElement && activeDraggedPosition && (
{}} onHoverEnd={() => {}} onResizeStart={() => {}} onClick={() => {}} onDoubleClick={() => {}} > {renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))}
)} {/* Range highlight */} {rangeSelection && (
)} {/* Playhead — hidden while dragging a beat so its guideline doesn't track the scrub and clutter the beat being moved. */}
); });