Files
hyperframes/packages/studio/src/player/components/TimelineTrackHeader.tsx
T
Miguel Angel Simon Sierra 8470b88aa1 fix(studio): settle boundary retimes, delete every keyframed tween, tighten the test locks
Review follow-ups on the expanded keyframe lanes.

Writer:
- `onMoveKeyframe`'s flat-tween boundary branch answered `true` the moment it
  dispatched update-meta, so a rejected write left the diamond parked at its drop
  position. `observeGsapMutation` now resolves to whether the mutation landed and
  the boundary branch returns it, matching the other branches.
- "Delete All Keyframes" cleared only the first keyframed tween on the layer, so
  a layer with position AND opacity keyframes kept half of them. It now walks
  every keyframed tween, serially, through the clicked element's selection.
- The post-convert lookup in `commitFlatViaKeyframes` matched by target selector,
  which picks an arbitrary tween when a target carries several. Match by id first.

Interaction and a11y:
- A rejected retime whose commit settled after a newer drag reverted the
  selection to its own source keyframe, undoing a retime the user could see. The
  revert now only runs while it is still the lane's latest gesture.
- Diamonds key on the authored identity instead of index plus rendered clip-%, so
  a neighbour's retime no longer remounts the button mid-drag.
- The disclosure caret gets `aria-controls` on an always-mounted lanes container,
  and both it and the property-group toggle grow to the 24x24 WCAG 2.2 minimum.
- `LayerDisclosureRow` takes the same adaptive `columnWidth` as its sibling lane
  rows instead of hardcoding LABEL_COL_W over the canvas.

Test locks:
- The timeline callbacks harness resolves a DISTINCT selection per element, so
  the clicked-element writes are actually pinned; three assertions that passed
  either way now name the clicked element's selection.
- New: null-selection aborts every mutation, delete-all covers both tweens, a
  rejected boundary retime reports `false`, and a stale revert leaves selection.
- The playhead-percentage assertion checks 25, not `expect.any(Number)` (which
  also accepts NaN); ease segments assert their label ORDER, not just that the
  three curves differ; the collapsed-diamond callback asserts the whole target.
- Dropped a duplicate `selection override` describe left by a rebase.
2026-07-28 01:19:05 +02:00

