From 4ac57a5845ae17362f3f01106c07af0a5d66b860 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 26 Jul 2026 01:05:30 +0200 Subject: [PATCH] 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. --- .../player/components/LayerDisclosureRow.tsx | 4 + .../components/TimelineTrackHeader.test.tsx | 13 + .../player/components/TimelineTrackHeader.tsx | 249 ++---------------- .../src/player/components/TrackClipCount.tsx | 17 ++ .../player/components/trackHeaderLaneState.ts | 121 +++++++++ .../components/trackHeaderLaneValues.ts | 110 ++++++++ 6 files changed, 287 insertions(+), 227 deletions(-) create mode 100644 packages/studio/src/player/components/TrackClipCount.tsx create mode 100644 packages/studio/src/player/components/trackHeaderLaneState.ts create mode 100644 packages/studio/src/player/components/trackHeaderLaneValues.ts diff --git a/packages/studio/src/player/components/LayerDisclosureRow.tsx b/packages/studio/src/player/components/LayerDisclosureRow.tsx index c061da300..52e345b86 100644 --- a/packages/studio/src/player/components/LayerDisclosureRow.tsx +++ b/packages/studio/src/player/components/LayerDisclosureRow.tsx @@ -1,16 +1,19 @@ import { CaretRight } from "@phosphor-icons/react"; import type { TimelineElement } from "../store/playerStore"; import { LABEL_COL_W, TRACK_H } from "./timelineLayout"; +import { TrackClipCount } from "./TrackClipCount"; // Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives // here, not on the clip bar, and re-expands a collapsed layer. export function LayerDisclosureRow({ keyframeClip, + clipCount, isExpanded, gutterBackground, onToggleClipExpanded, }: { keyframeClip: TimelineElement; + clipCount: number; isExpanded: boolean; gutterBackground: string; onToggleClipExpanded: () => void; @@ -53,6 +56,7 @@ export function LayerDisclosureRow({ {name} + ); } diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index 094b54a5f..96feb1794 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -60,6 +60,7 @@ const OPACITY = animation("opacity-tween", "visual", [ interface RenderHeaderOptions { animations?: GsapAnimation[]; + clipCount?: number; currentTime?: number; expanded?: boolean; onSeek?: (time: number) => void; @@ -82,6 +83,7 @@ function renderHeader(options: RenderHeaderOptions = {}): { trackLabel="Hero card" contentOrigin={LABEL_COL_W} keyframeClip={ELEMENT} + clipCount={next.clipCount ?? 1} isExpanded={next.expanded !== false} animations={next.animations ?? [POSITION, OPACITY]} currentTime={next.currentTime ?? 0} @@ -109,6 +111,17 @@ function click(host: HTMLElement, label: string) { } describe("TimelineTrackHeader", () => { + // The header shows one clip's lanes, so how many clips the track holds is + // otherwise invisible from the label column. A single-clip track stays silent. + it("shows the track's clip count only once the track holds more than one clip", () => { + const view = renderHeader({ clipCount: 1 }); + expect(view.host.querySelector('[aria-label="1 clips"]')).toBeNull(); + + view.rerender({ clipCount: 3 }); + expect(view.host.querySelector('[aria-label="3 clips"]')?.textContent).toBe("3"); + act(() => view.root.unmount()); + }); + it("adds and removes a keyframe on the explicitly targeted property-group tween", () => { const onTogglePropertyGroupKeyframe = vi.fn(); const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe }); diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index c0fac5b49..4ef4c5db4 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -1,30 +1,20 @@ import { useState } from "react"; import { Eye, EyeSlash } from "@phosphor-icons/react"; -import { - classifyPropertyGroup, - type GsapAnimation, - type PropertyGroupName, -} from "@hyperframes/core/gsap-parser"; -import { - clipToTweenPercentage, - getKeyframeNavigationState, -} from "../../components/editor/KeyframeNavigation"; +import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; import { Music } from "../../icons/SystemIcons"; -import { - absoluteToPercentageForAnimation, - isTimeWithinTween, - resolveTweenDuration, - resolveTweenStart, -} from "../../utils/globalTimeCompiler"; import type { TimelineElement } from "../store/playerStore"; -import type { - TimelineEditCallbacks, - TimelinePropertyGroupKeyframeToggle, -} from "./timelineCallbacks"; +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; @@ -33,6 +23,8 @@ interface TimelineTrackHeaderProps { /** 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; @@ -47,104 +39,6 @@ interface TimelineTrackHeaderProps { onSeek?: (time: number) => void; } -function roundValue(value: number): string { - return String(Math.round(value * 100) / 100); -} - -function propertyValueAt( - animation: GsapAnimation, - property: string, - tweenPercentage: number, -): number | string | undefined { - const keyframes = animation.keyframes?.keyframes ?? []; - const values = keyframes - .filter((keyframe) => property in keyframe.properties) - .map((keyframe) => ({ - percentage: keyframe.percentage, - value: keyframe.properties[property], - })); - const before = values.filter((value) => value.percentage <= tweenPercentage).at(-1); - const after = values.find((value) => value.percentage >= tweenPercentage); - if (!before) return after?.value; - if (!after) return before.value; - if ( - typeof before.value !== "number" || - typeof after.value !== "number" || - before.percentage === after.percentage - ) { - return before.value; - } - const progress = (tweenPercentage - before.percentage) / (after.percentage - before.percentage); - return before.value + (after.value - before.value) * progress; -} - -function valuesAt( - animation: GsapAnimation, - group: PropertyGroupName, - tweenPercentage: number, -): Record { - const propertyNames = new Set(); - for (const keyframe of animation.keyframes?.keyframes ?? []) { - for (const property of Object.keys(keyframe.properties)) { - if (classifyPropertyGroup(property) === group) propertyNames.add(property); - } - } - const values: Record = {}; - for (const property of propertyNames) { - const value = propertyValueAt(animation, property, tweenPercentage); - if (value !== undefined) values[property] = value; - } - return values; -} - -function groupLabel(group: PropertyGroupName, properties: Record): string { - if (group === "visual" && ("opacity" in properties || "autoAlpha" in properties)) { - return "Opacity"; - } - if (group !== "other") return `${group[0]?.toUpperCase() ?? ""}${group.slice(1)}`; - const property = Object.keys(properties)[0]; - return property ? `${property[0]?.toUpperCase() ?? ""}${property.slice(1)}` : "Other"; -} - -type LaneValues = Record; - -function defaultValueReadout(values: LaneValues): string { - return Object.values(values) - .map((value) => (typeof value === "number" ? roundValue(value) : value)) - .join(", "); -} - -function positionValueReadout(values: LaneValues): string | null { - const x = values.x; - const y = values.y; - return typeof x === "number" && typeof y === "number" - ? `${roundValue(x)}, ${roundValue(y)}` - : null; -} - -function rotationValueReadout(values: LaneValues): string | null { - return typeof values.rotation === "number" ? `${roundValue(values.rotation)}°` : null; -} - -function visualValueReadout(values: LaneValues): string | null { - const opacity = values.opacity ?? values.autoAlpha; - return typeof opacity === "number" - ? `${roundValue(Math.abs(opacity) <= 1 ? opacity * 100 : opacity)}%` - : null; -} - -const GROUP_VALUE_READOUTS: Partial< - Record string | null> -> = { - position: positionValueReadout, - rotation: rotationValueReadout, - visual: visualValueReadout, -}; - -function valueReadout(group: PropertyGroupName, values: Record): string { - return GROUP_VALUE_READOUTS[group]?.(values) ?? defaultValueReadout(values); -} - function VisibilityButton({ hidden, trackNumber, @@ -184,13 +78,19 @@ function VisibilityButton({ function LegacyTrackHeader({ trackNumber, trackLabel, + clipCount, showTrackLabel, isTrackHidden, isAudioTrack, onToggleTrackHidden, }: Pick< TimelineTrackHeaderProps, - "trackNumber" | "trackLabel" | "isTrackHidden" | "isAudioTrack" | "onToggleTrackHidden" + | "trackNumber" + | "trackLabel" + | "clipCount" + | "isTrackHidden" + | "isAudioTrack" + | "onToggleTrackHidden" > & { showTrackLabel: boolean }) { return ( <> @@ -202,6 +102,7 @@ function LegacyTrackHeader({ {trackLabel} )} + {showTrackLabel && }