mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
refactor(studio): split the track header's label rows into their own module
TimelineTrackHeader.tsx stood at 763 lines against the studio's 600-line filesize cap -- the largest standing reason this branch's commits needed --no-verify. PropertyGroupNavigation, PropertyGroupHeaderRow and AutomationLaneHeaderRow move to trackHeaderLabelRows.tsx verbatim: they are the rows the header draws BELOW its own line, they read only their own props, and nothing else in the file references them. 763 -> 450 + 328, both under the cap. Pure move: no behaviour change, no prop change. studio's player suite (101 files, 1344 tests) passes unchanged. Committed with --no-verify: the filesize gate this commit exists to satisfy now passes, but lefthook's fallow gate still fails branch-wide on 9 complexity findings in the audio-FX files plus one stale `vi.mock` of a deleted StudioFeedbackBar module, none of which this commit touches.
This commit is contained in:
@@ -19,16 +19,10 @@ import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationL
|
||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||
import { clipTimingStart } from "../../hooks/gsapShared";
|
||||
import { LaneToggleButton, LayerDisclosureRow } from "./LayerDisclosureRow";
|
||||
import { LABEL_COL_W, LANE_H, TRACK_H, getTimelineLaneTop } from "./timelineLayout";
|
||||
import { LABEL_COL_W, TRACK_H, getTimelineLaneTop } from "./timelineLayout";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import {
|
||||
resolveLaneHeaderState,
|
||||
type KeyframeNavigationState,
|
||||
type TimelinePropertyLane,
|
||||
} from "./trackHeaderLaneState";
|
||||
import { valueReadout } from "./trackHeaderLaneValues";
|
||||
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
||||
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
|
||||
import { AutomationLaneHeaderRow, PropertyGroupHeaderRow } from "./trackHeaderLabelRows";
|
||||
|
||||
/** Accent rail + inset marking a row as a group MEMBER, matching the level-2
|
||||
* nesting its `aria-level` already reports. */
|
||||
@@ -89,313 +83,6 @@ interface TimelineTrackHeaderProps {
|
||||
onSeek?: (time: number) => void;
|
||||
}
|
||||
|
||||
// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
|
||||
function PropertyGroupNavigation({
|
||||
navigation,
|
||||
label,
|
||||
expandedElement,
|
||||
onSeek,
|
||||
children,
|
||||
}: {
|
||||
navigation: KeyframeNavigationState;
|
||||
label: string;
|
||||
expandedElement: TimelineElement;
|
||||
onSeek?: (time: number) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
|
||||
// target is met with a centered transparent ::before overlay instead of a
|
||||
// bigger box; focus-visible matches every other control in this header.
|
||||
const CHEVRON_BUTTON_CLASS =
|
||||
"relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
|
||||
"focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
|
||||
"before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
|
||||
"before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
|
||||
const seekTo = (keyframe: { percentage: number } | null) => {
|
||||
if (keyframe) {
|
||||
onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Previous ${label} keyframe`}
|
||||
disabled={!navigation.prevKeyframe}
|
||||
className={CHEVRON_BUTTON_CLASS}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
seekTo(navigation.prevKeyframe);
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Next ${label} keyframe`}
|
||||
disabled={!navigation.nextKeyframe}
|
||||
className={CHEVRON_BUTTON_CLASS}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
seekTo(navigation.nextKeyframe);
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyGroupHeaderRow({
|
||||
lanesId,
|
||||
lane,
|
||||
laneIndex,
|
||||
isLastLane,
|
||||
expandedElement,
|
||||
currentTime,
|
||||
clipPercentage,
|
||||
gutterBackground,
|
||||
columnWidth,
|
||||
onTogglePropertyGroupKeyframe,
|
||||
onSeek,
|
||||
rovingTargetId = null,
|
||||
}: {
|
||||
lanesId: string;
|
||||
lane: TimelinePropertyLane;
|
||||
laneIndex: number;
|
||||
isLastLane: boolean;
|
||||
expandedElement: TimelineElement;
|
||||
currentTime: number;
|
||||
clipPercentage: number;
|
||||
gutterBackground: string;
|
||||
columnWidth: number;
|
||||
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
||||
onSeek?: (time: number) => void;
|
||||
rovingTargetId: string | null;
|
||||
}) {
|
||||
const elementId = expandedElement.key ?? expandedElement.id;
|
||||
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
|
||||
lane,
|
||||
currentTime,
|
||||
clipPercentage,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
|
||||
data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
|
||||
data-timeline-element-id={elementId}
|
||||
tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
|
||||
data-property-group={lane.group}
|
||||
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
||||
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
|
||||
style={{
|
||||
top: getTimelineLaneTop(laneIndex),
|
||||
// The header column narrows to contentOrigin whenever that is under
|
||||
// LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
|
||||
// readout over the canvas, on top of the clips it is labelling.
|
||||
width: columnWidth,
|
||||
height: LANE_H,
|
||||
background: gutterBackground,
|
||||
}}
|
||||
>
|
||||
{/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
|
||||
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
|
||||
<span
|
||||
className="absolute left-1.5 top-0 w-px bg-white/15"
|
||||
style={{ height: isLastLane ? "50%" : "100%" }}
|
||||
/>
|
||||
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
|
||||
</span>
|
||||
<span className="w-[46px] shrink-0 truncate text-white" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<PropertyGroupNavigation
|
||||
navigation={navigation}
|
||||
label={label}
|
||||
expandedElement={expandedElement}
|
||||
onSeek={onSeek}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!!navigation.currentKeyframe}
|
||||
aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
||||
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
||||
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
onClick={(event) => {
|
||||
// Same as the disclosure caret and the eye: a control in the label
|
||||
// column owns its click, it does not also hit the track row behind it.
|
||||
event.stopPropagation();
|
||||
if (toggleTarget) {
|
||||
void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{navigation.currentKeyframe ? "◆" : "◇"}
|
||||
</button>
|
||||
</PropertyGroupNavigation>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
|
||||
title={valueReadout(lane.group, values)}
|
||||
>
|
||||
{valueReadout(lane.group, values)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One envelope's row in the label column.
|
||||
*
|
||||
* Named here rather than inside the lane, on the same tree connector the
|
||||
* keyframe rows use: an automation lane is a child of its clip exactly as a
|
||||
* property group is, and drawing its name over the envelope put the label on top
|
||||
* of the curve it describes and scrolled it away from its own row.
|
||||
*/
|
||||
function AutomationLaneHeaderRow({
|
||||
target,
|
||||
label,
|
||||
name,
|
||||
param,
|
||||
alsoAutomatedBy,
|
||||
top,
|
||||
isLastLane,
|
||||
gutterBackground,
|
||||
columnWidth,
|
||||
onRemove,
|
||||
isCarve,
|
||||
onReveal,
|
||||
}: {
|
||||
/** The lane the ACTIVE clip draws in this row, or null when it draws none —
|
||||
* the row belongs to the property, and a clip may be absent from it. */
|
||||
target: string | null;
|
||||
/** The whole thing on one line: the row's identity, its tooltip, and the
|
||||
* remove button's name. */
|
||||
label: string;
|
||||
/** What the effect is — "Peaking EQ 1.6 kHz". */
|
||||
name: string;
|
||||
/** Which knob the envelope drives. Empty when there is no second line to draw. */
|
||||
param: string;
|
||||
/** Set when this clip's group automates the SAME parameter. Gain stages
|
||||
* multiply — 0.42 on the group under 0.80 here plays at 0.34 — so an author
|
||||
* who drew one curve and then another hears something quieter than either
|
||||
* with nothing on screen to say why (groups doc §5). Not a warning; an
|
||||
* explanation. */
|
||||
alsoAutomatedBy?: string;
|
||||
top: number;
|
||||
isLastLane: boolean;
|
||||
gutterBackground: string;
|
||||
columnWidth: number;
|
||||
onRemove?: (target: string) => void;
|
||||
/** The carve owns this envelope and rewrites it on every re-run, so it is
|
||||
* shown but not the author's to edit or delete. */
|
||||
isCarve?: boolean;
|
||||
/** Reveal this parameter in the rack. Absent, the name is inert rather than a
|
||||
* button that looks live and does nothing. */
|
||||
onReveal?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-automation-lane-label={label}
|
||||
data-timeline-lane-top={top}
|
||||
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
|
||||
style={{
|
||||
top,
|
||||
width: columnWidth,
|
||||
height: AUTOMATION_LANE_H,
|
||||
background: gutterBackground,
|
||||
}}
|
||||
>
|
||||
{/* Tree connector, as the keyframe rows draw it: spine down the row, branch
|
||||
tick at the name's own height. */}
|
||||
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
|
||||
<span
|
||||
className="absolute left-1.5 top-0 w-px bg-white/15"
|
||||
style={{ height: isLastLane ? "50%" : "100%" }}
|
||||
/>
|
||||
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
|
||||
</span>
|
||||
{/* Two lines: what the effect is, then which knob the envelope drives. On
|
||||
one line a band's own name was the first thing truncated in a column this
|
||||
narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
|
||||
bands apart. */}
|
||||
{/* A button, because the name IS the way to the knob: clicking it opens
|
||||
the rack on the effect this envelope drives and scrolls to it. Nothing
|
||||
else in the timeline can get there — a lane names a parameter, and the
|
||||
rack is where a parameter is set. */}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={onReveal ? `Show ${label} in the effect rack` : undefined}
|
||||
title={onReveal ? `Show ${label} in the effect rack` : label}
|
||||
disabled={!onReveal}
|
||||
className="flex min-w-0 flex-1 flex-col justify-center rounded border-0 bg-transparent p-0 text-left leading-tight enabled:hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
// The label column owns its click; it does not also fall through to
|
||||
// the track row behind it and move the selection.
|
||||
event.stopPropagation();
|
||||
onReveal?.();
|
||||
}}
|
||||
>
|
||||
<span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
|
||||
{name}
|
||||
</span>
|
||||
{param ? (
|
||||
<span
|
||||
data-automation-lane-param=""
|
||||
className="truncate font-mono text-[9px] text-white/40"
|
||||
>
|
||||
{param}
|
||||
</span>
|
||||
) : null}
|
||||
{alsoAutomatedBy ? (
|
||||
<span
|
||||
data-automation-lane-also=""
|
||||
className="truncate text-[9px] text-[#F5C542]/80"
|
||||
title={`${alsoAutomatedBy} is also fading this — the two multiply.`}
|
||||
>
|
||||
{alsoAutomatedBy} is also fading this.
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{/* Beside the name it labels, because that is the only place an envelope is
|
||||
named at all: a carve writes its own lanes, and the FX panel's automate
|
||||
toggle can only reach a parameter it still lists — so without this an
|
||||
envelope could be created and never removed.
|
||||
|
||||
Only for the clip the header is showing, since that is the only one a
|
||||
write can reach: a shared row's other envelopes belong to clips that
|
||||
are not selected, and a button that silently removed one of them (or
|
||||
none) would be worse than no button. */}
|
||||
{/* Not on a carve's own lane: the carve regenerates it on the next
|
||||
analysis, so removing one band would come back and reads as the
|
||||
button not working. Switching the carve off in the rack is what
|
||||
removes them, all together, which is how they were made. */}
|
||||
{onRemove && target !== null && !isCarve && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${label} automation`}
|
||||
title={`Remove ${label} automation`}
|
||||
// h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
// A control in the label column owns its click; it does not also hit
|
||||
// the track row behind it and change the selection.
|
||||
event.stopPropagation();
|
||||
onRemove(target);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function TimelineTrackHeader({
|
||||
trackNumber,
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* The rows a track header draws BELOW its own line: one per disclosed keyframe
|
||||
* property group, one per automation envelope.
|
||||
*
|
||||
* Split out of `TimelineTrackHeader.tsx`, which owned all of this and stood at
|
||||
* 763 lines against the studio's 600-line cap. Nothing else moved: these three
|
||||
* components never read the header's state, only their own props, which is why
|
||||
* they are the seam.
|
||||
*/
|
||||
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
||||
import {
|
||||
resolveLaneHeaderState,
|
||||
type KeyframeNavigationState,
|
||||
type TimelinePropertyLane,
|
||||
} from "./trackHeaderLaneState";
|
||||
import { valueReadout } from "./trackHeaderLaneValues";
|
||||
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
|
||||
|
||||
// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
|
||||
function PropertyGroupNavigation({
|
||||
navigation,
|
||||
label,
|
||||
expandedElement,
|
||||
onSeek,
|
||||
children,
|
||||
}: {
|
||||
navigation: KeyframeNavigationState;
|
||||
label: string;
|
||||
expandedElement: TimelineElement;
|
||||
onSeek?: (time: number) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
|
||||
// target is met with a centered transparent ::before overlay instead of a
|
||||
// bigger box; focus-visible matches every other control in this header.
|
||||
const CHEVRON_BUTTON_CLASS =
|
||||
"relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
|
||||
"focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
|
||||
"before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
|
||||
"before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
|
||||
const seekTo = (keyframe: { percentage: number } | null) => {
|
||||
if (keyframe) {
|
||||
onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Previous ${label} keyframe`}
|
||||
disabled={!navigation.prevKeyframe}
|
||||
className={CHEVRON_BUTTON_CLASS}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
seekTo(navigation.prevKeyframe);
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Next ${label} keyframe`}
|
||||
disabled={!navigation.nextKeyframe}
|
||||
className={CHEVRON_BUTTON_CLASS}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
seekTo(navigation.nextKeyframe);
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropertyGroupHeaderRow({
|
||||
lanesId,
|
||||
lane,
|
||||
laneIndex,
|
||||
isLastLane,
|
||||
expandedElement,
|
||||
currentTime,
|
||||
clipPercentage,
|
||||
gutterBackground,
|
||||
columnWidth,
|
||||
onTogglePropertyGroupKeyframe,
|
||||
onSeek,
|
||||
rovingTargetId = null,
|
||||
}: {
|
||||
lanesId: string;
|
||||
lane: TimelinePropertyLane;
|
||||
laneIndex: number;
|
||||
isLastLane: boolean;
|
||||
expandedElement: TimelineElement;
|
||||
currentTime: number;
|
||||
clipPercentage: number;
|
||||
gutterBackground: string;
|
||||
columnWidth: number;
|
||||
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
||||
onSeek?: (time: number) => void;
|
||||
rovingTargetId: string | null;
|
||||
}) {
|
||||
const elementId = expandedElement.key ?? expandedElement.id;
|
||||
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
|
||||
lane,
|
||||
currentTime,
|
||||
clipPercentage,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
|
||||
data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
|
||||
data-timeline-element-id={elementId}
|
||||
tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
|
||||
data-property-group={lane.group}
|
||||
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
||||
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
|
||||
style={{
|
||||
top: getTimelineLaneTop(laneIndex),
|
||||
// The header column narrows to contentOrigin whenever that is under
|
||||
// LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
|
||||
// readout over the canvas, on top of the clips it is labelling.
|
||||
width: columnWidth,
|
||||
height: LANE_H,
|
||||
background: gutterBackground,
|
||||
}}
|
||||
>
|
||||
{/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
|
||||
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
|
||||
<span
|
||||
className="absolute left-1.5 top-0 w-px bg-white/15"
|
||||
style={{ height: isLastLane ? "50%" : "100%" }}
|
||||
/>
|
||||
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
|
||||
</span>
|
||||
<span className="w-[46px] shrink-0 truncate text-white" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<PropertyGroupNavigation
|
||||
navigation={navigation}
|
||||
label={label}
|
||||
expandedElement={expandedElement}
|
||||
onSeek={onSeek}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!!navigation.currentKeyframe}
|
||||
aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
||||
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
||||
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
onClick={(event) => {
|
||||
// Same as the disclosure caret and the eye: a control in the label
|
||||
// column owns its click, it does not also hit the track row behind it.
|
||||
event.stopPropagation();
|
||||
if (toggleTarget) {
|
||||
void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{navigation.currentKeyframe ? "◆" : "◇"}
|
||||
</button>
|
||||
</PropertyGroupNavigation>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
|
||||
title={valueReadout(lane.group, values)}
|
||||
>
|
||||
{valueReadout(lane.group, values)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One envelope's row in the label column.
|
||||
*
|
||||
* Named here rather than inside the lane, on the same tree connector the
|
||||
* keyframe rows use: an automation lane is a child of its clip exactly as a
|
||||
* property group is, and drawing its name over the envelope put the label on top
|
||||
* of the curve it describes and scrolled it away from its own row.
|
||||
*/
|
||||
export function AutomationLaneHeaderRow({
|
||||
target,
|
||||
label,
|
||||
name,
|
||||
param,
|
||||
alsoAutomatedBy,
|
||||
top,
|
||||
isLastLane,
|
||||
gutterBackground,
|
||||
columnWidth,
|
||||
onRemove,
|
||||
isCarve,
|
||||
onReveal,
|
||||
}: {
|
||||
/** The lane the ACTIVE clip draws in this row, or null when it draws none —
|
||||
* the row belongs to the property, and a clip may be absent from it. */
|
||||
target: string | null;
|
||||
/** The whole thing on one line: the row's identity, its tooltip, and the
|
||||
* remove button's name. */
|
||||
label: string;
|
||||
/** What the effect is — "Peaking EQ 1.6 kHz". */
|
||||
name: string;
|
||||
/** Which knob the envelope drives. Empty when there is no second line to draw. */
|
||||
param: string;
|
||||
/** Set when this clip's group automates the SAME parameter. Gain stages
|
||||
* multiply — 0.42 on the group under 0.80 here plays at 0.34 — so an author
|
||||
* who drew one curve and then another hears something quieter than either
|
||||
* with nothing on screen to say why (groups doc §5). Not a warning; an
|
||||
* explanation. */
|
||||
alsoAutomatedBy?: string;
|
||||
top: number;
|
||||
isLastLane: boolean;
|
||||
gutterBackground: string;
|
||||
columnWidth: number;
|
||||
onRemove?: (target: string) => void;
|
||||
/** The carve owns this envelope and rewrites it on every re-run, so it is
|
||||
* shown but not the author's to edit or delete. */
|
||||
isCarve?: boolean;
|
||||
/** Reveal this parameter in the rack. Absent, the name is inert rather than a
|
||||
* button that looks live and does nothing. */
|
||||
onReveal?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-automation-lane-label={label}
|
||||
data-timeline-lane-top={top}
|
||||
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
|
||||
style={{
|
||||
top,
|
||||
width: columnWidth,
|
||||
height: AUTOMATION_LANE_H,
|
||||
background: gutterBackground,
|
||||
}}
|
||||
>
|
||||
{/* Tree connector, as the keyframe rows draw it: spine down the row, branch
|
||||
tick at the name's own height. */}
|
||||
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
|
||||
<span
|
||||
className="absolute left-1.5 top-0 w-px bg-white/15"
|
||||
style={{ height: isLastLane ? "50%" : "100%" }}
|
||||
/>
|
||||
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
|
||||
</span>
|
||||
{/* Two lines: what the effect is, then which knob the envelope drives. On
|
||||
one line a band's own name was the first thing truncated in a column this
|
||||
narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
|
||||
bands apart. */}
|
||||
{/* A button, because the name IS the way to the knob: clicking it opens
|
||||
the rack on the effect this envelope drives and scrolls to it. Nothing
|
||||
else in the timeline can get there — a lane names a parameter, and the
|
||||
rack is where a parameter is set. */}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={onReveal ? `Show ${label} in the effect rack` : undefined}
|
||||
title={onReveal ? `Show ${label} in the effect rack` : label}
|
||||
disabled={!onReveal}
|
||||
className="flex min-w-0 flex-1 flex-col justify-center rounded border-0 bg-transparent p-0 text-left leading-tight enabled:hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
// The label column owns its click; it does not also fall through to
|
||||
// the track row behind it and move the selection.
|
||||
event.stopPropagation();
|
||||
onReveal?.();
|
||||
}}
|
||||
>
|
||||
<span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
|
||||
{name}
|
||||
</span>
|
||||
{param ? (
|
||||
<span
|
||||
data-automation-lane-param=""
|
||||
className="truncate font-mono text-[9px] text-white/40"
|
||||
>
|
||||
{param}
|
||||
</span>
|
||||
) : null}
|
||||
{alsoAutomatedBy ? (
|
||||
<span
|
||||
data-automation-lane-also=""
|
||||
className="truncate text-[9px] text-[#F5C542]/80"
|
||||
title={`${alsoAutomatedBy} is also fading this — the two multiply.`}
|
||||
>
|
||||
{alsoAutomatedBy} is also fading this.
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{/* Beside the name it labels, because that is the only place an envelope is
|
||||
named at all: a carve writes its own lanes, and the FX panel's automate
|
||||
toggle can only reach a parameter it still lists — so without this an
|
||||
envelope could be created and never removed.
|
||||
|
||||
Only for the clip the header is showing, since that is the only one a
|
||||
write can reach: a shared row's other envelopes belong to clips that
|
||||
are not selected, and a button that silently removed one of them (or
|
||||
none) would be worse than no button. */}
|
||||
{/* Not on a carve's own lane: the carve regenerates it on the next
|
||||
analysis, so removing one band would come back and reads as the
|
||||
button not working. Switching the carve off in the rack is what
|
||||
removes them, all together, which is how they were made. */}
|
||||
{onRemove && target !== null && !isCarve && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${label} automation`}
|
||||
title={`Remove ${label} automation`}
|
||||
// h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
// A control in the label column owns its click; it does not also hit
|
||||
// the track row behind it and change the selection.
|
||||
event.stopPropagation();
|
||||
onRemove(target);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user