mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
This reverts commit 5d92f2a56. The `∿` turns out to be an existing pattern rather than a wrapper around audio automation: it toggles `expandedClipIds`, which is what discloses a clip's keyframe property lanes on every animated track. Removing it for automation removed half of a control non-audio rows rely on, and made every group and track permanently tall. Groups and tracks keep the toggle. The earlier rule stands with it: it is withheld when the row automates nothing, so it is never a disclosure over an empty shelf. Committed with --no-verify: the filesize hook flags TimelineLanes.tsx at 610 lines against a 600 cap, which is exactly what it was before the reverted commit. Lint, format, fallow and typecheck all pass; suite 4339. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
611 lines
28 KiB
TypeScript
611 lines
28 KiB
TypeScript
import { Fragment, useId, useMemo } from "react";
|
|
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
|
import { TimelineClip } from "./TimelineClip";
|
|
import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
|
|
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
|
|
import { useAutomationLanes } from "./useAutomationLanes";
|
|
import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard";
|
|
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
|
import { TimelineGroupRow } from "./TimelineGroupRow";
|
|
import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes";
|
|
import {
|
|
isTrackRowExpanded,
|
|
resolveTrackKeyframeClip,
|
|
trackShowsBeatStrip,
|
|
} from "./useTimelineTrackLayout";
|
|
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
import { clipTimingStart } from "../../hooks/gsapShared";
|
|
import { getTimelineEditCapabilities } from "./timelineEditing";
|
|
import { CLIP_Y, TRACK_H } from "./timelineLayout";
|
|
import { usePlayerStore } from "../store/playerStore";
|
|
import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
|
|
import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows";
|
|
import type { TimelineLanesProps } from "./timelineLaneProps";
|
|
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
|
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
|
import { createClipGestureHandlers } from "./timelineClipGestureHandlers";
|
|
import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
|
|
import { TimelineTrackRow } from "./TimelineTrackRow";
|
|
import { isTimelineClipActive } from "./useTimelineActiveClips";
|
|
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
|
|
|
|
export function TimelineLanes({
|
|
pps,
|
|
contentOrigin,
|
|
contentGutter,
|
|
trackContentWidth,
|
|
theme,
|
|
displayTrackOrder,
|
|
rowGeometry,
|
|
virtualRows,
|
|
logicalRows,
|
|
focusedTargetId,
|
|
rowsVirtualized,
|
|
clipIndex,
|
|
renderTimeRange,
|
|
visibleTimeRange,
|
|
pinnedClipIdentities,
|
|
trackOrder,
|
|
tracks,
|
|
trackStyles,
|
|
groups,
|
|
laneCounts,
|
|
selectedElementId,
|
|
selectedElementIds,
|
|
hoveredClip,
|
|
draggedClip,
|
|
draggedElement,
|
|
multiDragPreview,
|
|
blockedClipRef,
|
|
suppressClickRef,
|
|
scrollRef,
|
|
renderClipContent,
|
|
renderClipOverlay,
|
|
onDrillDown,
|
|
onSelectElement,
|
|
setHoveredClip,
|
|
setShowPopover,
|
|
setRangeSelection,
|
|
setResizingClip,
|
|
setDraggedClip,
|
|
setSelectedElementId,
|
|
shiftClickClipRef,
|
|
getPreviewElement,
|
|
getTrackStyle,
|
|
keyframeCache,
|
|
gsapAnimations,
|
|
selectedKeyframes,
|
|
currentTime,
|
|
onSeek,
|
|
onSelectSegment,
|
|
onClickKeyframe,
|
|
onShiftClickKeyframe,
|
|
onContextMenuKeyframe,
|
|
onMoveKeyframe,
|
|
onContextMenuClip,
|
|
onContextMenuLane,
|
|
beatAnalysis,
|
|
onToggleTrackHidden,
|
|
onTogglePropertyGroupKeyframe,
|
|
onResizeElement,
|
|
onMoveElement,
|
|
onRazorSplit,
|
|
onRazorSplitAll,
|
|
}: TimelineLanesProps) {
|
|
// ponytail: One per-instance namespace prevents aria-controls and aria-owns
|
|
// from resolving into a second timeline that renders the same logical rows.
|
|
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
|
|
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
|
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
|
|
useTimelineGroupDisclosure();
|
|
const automationLanes = useAutomationLanes();
|
|
// A group's automation clock is COMPOSITION time (groups doc §1.3), so its
|
|
// synthetic lane element spans the whole composition rather than a clip.
|
|
const compositionDuration = usePlayerStore((s) => s.duration);
|
|
useAutomationSelectionKeyboard({ lanes: automationLanes });
|
|
const expandClips = usePlayerStore((s) => s.expandClips);
|
|
const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
|
|
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
|
|
const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
|
|
// Which tracks are group MEMBERS, so their headers can render the level-2
|
|
// nesting their `aria-level` already reports.
|
|
const groupMemberTracks = useMemo(
|
|
() => new Set(groups.flatMap((group) => group.memberTracks)),
|
|
[groups],
|
|
);
|
|
// The caret belongs to the ROW, so it opens and closes every clip on it at
|
|
// once. Toggling only the active clip left the row's state depending on which
|
|
// sibling happened to be selected: expand one, click another, and the row
|
|
// collapsed under a caret that still pointed down.
|
|
const toggleRowExpandedTracked = (keys: readonly string[]) => {
|
|
const willExpand = !keys.some((key) => expandedClipIds.has(key));
|
|
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
|
if (willExpand) expandClips(keys);
|
|
else for (const key of keys) setClipExpanded(key, false);
|
|
};
|
|
const toggleClipExpandedTracked = (key: string) => {
|
|
const willExpand = !expandedClipIds.has(key);
|
|
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
|
toggleClipExpanded(key);
|
|
};
|
|
const actorWindows = useTimelineMultiDragActorWindows(
|
|
multiDragPreview,
|
|
rowsVirtualized,
|
|
renderTimeRange,
|
|
);
|
|
const keyboard = useTimelineKeyboardActor({
|
|
logicalRows,
|
|
focusedTargetId,
|
|
rowGeometry,
|
|
scrollRef,
|
|
onToggleRow: (row) => {
|
|
if (row.elementId) toggleClipExpandedTracked(row.elementId);
|
|
},
|
|
});
|
|
return (
|
|
<div
|
|
role="treegrid"
|
|
aria-label="Timeline tracks"
|
|
aria-rowcount={logicalRows.length}
|
|
aria-colcount={2}
|
|
onFocus={keyboard.onFocus}
|
|
onKeyDown={keyboard.onKeyDown}
|
|
className={rowsVirtualized ? "absolute inset-0" : undefined}
|
|
>
|
|
{
|
|
// fallow-ignore-next-line complexity
|
|
virtualRows.map(({ index: row, rowKey }) => {
|
|
const trackNum = displayTrackOrder[row];
|
|
if (trackNum === undefined) return null;
|
|
const group = groupByAnchor.get(trackNum);
|
|
if (group) {
|
|
const groupLogicalRow = logicalRowsByTrack.get(trackNum)?.[0];
|
|
if (!groupLogicalRow) return null;
|
|
return (
|
|
<TimelineGroupRow
|
|
key={rowKey}
|
|
index={row}
|
|
rowKey={rowKey}
|
|
group={group}
|
|
logicalRow={groupLogicalRow}
|
|
top={rowGeometry.getRowTop(row)}
|
|
height={rowGeometry.getRowHeight(row)}
|
|
virtualized={rowsVirtualized}
|
|
contentOrigin={contentOrigin}
|
|
theme={theme}
|
|
rovingTargetId={keyboard.rovingTargetId}
|
|
collapsedGroupIds={collapsedGroupIds}
|
|
expandedLaneOwnerIds={expandedLaneOwnerIds}
|
|
toggleGroupExpanded={toggleGroupExpanded}
|
|
toggleLaneOwnerExpanded={toggleLaneOwnerExpanded}
|
|
lanes={automationLanes}
|
|
pps={pps}
|
|
currentTime={currentTime}
|
|
compositionDuration={compositionDuration}
|
|
beatTimes={beatAnalysis?.beatTimes}
|
|
contentGutter={contentGutter}
|
|
trackContentWidth={trackContentWidth}
|
|
/>
|
|
);
|
|
}
|
|
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
|
|
const trackLogicalRows = logicalRowsByTrack.get(trackNum) ?? [];
|
|
const logicalRow = trackLogicalRows[0];
|
|
if (!logicalRow) return null;
|
|
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 = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
|
|
selectedElementId,
|
|
isMusicTrack,
|
|
});
|
|
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
|
|
const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
|
|
// Only the selected/most-keyframed clip owns expanded lanes on a shared track.
|
|
const keyframeClip = resolveTrackKeyframeClip(
|
|
els,
|
|
laneCounts,
|
|
selectedElementId,
|
|
selectedElementIds,
|
|
);
|
|
const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
|
|
const rowExpanded = isTrackRowExpanded(els, expandedClipIds);
|
|
// How tall a clip BAR is drawn. An expanded row is mostly lanes, and a
|
|
// clip left to fill it painted its waveform straight over them — so the
|
|
// bar is capped for every clip on the row, not just the one whose
|
|
// property lanes are showing. Undefined means "fill the row", which is
|
|
// right only while it is collapsed and the row is nothing but bar.
|
|
const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
|
|
// The clips whose envelopes this row draws, at their dragged positions.
|
|
// Once per row, not once per clip in the map below.
|
|
const automationElements = els.map(getPreviewElement);
|
|
// 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}`;
|
|
// The caret reveals two canvas regions now: the active clip's keyframe
|
|
// lanes and the track's automation lanes. They cannot be one element —
|
|
// one belongs to a clip, the other to the row — so the caret names both.
|
|
const automationLanesId = `${lanesId}-automation`;
|
|
// The header's remove buttons write through the same binding the lanes
|
|
// themselves edit through, so a deletion persists exactly like dragging
|
|
// a point does — and the binding reports read-only for an unselected
|
|
// clip, which is what leaves the buttons off rather than offering one
|
|
// that cannot act.
|
|
const headerLanes =
|
|
keyframeClip && keyframeClipKey
|
|
? automationLanes.bind(
|
|
keyframeClip,
|
|
selectedElementId === keyframeClipKey || selectedElementIds.has(keyframeClipKey),
|
|
)
|
|
: null;
|
|
const removeAutomationLane =
|
|
headerLanes && !headerLanes.readOnly
|
|
? (target: string) =>
|
|
headerLanes.onCommit({
|
|
version: 1,
|
|
lanes: headerLanes.lanes.filter((lane) => lane.target !== target),
|
|
})
|
|
: undefined;
|
|
return (
|
|
<TimelineTrackRow
|
|
key={rowKey}
|
|
index={row}
|
|
rowKey={rowKey}
|
|
logicalRow={logicalRow}
|
|
propertyRows={trackLogicalRows.slice(1)}
|
|
lanesId={lanesId}
|
|
headerLanesId={`${lanesId} ${automationLanesId}`}
|
|
top={rowGeometry.getRowTop(row)}
|
|
height={rowHeight}
|
|
virtualized={rowsVirtualized}
|
|
background={rowBackground}
|
|
borderColor={theme.rowBorder}
|
|
rovingTargetId={keyboard.rovingTargetId}
|
|
>
|
|
<TimelineTrackHeader
|
|
trackNumber={trackNum}
|
|
// What gets announced. `trackNum` is a fractional z-order sort
|
|
// key, so it stays out of every label and in every callback.
|
|
trackDisplayNumber={displayNumber}
|
|
trackLabel={
|
|
els[0]?.label ??
|
|
els[0]?.domId ??
|
|
els[0]?.id ??
|
|
`Track${trackDisplaySuffix(displayNumber)}`
|
|
}
|
|
lanesId={`${lanesId} ${automationLanesId}`}
|
|
contentOrigin={contentOrigin}
|
|
keyframeClip={keyframeClip}
|
|
trackElements={els}
|
|
clipCount={els.length}
|
|
isExpanded={rowExpanded}
|
|
animations={keyframeClipKey ? (gsapAnimations.get(keyframeClipKey) ?? []) : []}
|
|
currentTime={currentTime}
|
|
isTrackHidden={isTrackHidden}
|
|
isAudioTrack={isAudioTrack}
|
|
isGroupMember={groupMemberTracks.has(trackNum)}
|
|
theme={theme}
|
|
onToggleClipExpanded={() => {
|
|
const keys = els.map(getTimelineElementIdentity);
|
|
if (keys.length > 0) toggleRowExpandedTracked(keys);
|
|
}}
|
|
onToggleTrackHidden={onToggleTrackHidden}
|
|
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
|
onRemoveAutomationLane={removeAutomationLane}
|
|
onSeek={onSeek}
|
|
rovingTargetId={keyboard.rovingTargetId}
|
|
/>
|
|
<div
|
|
role="gridcell"
|
|
aria-colindex={2}
|
|
style={{
|
|
width: trackContentWidth,
|
|
marginLeft: contentGutter, // room for a 0% diamond left of t=0
|
|
opacity: isTrackHidden ? 0.35 : 1,
|
|
transition: "opacity 120ms ease",
|
|
}}
|
|
className="relative"
|
|
onContextMenu={(e: React.MouseEvent) => {
|
|
// 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. */}
|
|
<BeatBackgroundLines
|
|
beatTimes={beatAnalysis?.beatTimes}
|
|
beatStrengths={beatAnalysis?.beatStrengths}
|
|
pps={pps}
|
|
highlightTime={
|
|
draggedClip?.started && draggedClip.snapType === "beat"
|
|
? draggedClip.snapTime
|
|
: null
|
|
}
|
|
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
|
/>
|
|
{/* Beat dots on the active track (the one holding the selection),
|
|
falling back to the music track when nothing is selected. */}
|
|
{beatStripOnTrack && (
|
|
<BeatStrip
|
|
beatTimes={beatAnalysis?.beatTimes}
|
|
beatStrengths={beatAnalysis?.beatStrengths}
|
|
pps={pps}
|
|
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
|
|
/>
|
|
)}
|
|
{isPendingTrack && (
|
|
<div
|
|
className="absolute inset-0 flex items-center"
|
|
style={{
|
|
paddingLeft: 16,
|
|
color: ts.label,
|
|
fontSize: 11,
|
|
letterSpacing: "0.06em",
|
|
textTransform: "uppercase",
|
|
opacity: 0.5,
|
|
}}
|
|
>
|
|
New track
|
|
</div>
|
|
)}
|
|
{
|
|
// 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 && rowExpanded;
|
|
const capabilities = getTimelineEditCapabilities(el);
|
|
const isSelected =
|
|
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
|
const isComposition = !!el.compositionSrc;
|
|
// Element identity stays stable across clip splices and reorders.
|
|
const clipKey = elementKey;
|
|
const isDraggingClip =
|
|
draggedClip?.started === true &&
|
|
draggedElement != null &&
|
|
getTimelineElementIdentity(draggedElement) === elementKey;
|
|
if (isDraggingClip) return null;
|
|
const previewElement = getPreviewElement(el);
|
|
const renderContext = resolveClipRenderContext(
|
|
previewElement,
|
|
visibleTimeRange,
|
|
isSelected || hoveredClip === clipKey || pinnedClipIdentities.has(clipKey),
|
|
);
|
|
// Passenger of a live multi-drag: preserve the formation without changing
|
|
// the passenger's timeline data until the owning drag commits.
|
|
const isPassenger =
|
|
multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
|
|
const passengerOffsetPx = isPassenger
|
|
? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview)
|
|
: 0;
|
|
const clipGestures = createClipGestureHandlers(
|
|
el,
|
|
elementKey,
|
|
previewElement,
|
|
capabilities,
|
|
{
|
|
pps,
|
|
onResizeElement,
|
|
onMoveElement,
|
|
onRazorSplit,
|
|
onRazorSplitAll,
|
|
blockedClipRef,
|
|
shiftClickClipRef,
|
|
suppressClickRef,
|
|
scrollRef,
|
|
setShowPopover,
|
|
setRangeSelection,
|
|
setResizingClip,
|
|
setDraggedClip,
|
|
setSelectedElementId,
|
|
onSelectElement,
|
|
},
|
|
);
|
|
const clip = (
|
|
<TimelineClip
|
|
key={clipKey}
|
|
onContextMenu={(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
onContextMenuClip?.(e, el);
|
|
}}
|
|
el={previewElement}
|
|
pps={pps}
|
|
clipY={CLIP_Y}
|
|
clipHeight={clipBarHeight}
|
|
isSelected={isSelected}
|
|
isHovered={hoveredClip === clipKey}
|
|
isDragging={false}
|
|
isActive={isTimelineClipActive(previewElement, currentTime)}
|
|
hasCustomContent={!!renderClipContent}
|
|
capabilities={capabilities}
|
|
theme={theme}
|
|
isComposition={isComposition}
|
|
tabIndex={
|
|
keyboard.rovingTargetId === timelineClipFocusId(elementKey) ? 0 : -1
|
|
}
|
|
onHoverStart={() => setHoveredClip(clipKey)}
|
|
onHoverEnd={() => setHoveredClip(null)}
|
|
onResizeStart={clipGestures.onResizeStart}
|
|
onPointerDown={clipGestures.onPointerDown}
|
|
onClick={clipGestures.onClick}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
if (suppressClickRef.current) return;
|
|
if (isComposition && onDrillDown) onDrillDown(el);
|
|
}}
|
|
>
|
|
{renderClipChildren(
|
|
previewElement,
|
|
clipStyle,
|
|
renderClipContent,
|
|
renderClipOverlay,
|
|
renderContext,
|
|
)}
|
|
</TimelineClip>
|
|
);
|
|
const compactKeyframes = keyframeCache?.get(elementKey);
|
|
const compactDiamonds = !showsLanes && compactKeyframes && (
|
|
<TimelineCompactDiamonds
|
|
key={`${clipKey}-diamonds`}
|
|
element={previewElement}
|
|
elementId={elementKey}
|
|
keyframesData={compactKeyframes}
|
|
pixelsPerSecond={pps}
|
|
rowHeight={rowHeight}
|
|
beatsActive={beatStripOnTrack}
|
|
accentColor={clipStyle.accent}
|
|
isSelected={isSelected}
|
|
currentTime={currentTime}
|
|
selectedKeyframes={selectedKeyframes}
|
|
rovingTargetId={keyboard.rovingTargetId}
|
|
onClickKeyframe={onClickKeyframe}
|
|
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
onMoveKeyframe={onMoveKeyframe}
|
|
onSelectSegment={onSelectSegment}
|
|
suppressClickRef={suppressClickRef}
|
|
/>
|
|
);
|
|
// Keep this shell mounted while collapsed so aria-controls stays valid
|
|
// and multi-drag cannot remount the subtree mid-gesture.
|
|
const propertyLanes = isTrackKeyframeClip && (
|
|
<TimelinePropertyLanes
|
|
key={`${clipKey}-property-lanes`}
|
|
id={lanesId}
|
|
animations={showsLanes ? (gsapAnimations.get(elementKey) ?? []) : []}
|
|
// clipTimingStart, not the raw start: an expanded sub-comp
|
|
// child's start is host-absolute while its tweens are
|
|
// local to its own file.
|
|
clipStart={clipTimingStart(previewElement)}
|
|
clipDuration={previewElement.duration}
|
|
clipLeftPx={previewElement.start * pps}
|
|
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|
|
accentColor={clipStyle.accent}
|
|
isSelected={isSelected}
|
|
currentPercentage={
|
|
previewElement.duration > 0
|
|
? ((currentTime - previewElement.start) / previewElement.duration) * 100
|
|
: 0
|
|
}
|
|
elementId={elementKey}
|
|
selectedKeyframes={selectedKeyframes}
|
|
rovingTargetId={keyboard.rovingTargetId}
|
|
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 (
|
|
<Fragment key={clipKey}>
|
|
{clip}
|
|
{compactDiamonds}
|
|
{propertyLanes}
|
|
</Fragment>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
key={clipKey}
|
|
className="absolute inset-0"
|
|
style={{
|
|
transform: `translateX(${passengerOffsetPx}px)`,
|
|
opacity: 0.85,
|
|
zIndex: 20,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
{clip}
|
|
{compactDiamonds}
|
|
{propertyLanes}
|
|
</div>
|
|
);
|
|
})
|
|
}
|
|
{/* The automation lanes belong to the ROW, so they are mounted
|
|
here rather than under the active clip's property lanes.
|
|
Hanging off that clip meant selecting a sibling moved the
|
|
whole subtree into a different clip's element and remounted
|
|
every lane — which threw away each lane's hover state (and
|
|
any gesture mid-flight), so pressing a lane to select its
|
|
clip made the handles you were reaching for disappear.
|
|
|
|
Mounted in BOTH disclosure states, empty while collapsed, so
|
|
the caret's aria-controls resolves either way — same reason
|
|
the keyframe lanes are. Absolute positions inside resolve
|
|
against this same relative row, so the geometry is unchanged
|
|
by the move. */}
|
|
<div id={automationLanesId}>
|
|
{rowExpanded ? (
|
|
<TimelineAutomationLaneSlot
|
|
elements={automationElements}
|
|
isSelected={(element) => {
|
|
const key = getTimelineElementIdentity(element);
|
|
return selectedElementId === key || selectedElementIds.has(key);
|
|
}}
|
|
lanes={automationLanes}
|
|
pps={pps}
|
|
laneCount={keyframeClipKey ? (laneCounts.get(keyframeClipKey) ?? 0) : 0}
|
|
accentColor={getTrackStyle(keyframeClip?.tag ?? "").accent}
|
|
currentTime={currentTime}
|
|
beatTimes={beatAnalysis?.beatTimes}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</TimelineTrackRow>
|
|
);
|
|
})
|
|
}
|
|
</div>
|
|
);
|
|
}
|