import { Fragment, useId } from "react"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; import { clipTimingStart } from "../../hooks/gsapShared"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; import { CLIP_Y, CLIP_HANDLE_W, TRACK_H } from "./timelineLayout"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { isMultiDragActive, isMultiDragPassenger, multiDragDeltaSeconds, type MultiDragPreviewInput, multiDragPassengerOffsetPx, } from "./timelineMultiDragPreview"; import type { TimelineLaneBaseProps } from "./timelineLaneProps"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { trackStudioKeyframeLaneExpand } from "../../telemetry/events"; import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; import { renderClipChildren } from "./timelineClipChildren"; import { TimelineTrackRow } from "./TimelineTrackRow"; import { isTimelineClipActive } from "./useTimelineActiveClips"; import { queryTimelineClipIndex } from "../lib/timelineClipIndex"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; interface TimelineLanesProps extends TimelineLaneBaseProps { /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ draggedElement: TimelineElement | null; multiDragPreview: MultiDragPreviewInput | null; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; onResizeElement: TimelineEditCallbacks["onResizeElement"]; onMoveElement: TimelineEditCallbacks["onMoveElement"]; onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"]; } export function TimelineLanes({ pps, contentOrigin, contentGutter, trackContentWidth, theme, displayTrackOrder, rowGeometry, virtualRows, rowsVirtualized, clipIndex, renderTimeRange, pinnedClipIdentities, trackOrder, tracks, trackStyles, laneCounts, selectedElementId, selectedElementIds, hoveredClip, draggedClip, draggedElement, multiDragPreview, blockedClipRef, suppressClickRef, scrollRef, renderClipContent, renderClipOverlay, onDrillDown, onSelectElement, setHoveredClip, setShowPopover, setRangeSelection, setResizingClip, setDraggedClip, setSelectedElementId, syncClipDragAutoScroll, shiftClickClipRef, getPreviewElement, getTrackStyle, keyframeCache, gsapAnimations, selectedKeyframes, currentTime, onSeek, onSelectSegment, onClickKeyframe, onShiftClickKeyframe, onContextMenuKeyframe, onMoveKeyframe, onContextMenuClip, onContextMenuLane, beatAnalysis, onToggleTrackHidden, onTogglePropertyGroupKeyframe, onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll, }: TimelineLanesProps) { // Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal // beside the main one) cannot both mint `...-track-0` and have every caret's // aria-controls resolve to whichever mounted first. React's useId embeds // colons, which are legal in an id and in aria-controls but need escaping in // a CSS `#id` selector, so they come out here and the prefix stays plain. const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); const toggleClipExpandedTracked = (key: string) => { const willExpand = !expandedClipIds.has(key); trackStudioKeyframeLaneExpand({ expanded: willExpand }); toggleClipExpanded(key); }; const multiDragDelta = multiDragPreview && isMultiDragActive(multiDragPreview) ? multiDragDeltaSeconds(multiDragPreview) : 0; const actorWindows = rowsVirtualized && multiDragPreview && multiDragDelta !== 0 ? [ { range: { start: renderTimeRange.start - multiDragDelta, end: renderTimeRange.end - multiDragDelta, }, identities: multiDragPreview.selectedKeys, }, ] : []; return (
{ // fallow-ignore-next-line complexity virtualRows.map(({ index: row, rowKey }) => { const trackNum = displayTrackOrder[row]; if (trackNum === undefined) return null; const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum); const rowHeight = rowGeometry.getRowHeight(row); const els = tracks.find(([t]) => t === trackNum)?.[1] ?? []; const renderElements = rowsVirtualized ? queryTimelineClipIndex( clipIndex, trackNum, renderTimeRange, pinnedClipIdentities, actorWindows, ) : els; const ts = trackStyles.get(trackNum) ?? getTrackStyle(""); const isPendingTrack = draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0; // All lanes use the same uniform color — no alternating stripes. const rowBackground = theme.rowBackground; // 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)); const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true); const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement); // The one keyframed element this track shows lanes for (selected, else // most lanes). A track can hold several elements; scoping to one keeps // their keyframes from cramming into a single row. const keyframeClip = resolveTrackKeyframeClip( els, laneCounts, selectedElementId, selectedElementIds, ); const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; const keyframeClipExpanded = keyframeClipKey != null && expandedClipIds.has(keyframeClipKey); // Minted here because this is the only place that sees BOTH ends of // the disclosure: the caret in the sticky header and the diamond lanes // on the canvas. Keyed by display row, not by `trackNum`, which is a // fractional sort key and would mint ids like `...-0.16666666666666666`. const lanesId = `${lanesIdPrefix}-track-${row}`; return ( { if (keyframeClipKey) { toggleClipExpandedTracked(keyframeClipKey); } }} onToggleTrackHidden={onToggleTrackHidden} onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe} onSeek={onSeek} />
{ // Clip / keyframe-diamond context menus preventDefault at the // target before this bubble handler runs — respect them so a // right-click on a clip never also opens the gap menu. if (e.defaultPrevented || !onContextMenuLane) return; const rect = e.currentTarget.getBoundingClientRect(); const time = (e.clientX - rect.left) / pps; if (time < 0) return; e.preventDefault(); onContextMenuLane(e, trackNum, time); }} > {/* 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
)} { // fallow-ignore-next-line complexity renderElements.map((el) => { const clipStyle = getTrackStyle(el.tag); const elementKey = getTimelineElementIdentity(el); // Only the track's active keyframe clip shows expanded lanes; // other clips (incl. siblings on a shared track) show compact // diamonds on their own bar instead. const isTrackKeyframeClip = elementKey === keyframeClipKey; const showsLanes = isTrackKeyframeClip && keyframeClipExpanded; const capabilities = getTimelineEditCapabilities(el); const isSelected = selectedElementId === elementKey || selectedElementIds.has(elementKey); const isComposition = !!el.compositionSrc; // elementKey (el.key ?? el.id) is already unique per clip; do NOT // fold in the map index, or a splice/reorder remounts every clip // at/after the change (DOM flash, drag interruption). const clipKey = elementKey; const isDraggingClip = draggedClip?.started === true && draggedElement != null && getTimelineElementIdentity(draggedElement) === elementKey; if (isDraggingClip) return null; const previewElement = getPreviewElement(el); // Passenger of a live multi-drag: slide by the SAME formation // delta (the grabbed clip's group-clamped delta) via a // compositor transform on a same-geometry wrapper (absolute // inset-0 → identical offset parent, so the clip's own // left/top are preserved), plus the ghost's elevated z/opacity. const isPassenger = multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview); const passengerOffsetPx = isPassenger ? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview) : 0; const clip = ( { e.preventDefault(); onContextMenuClip?.(e, el); }} el={previewElement} pps={pps} clipY={CLIP_Y} clipHeight={showsLanes ? TRACK_H - 2 * CLIP_Y : undefined} isSelected={isSelected} isHovered={hoveredClip === clipKey} isDragging={false} isActive={isTimelineClipActive(previewElement, currentTime)} hasCustomContent={!!renderClipContent} capabilities={capabilities} theme={theme} isComposition={isComposition} onHoverStart={() => setHoveredClip(clipKey)} onHoverEnd={() => setHoveredClip(null)} onResizeStart={ // fallow-ignore-next-line complexity (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, originScrollLeft: scrollRef.current?.scrollLeft ?? 0, previewStart: el.start, previewDuration: el.duration, previewPlaybackStart: el.playbackStart, started: false, }); } } onPointerDown={ // fallow-ignore-next-line complexity (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, desiredTrack: el.track, insertRow: null, snapTime: null, snapType: null, started: false, }); syncClipDragAutoScroll(e.clientX, e.clientY); } } onClick={ // fallow-ignore-next-line complexity (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; } // Plain click single-selects: drop any marquee multi-selection. // Only a click on the PRIMARY selection toggles it off — a click // on a marquee-selected clip narrows the selection to that clip. const hadMultiSelection = selectedElementIds.size > 0; usePlayerStore.getState().clearSelectedElementIds(); const nextElement = selectedElementId === elementKey && !hadMultiSelection ? null : el; setSelectedElementId(nextElement ? elementKey : null); onSelectElement?.(nextElement); } } onDoubleClick={(e) => { e.stopPropagation(); if (suppressClickRef.current) return; if (isComposition && onDrillDown) onDrillDown(el); }} > {renderClipChildren( previewElement, clipStyle, renderClipContent, renderClipOverlay, )} {!showsLanes && keyframeCache?.get(elementKey) && ( 0 ? ((currentTime - previewElement.start) / previewElement.duration) * 100 : 0 } elementId={elementKey} selectedKeyframes={selectedKeyframes} onClickKeyframe={(_elId, target) => onClickKeyframe?.(previewElement, target) } onShiftClickKeyframe={onShiftClickKeyframe} onContextMenuKeyframe={onContextMenuKeyframe} onMoveKeyframe={onMoveKeyframe} onSelectSegment={onSelectSegment} suppressClickRef={suppressClickRef} /> )} ); // Mounted for the track's keyframe clip in BOTH disclosure // states, so the header caret's aria-controls resolves while // collapsed too; collapsed just feeds it no animations, so // the wrapper renders empty. The key is stable across a // multi-drag: without it the passenger branch below remounts // this subtree and interrupts the gesture. const propertyLanes = isTrackKeyframeClip && ( 0 ? ((currentTime - previewElement.start) / previewElement.duration) * 100 : 0 } elementId={elementKey} selectedKeyframes={selectedKeyframes} onSelectSegment={(target) => onSelectSegment?.(elementKey, target)} onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)} onShiftClickKeyframe={(target) => onShiftClickKeyframe?.(elementKey, target) } onContextMenuKeyframe={(e, target) => onContextMenuKeyframe?.(e, elementKey, target) } onMoveKeyframe={(target, toClipPercentage) => onMoveKeyframe?.(elementKey, target, toClipPercentage) ?? Promise.resolve(false) } suppressClickRef={suppressClickRef} /> ); // Keep one keyed top-level child per element. Returning an // array here makes React reconcile the outer array by // position, so a window shift remounts otherwise stable // clip keys and can tear down focus mid-reveal. if (!isPassenger) { return ( {clip} {propertyLanes} ); } return (
{clip} {propertyLanes}
); }) }
); }) }
); }