364 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Eye, EyeSlash } from "@phosphor-icons/react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { Music } from "../../icons/SystemIcons";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { LayerDisclosureRow } from "./LayerDisclosureRow";
import { TrackClipCount } from "./TrackClipCount";
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
import {
resolveLaneHeaderState,
type KeyframeNavigationState,
type TimelinePropertyLane,
} from "./trackHeaderLaneState";
import { valueReadout } from "./trackHeaderLaneValues";
interface TimelineTrackHeaderProps {
trackNumber: number;
trackLabel: string;
contentOrigin: number;
/** The track's active keyframe clip (selected, else primary) — the one whose
* disclosure + property rows this header shows, whether expanded or not. */
keyframeClip: TimelineElement | null;
/** Clips on this track, so the header can say how many the row holds. */
clipCount: number;
isExpanded: boolean;
animations: readonly GsapAnimation[];
currentTime: number;
isTrackHidden: boolean;
isAudioTrack: boolean;
theme: TimelineTheme;
onToggleClipExpanded: () => void;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onSeek?: (time: number) => void;
}
function VisibilityButton({
hidden,
trackNumber,
visible,
onToggle,
}: {
hidden: boolean;
trackNumber: number;
visible: boolean;
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
}) {
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
const label = hidden ? `Show track ${trackNumber}` : `Hide track ${trackNumber}`;
return (
<button
type="button"
aria-label={label}
title={label}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
void onToggle?.(trackNumber, !hidden);
}}
>
{hidden ? (
<EyeSlash size={14} weight="bold" aria-hidden="true" />
) : (
<Eye size={14} weight="bold" aria-hidden="true" />
)}
</button>
);
}
// The header a track gets when it has no keyframe clip to disclose: label, clip
// count, eye. Not deprecated — it is the live path for every track without lanes.
function PlainTrackHeader({
trackNumber,
trackLabel,
clipCount,
showTrackLabel,
isTrackHidden,
isAudioTrack,
onToggleTrackHidden,
}: Pick<
TimelineTrackHeaderProps,
| "trackNumber"
| "trackLabel"
| "clipCount"
| "isTrackHidden"
| "isAudioTrack"
| "onToggleTrackHidden"
> & { showTrackLabel: boolean }) {
return (
<>
{isAudioTrack && (
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
)}
{showTrackLabel && (
<span className="min-w-0 flex-1 truncate text-[11px]" title={trackLabel}>
{trackLabel}
</span>
)}
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
<VisibilityButton
hidden={isTrackHidden}
trackNumber={trackNumber}
visible
onToggle={onToggleTrackHidden}
/>
</>
);
}
// 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({
lane,
laneIndex,
isLastLane,
expandedElement,
currentTime,
clipPercentage,
gutterBackground,
columnWidth,
onTogglePropertyGroupKeyframe,
onSeek,
}: {
lane: TimelinePropertyLane;
laneIndex: number;
isLastLane: boolean;
expandedElement: TimelineElement;
currentTime: number;
clipPercentage: number;
gutterBackground: string;
columnWidth: number;
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onSeek?: (time: number) => void;
}) {
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
lane,
currentTime,
clipPercentage,
);
return (
<div
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>
);
}
export function TimelineTrackHeader({
trackNumber,
trackLabel,
contentOrigin,
keyframeClip,
clipCount,
isExpanded,
animations,
currentTime,
isTrackHidden,
isAudioTrack,
theme,
onToggleClipExpanded,
onToggleTrackHidden,
onTogglePropertyGroupKeyframe,
onSeek,
}: TimelineTrackHeaderProps) {
const clipPercentage = keyframeClip
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
: 0;
const lanes = keyframeClip
? getTimelinePropertyLanes(animations, keyframeClip.start, keyframeClip.duration)
: [];
// Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
// owns the gutter past it, so a 0% diamond isn't clipped by this panel).
const showTrackLabel = contentOrigin >= LABEL_COL_W;
const isKeyframeLayer = !!keyframeClip && lanes.length > 0;
const lanesId = `timeline-lanes-track-${trackNumber}`;
return (
<div
className={`sticky left-0 z-[12] shrink-0 ${
!isKeyframeLayer
? showTrackLabel
? "flex items-center gap-1 px-1.5 text-white/55"
: "flex flex-col items-center justify-center gap-0.5"
: ""
}`}
style={{
width: showTrackLabel ? LABEL_COL_W : contentOrigin,
background: theme.gutterBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
}}
>
{!keyframeClip || lanes.length === 0 ? (
<PlainTrackHeader
trackNumber={trackNumber}
trackLabel={trackLabel}
clipCount={clipCount}
showTrackLabel={showTrackLabel}
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
onToggleTrackHidden={onToggleTrackHidden}
/>
) : (
<>
<LayerDisclosureRow
keyframeClip={keyframeClip}
clipCount={clipCount}
isExpanded={isExpanded}
gutterBackground={theme.gutterBackground}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
lanesId={lanesId}
onToggleClipExpanded={onToggleClipExpanded}
>
{/* The eye belongs to the LAYER, so it lives on the always-mounted
layer row exactly like a plain track's. Hanging it off a lane row
(hover-gated, and only while expanded) left a keyframed track with
no way to be hidden at all by keyboard, and put the control on a
row it does not act on. */}
<VisibilityButton
hidden={isTrackHidden}
trackNumber={trackNumber}
visible
onToggle={onToggleTrackHidden}
/>
</LayerDisclosureRow>
{/* Always mounted so the caret's aria-controls resolves in both states. */}
<div id={lanesId}>
{isExpanded &&
lanes.map((lane, laneIndex) => (
<PropertyGroupHeaderRow
key={lane.group}
lane={lane}
laneIndex={laneIndex}
isLastLane={laneIndex === lanes.length - 1}
expandedElement={keyframeClip}
currentTime={currentTime}
clipPercentage={clipPercentage}
gutterBackground={theme.gutterBackground}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
onSeek={onSeek}
/>
))}
</div>
</>
)}
</div>
);
}