mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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.
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|