Skip to content

Commit ca933b4

Browse files
committed
refactor(studio): split the track header's lane math out of its JSX
The header file owned value sampling, readout formatting, lane-state resolution and the JSX at once, so a formatting change and a layout change edited the same file. Sampling and formatting now live in trackHeaderLaneValues, lane-state resolution in trackHeaderLaneState, and resolveLaneHeaderState returns only the four fields its caller reads. Also shows the track's clip count next to the track identity, which the header promised but never rendered.
1 parent 191786b commit ca933b4

6 files changed

Lines changed: 287 additions & 227 deletions

File tree

packages/studio/src/player/components/LayerDisclosureRow.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { CaretRight } from "@phosphor-icons/react";
22
import type { TimelineElement } from "../store/playerStore";
33
import { LABEL_COL_W, TRACK_H } from "./timelineLayout";
4+
import { TrackClipCount } from "./TrackClipCount";
45

56
// Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives
67
// here, not on the clip bar, and re-expands a collapsed layer.
78
export function LayerDisclosureRow({
89
keyframeClip,
10+
clipCount,
911
isExpanded,
1012
gutterBackground,
1113
onToggleClipExpanded,
1214
}: {
1315
keyframeClip: TimelineElement;
16+
clipCount: number;
1417
isExpanded: boolean;
1518
gutterBackground: string;
1619
onToggleClipExpanded: () => void;
@@ -53,6 +56,7 @@ export function LayerDisclosureRow({
5356
<span className="min-w-0 flex-1 truncate font-medium" title={name}>
5457
{name}
5558
</span>
59+
<TrackClipCount clipCount={clipCount} />
5660
</div>
5761
);
5862
}

packages/studio/src/player/components/TimelineTrackHeader.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const OPACITY = animation("opacity-tween", "visual", [
6060

6161
interface RenderHeaderOptions {
6262
animations?: GsapAnimation[];
63+
clipCount?: number;
6364
currentTime?: number;
6465
expanded?: boolean;
6566
onSeek?: (time: number) => void;
@@ -82,6 +83,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
8283
trackLabel="Hero card"
8384
contentOrigin={LABEL_COL_W}
8485
keyframeClip={ELEMENT}
86+
clipCount={next.clipCount ?? 1}
8587
isExpanded={next.expanded !== false}
8688
animations={next.animations ?? [POSITION, OPACITY]}
8789
currentTime={next.currentTime ?? 0}
@@ -109,6 +111,17 @@ function click(host: HTMLElement, label: string) {
109111
}
110112

111113
describe("TimelineTrackHeader", () => {
114+
// The header shows one clip's lanes, so how many clips the track holds is
115+
// otherwise invisible from the label column. A single-clip track stays silent.
116+
it("shows the track's clip count only once the track holds more than one clip", () => {
117+
const view = renderHeader({ clipCount: 1 });
118+
expect(view.host.querySelector('[aria-label="1 clips"]')).toBeNull();
119+
120+
view.rerender({ clipCount: 3 });
121+
expect(view.host.querySelector('[aria-label="3 clips"]')?.textContent).toBe("3");
122+
act(() => view.root.unmount());
123+
});
124+
112125
it("adds and removes a keyframe on the explicitly targeted property-group tween", () => {
113126
const onTogglePropertyGroupKeyframe = vi.fn();
114127
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });

packages/studio/src/player/components/TimelineTrackHeader.tsx

Lines changed: 22 additions & 227 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,20 @@
11
import { useState } from "react";
22
import { Eye, EyeSlash } from "@phosphor-icons/react";
3-
import {
4-
classifyPropertyGroup,
5-
type GsapAnimation,
6-
type PropertyGroupName,
7-
} from "@hyperframes/core/gsap-parser";
8-
import {
9-
clipToTweenPercentage,
10-
getKeyframeNavigationState,
11-
} from "../../components/editor/KeyframeNavigation";
3+
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
124
import { Music } from "../../icons/SystemIcons";
13-
import {
14-
absoluteToPercentageForAnimation,
15-
isTimeWithinTween,
16-
resolveTweenDuration,
17-
resolveTweenStart,
18-
} from "../../utils/globalTimeCompiler";
195
import type { TimelineElement } from "../store/playerStore";
20-
import type {
21-
TimelineEditCallbacks,
22-
TimelinePropertyGroupKeyframeToggle,
23-
} from "./timelineCallbacks";
6+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
247
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
258
import { LayerDisclosureRow } from "./LayerDisclosureRow";
9+
import { TrackClipCount } from "./TrackClipCount";
2610
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
2711
import type { TimelineTheme } from "./timelineTheme";
12+
import {
13+
resolveLaneHeaderState,
14+
type KeyframeNavigationState,
15+
type TimelinePropertyLane,
16+
} from "./trackHeaderLaneState";
17+
import { valueReadout } from "./trackHeaderLaneValues";
2818

2919
interface TimelineTrackHeaderProps {
3020
trackNumber: number;
@@ -33,6 +23,8 @@ interface TimelineTrackHeaderProps {
3323
/** The track's active keyframe clip (selected, else primary) — the one whose
3424
* disclosure + property rows this header shows, whether expanded or not. */
3525
keyframeClip: TimelineElement | null;
26+
/** Clips on this track, so the header can say how many the row holds. */
27+
clipCount: number;
3628
isExpanded: boolean;
3729
animations: readonly GsapAnimation[];
3830
currentTime: number;
@@ -47,104 +39,6 @@ interface TimelineTrackHeaderProps {
4739
onSeek?: (time: number) => void;
4840
}
4941

50-
function roundValue(value: number): string {
51-
return String(Math.round(value * 100) / 100);
52-
}
53-
54-
function propertyValueAt(
55-
animation: GsapAnimation,
56-
property: string,
57-
tweenPercentage: number,
58-
): number | string | undefined {
59-
const keyframes = animation.keyframes?.keyframes ?? [];
60-
const values = keyframes
61-
.filter((keyframe) => property in keyframe.properties)
62-
.map((keyframe) => ({
63-
percentage: keyframe.percentage,
64-
value: keyframe.properties[property],
65-
}));
66-
const before = values.filter((value) => value.percentage <= tweenPercentage).at(-1);
67-
const after = values.find((value) => value.percentage >= tweenPercentage);
68-
if (!before) return after?.value;
69-
if (!after) return before.value;
70-
if (
71-
typeof before.value !== "number" ||
72-
typeof after.value !== "number" ||
73-
before.percentage === after.percentage
74-
) {
75-
return before.value;
76-
}
77-
const progress = (tweenPercentage - before.percentage) / (after.percentage - before.percentage);
78-
return before.value + (after.value - before.value) * progress;
79-
}
80-
81-
function valuesAt(
82-
animation: GsapAnimation,
83-
group: PropertyGroupName,
84-
tweenPercentage: number,
85-
): Record<string, number | string> {
86-
const propertyNames = new Set<string>();
87-
for (const keyframe of animation.keyframes?.keyframes ?? []) {
88-
for (const property of Object.keys(keyframe.properties)) {
89-
if (classifyPropertyGroup(property) === group) propertyNames.add(property);
90-
}
91-
}
92-
const values: Record<string, number | string> = {};
93-
for (const property of propertyNames) {
94-
const value = propertyValueAt(animation, property, tweenPercentage);
95-
if (value !== undefined) values[property] = value;
96-
}
97-
return values;
98-
}
99-
100-
function groupLabel(group: PropertyGroupName, properties: Record<string, number | string>): string {
101-
if (group === "visual" && ("opacity" in properties || "autoAlpha" in properties)) {
102-
return "Opacity";
103-
}
104-
if (group !== "other") return `${group[0]?.toUpperCase() ?? ""}${group.slice(1)}`;
105-
const property = Object.keys(properties)[0];
106-
return property ? `${property[0]?.toUpperCase() ?? ""}${property.slice(1)}` : "Other";
107-
}
108-
109-
type LaneValues = Record<string, number | string>;
110-
111-
function defaultValueReadout(values: LaneValues): string {
112-
return Object.values(values)
113-
.map((value) => (typeof value === "number" ? roundValue(value) : value))
114-
.join(", ");
115-
}
116-
117-
function positionValueReadout(values: LaneValues): string | null {
118-
const x = values.x;
119-
const y = values.y;
120-
return typeof x === "number" && typeof y === "number"
121-
? `${roundValue(x)}, ${roundValue(y)}`
122-
: null;
123-
}
124-
125-
function rotationValueReadout(values: LaneValues): string | null {
126-
return typeof values.rotation === "number" ? `${roundValue(values.rotation)}°` : null;
127-
}
128-
129-
function visualValueReadout(values: LaneValues): string | null {
130-
const opacity = values.opacity ?? values.autoAlpha;
131-
return typeof opacity === "number"
132-
? `${roundValue(Math.abs(opacity) <= 1 ? opacity * 100 : opacity)}%`
133-
: null;
134-
}
135-
136-
const GROUP_VALUE_READOUTS: Partial<
137-
Record<PropertyGroupName, (values: LaneValues) => string | null>
138-
> = {
139-
position: positionValueReadout,
140-
rotation: rotationValueReadout,
141-
visual: visualValueReadout,
142-
};
143-
144-
function valueReadout(group: PropertyGroupName, values: Record<string, number | string>): string {
145-
return GROUP_VALUE_READOUTS[group]?.(values) ?? defaultValueReadout(values);
146-
}
147-
14842
function VisibilityButton({
14943
hidden,
15044
trackNumber,
@@ -184,13 +78,19 @@ function VisibilityButton({
18478
function LegacyTrackHeader({
18579
trackNumber,
18680
trackLabel,
81+
clipCount,
18782
showTrackLabel,
18883
isTrackHidden,
18984
isAudioTrack,
19085
onToggleTrackHidden,
19186
}: Pick<
19287
TimelineTrackHeaderProps,
193-
"trackNumber" | "trackLabel" | "isTrackHidden" | "isAudioTrack" | "onToggleTrackHidden"
88+
| "trackNumber"
89+
| "trackLabel"
90+
| "clipCount"
91+
| "isTrackHidden"
92+
| "isAudioTrack"
93+
| "onToggleTrackHidden"
19494
> & { showTrackLabel: boolean }) {
19595
return (
19696
<>
@@ -202,6 +102,7 @@ function LegacyTrackHeader({
202102
{trackLabel}
203103
</span>
204104
)}
105+
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
205106
<VisibilityButton
206107
hidden={isTrackHidden}
207108
trackNumber={trackNumber}
@@ -212,115 +113,6 @@ function LegacyTrackHeader({
212113
);
213114
}
214115

215-
type TimelinePropertyLane = ReturnType<typeof getTimelinePropertyLanes>[number];
216-
type KeyframeNavigationState = ReturnType<
217-
typeof getKeyframeNavigationState<TimelinePropertyLane["keyframes"][number]>
218-
>;
219-
220-
function findNearestLaneKeyframe(lane: TimelinePropertyLane, clipPercentage: number) {
221-
return lane.keyframes.reduce<(typeof lane.keyframes)[number] | null>(
222-
(nearest, keyframe) =>
223-
!nearest ||
224-
Math.abs(keyframe.percentage - clipPercentage) < Math.abs(nearest.percentage - clipPercentage)
225-
? keyframe
226-
: nearest,
227-
null,
228-
);
229-
}
230-
231-
function findAnimationAtTime(animations: TimelinePropertyLane["animations"], currentTime: number) {
232-
return animations.find((candidate) => {
233-
const start = resolveTweenStart(candidate);
234-
return start !== null && isTimeWithinTween(currentTime, start, resolveTweenDuration(candidate));
235-
});
236-
}
237-
238-
function resolveLaneAnimation(
239-
lane: TimelinePropertyLane,
240-
navigation: KeyframeNavigationState,
241-
nearestKeyframe: TimelinePropertyLane["keyframes"][number] | null,
242-
animationAtPlayhead: GsapAnimation | undefined,
243-
) {
244-
const animationId = navigation.currentKeyframe?.animationId ?? nearestKeyframe?.animationId;
245-
return animationAtPlayhead ?? lane.animations.find((candidate) => candidate.id === animationId);
246-
}
247-
248-
function resolveLaneTweenPercentage(
249-
navigation: KeyframeNavigationState,
250-
animation: GsapAnimation | undefined,
251-
animationKeyframes: TimelinePropertyLane["keyframes"],
252-
currentTime: number,
253-
clipPercentage: number,
254-
) {
255-
return (
256-
navigation.currentKeyframe?.tweenPercentage ??
257-
(animation ? absoluteToPercentageForAnimation(currentTime, animation) : null) ??
258-
clipToTweenPercentage(animationKeyframes, clipPercentage)
259-
);
260-
}
261-
262-
function valuesForLaneAnimation(
263-
animation: GsapAnimation | undefined,
264-
lane: TimelinePropertyLane,
265-
tweenPercentage: number,
266-
) {
267-
return animation ? valuesAt(animation, lane.group, tweenPercentage) : {};
268-
}
269-
270-
function createLaneToggleTarget(
271-
animation: GsapAnimation | undefined,
272-
lane: TimelinePropertyLane,
273-
tweenPercentage: number,
274-
values: LaneValues,
275-
navigation: KeyframeNavigationState,
276-
): TimelinePropertyGroupKeyframeToggle | null {
277-
return animation
278-
? {
279-
animationId: animation.id,
280-
propertyGroup: lane.group,
281-
tweenPercentage,
282-
properties: values,
283-
remove: navigation.currentKeyframe !== null,
284-
}
285-
: null;
286-
}
287-
288-
function resolveLaneHeaderState(
289-
lane: TimelinePropertyLane,
290-
currentTime: number,
291-
clipPercentage: number,
292-
) {
293-
const navigation = getKeyframeNavigationState(lane.keyframes, clipPercentage);
294-
const nearestKeyframe = findNearestLaneKeyframe(lane, clipPercentage);
295-
const animationAtPlayhead = findAnimationAtTime(lane.animations, currentTime);
296-
const animation = resolveLaneAnimation(lane, navigation, nearestKeyframe, animationAtPlayhead);
297-
const animationKeyframes = lane.keyframes.filter(
298-
(keyframe) => keyframe.animationId === animation?.id,
299-
);
300-
const tweenPercentage = resolveLaneTweenPercentage(
301-
navigation,
302-
animation,
303-
animationKeyframes,
304-
currentTime,
305-
clipPercentage,
306-
);
307-
const values = valuesForLaneAnimation(animation, lane, tweenPercentage);
308-
const label = groupLabel(lane.group, values);
309-
const toggleTarget = createLaneToggleTarget(animation, lane, tweenPercentage, values, navigation);
310-
311-
return {
312-
navigation,
313-
nearestKeyframe,
314-
animationAtPlayhead,
315-
animation,
316-
animationKeyframes,
317-
tweenPercentage,
318-
values,
319-
label,
320-
toggleTarget,
321-
};
322-
}
323-
324116
// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
325117
function PropertyGroupNavigation({
326118
navigation,
@@ -484,6 +276,7 @@ export function TimelineTrackHeader({
484276
trackLabel,
485277
contentOrigin,
486278
keyframeClip,
279+
clipCount,
487280
isExpanded,
488281
animations,
489282
currentTime,
@@ -528,6 +321,7 @@ export function TimelineTrackHeader({
528321
<LegacyTrackHeader
529322
trackNumber={trackNumber}
530323
trackLabel={trackLabel}
324+
clipCount={clipCount}
531325
showTrackLabel={showTrackLabel}
532326
isTrackHidden={isTrackHidden}
533327
isAudioTrack={isAudioTrack}
@@ -537,6 +331,7 @@ export function TimelineTrackHeader({
537331
<>
538332
<LayerDisclosureRow
539333
keyframeClip={keyframeClip}
334+
clipCount={clipCount}
540335
isExpanded={isExpanded}
541336
gutterBackground={theme.gutterBackground}
542337
onToggleClipExpanded={onToggleClipExpanded}

0 commit comments

Comments
 (0)