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}
);
})
}
);
})
}
);
}