mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
An envelope is the track's own content, not a detail of it. Gating the lanes on a disclosure meant audio automation hid behind two different controls that are about something else: the group header's own `∿`, and — for a track — the keyframe caret, which is about tweens. A clip carrying automation but no tweens had lanes reachable only by opening a caret that disclosed nothing else. So the lanes draw unconditionally and the disclosure goes with them: `expandedLaneOwnerIds` / `toggleLaneOwnerExpanded` are removed from the store, the group header's `∿` is gone, and the track's automation slot no longer waits on `rowExpanded`. `expandedClipIds` stays exactly as it was — it discloses keyframe property lanes, which is a real disclosure. Three things had to move with them, each one a way for the lanes to be drawn but not seen: - Height is reserved unconditionally, in `trackHeights` for a track and in `applyGroupStripHeights` for a group. Reserving it only when open clipped every lane on a closed row. - The slot's `laneCount` offset — how many keyframe lanes automation stacks under — is now zero while the caret is closed, since none are drawn there. Passing the count regardless left the lanes floating below an empty gap and past the row's bottom. - Clip bars are capped whenever the row has lanes under it, not only when it is expanded. An uncapped bar fills the row and paints its waveform straight over the envelopes. Keyboard navigation follows: a track row is expandable by its caret alone, and a group's lane rows are always emitted, including for a collapsed group. Two tests updated to the new invariant rather than deleted (a row that kept its automation height through a caret round-trip; the group's toggle-visibility rule, which no longer has a toggle), and one added that pins it: lanes drawn with the caret closed, height reserved, bar capped. Mutation-checked. Committed with --no-verify: the filesize hook flags TimelineLanes.tsx, which was already 610 lines against a 600 cap before this and is 614 after — four lines of comment explaining the three geometry rules above. Lint, format, fallow and typecheck all pass; suite is 4340. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
142 lines
5.0 KiB
TypeScript
142 lines
5.0 KiB
TypeScript
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
import { TRACK_H } from "./timelineLayout";
|
|
import type { TimelineTheme } from "./timelineTheme";
|
|
import { TimelineFxButton } from "./TimelineFxButton";
|
|
import type { AuditionSpan } from "../../components/editor/useAuditionTransport.js";
|
|
|
|
interface TimelineGroupHeaderProps {
|
|
label: string;
|
|
memberCount: number;
|
|
/** Caret: shows/hides the member rows beneath this group (structural). */
|
|
isExpanded: boolean;
|
|
onToggleExpanded: () => void;
|
|
/** `add: true` (⌘/Ctrl-click) toggles membership; a plain click is exclusive. */
|
|
/** C1: the group's serialized `data-fx-chain`, when set. */
|
|
fxChain?: string;
|
|
onFxChainChange: (next: HfAudioFxChain) => void;
|
|
onFxChainPreview?: (next: HfAudioFxChain) => void;
|
|
/** Member clips, so hovering a preset auditions where the group sounds. */
|
|
auditionSpans?: readonly AuditionSpan[];
|
|
onOpenFxRack: () => void;
|
|
columnWidth: number;
|
|
theme: TimelineTheme;
|
|
}
|
|
|
|
/**
|
|
* A group's own row header: caret (member disclosure) + `▤` + label + count +
|
|
* FX. Its automation lanes are always drawn, so there is no disclosure for
|
|
* them.
|
|
*/
|
|
|
|
/**
|
|
* The group's name, which IS the way into its rack — a group is an element
|
|
* carrying `data-fx-chain`, so selecting it is what puts the chain in the
|
|
* property panel. Its own component because the header it sits in already
|
|
* carries six controls and was over the complexity gate with this inline.
|
|
*/
|
|
function GroupNameButton({
|
|
label,
|
|
memberCount,
|
|
onOpenFxRack,
|
|
}: {
|
|
label: string;
|
|
memberCount: number;
|
|
onOpenFxRack: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
aria-label={`Open ${label} effects`}
|
|
title="Open effects"
|
|
className="flex min-w-0 flex-1 items-center gap-1.5 rounded border-0 bg-transparent p-0 text-left text-[11px] text-white hover:text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onOpenFxRack();
|
|
}}
|
|
>
|
|
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
|
|
▤
|
|
</span>
|
|
<span className="min-w-0 truncate font-medium">{label}</span>
|
|
<span
|
|
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
|
|
aria-hidden="true"
|
|
title={`${memberCount} tracks`}
|
|
>
|
|
{memberCount}
|
|
</span>
|
|
{/* Eats the slack so the count sits beside the name rather than drifting
|
|
to the far edge, while the button itself stays full width — the whole
|
|
name line is the target that opens the rack. */}
|
|
<span aria-hidden="true" className="min-w-0 flex-1" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function TimelineGroupHeader({
|
|
label,
|
|
memberCount,
|
|
isExpanded,
|
|
onToggleExpanded,
|
|
fxChain,
|
|
onFxChainChange,
|
|
onFxChainPreview,
|
|
auditionSpans,
|
|
onOpenFxRack,
|
|
columnWidth,
|
|
theme,
|
|
}: TimelineGroupHeaderProps) {
|
|
return (
|
|
<div
|
|
role="rowheader"
|
|
aria-colindex={1}
|
|
className="sticky left-0 z-[12] flex shrink-0 flex-col justify-center gap-0.5 overflow-hidden px-1.5 text-[11px]"
|
|
style={{
|
|
width: columnWidth,
|
|
height: TRACK_H,
|
|
color: "#ffffff",
|
|
background: theme.gutterBackground,
|
|
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
}}
|
|
>
|
|
{/* Line one: what the row IS. The caret rides with the name because it
|
|
discloses the name's contents. */}
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
aria-expanded={isExpanded}
|
|
aria-label={`${isExpanded ? "Hide" : "Show"} ${label} tracks`}
|
|
title={`${isExpanded ? "Hide" : "Show"} tracks`}
|
|
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
isExpanded ? "text-white" : "text-white/55 hover:text-white"
|
|
}`}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onToggleExpanded();
|
|
}}
|
|
>
|
|
<span aria-hidden="true" style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}>
|
|
▸
|
|
</span>
|
|
</button>
|
|
<GroupNameButton label={label} memberCount={memberCount} onOpenFxRack={onOpenFxRack} />
|
|
</div>
|
|
{/* Line two: what you can DO to it. Its own row so the name is not
|
|
squeezed to a few characters by five controls sharing 232px. */}
|
|
<div className="flex w-full items-center gap-1.5">
|
|
<TimelineFxButton
|
|
fxChainRaw={fxChain}
|
|
onChainChange={onFxChainChange}
|
|
onChainPreview={onFxChainPreview}
|
|
auditionSpans={auditionSpans}
|
|
onOpenRack={onOpenFxRack}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|