Skip to content

Commit 86e9735

Browse files
committed
feat(studio): add keyframe track headers
1 parent 6bc8d54 commit 86e9735

6 files changed

Lines changed: 937 additions & 14 deletions

File tree

packages/studio/src/components/editor/KeyframeNavigation.tsx

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,36 @@ interface KeyframeNavigationProps {
2222

2323
const TOLERANCE = 0.5;
2424

25+
interface NavigableKeyframe {
26+
percentage: number;
27+
tweenPercentage?: number;
28+
properties: Record<string, number | string>;
29+
}
30+
31+
export function getKeyframeNavigationState<Keyframe extends NavigableKeyframe>(
32+
keyframes: readonly Keyframe[],
33+
currentPercentage: number,
34+
property?: string,
35+
) {
36+
const propertyKeyframes = property
37+
? keyframes.filter((keyframe) => property in keyframe.properties)
38+
: keyframes;
39+
return {
40+
propertyKeyframes,
41+
prevKeyframe:
42+
propertyKeyframes
43+
.filter((keyframe) => keyframe.percentage < currentPercentage - TOLERANCE)
44+
.at(-1) ?? null,
45+
nextKeyframe:
46+
propertyKeyframes.find((keyframe) => keyframe.percentage > currentPercentage + TOLERANCE) ??
47+
null,
48+
currentKeyframe:
49+
propertyKeyframes.find(
50+
(keyframe) => Math.abs(keyframe.percentage - currentPercentage) <= TOLERANCE,
51+
) ?? null,
52+
};
53+
}
54+
2555
/**
2656
* Convert a clip-relative percentage (element lifetime, used for display/seek) to
2757
* the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween
@@ -92,18 +122,12 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
92122
onRemoveKeyframe,
93123
onConvertToKeyframes,
94124
}: KeyframeNavigationProps) {
95-
// Find keyframes that contain this property
96-
const propertyKeyframes = keyframes?.filter((kf) => property in kf.properties) ?? [];
97-
98-
const prevKf =
99-
propertyKeyframes.filter((kf) => kf.percentage < currentPercentage - TOLERANCE).at(-1) ?? null;
100-
101-
const nextKf =
102-
propertyKeyframes.find((kf) => kf.percentage > currentPercentage + TOLERANCE) ?? null;
103-
104-
const atCurrent =
105-
propertyKeyframes.find((kf) => Math.abs(kf.percentage - currentPercentage) <= TOLERANCE) ??
106-
null;
125+
const {
126+
propertyKeyframes,
127+
prevKeyframe: prevKf,
128+
nextKeyframe: nextKf,
129+
currentKeyframe: atCurrent,
130+
} = getKeyframeNavigationState(keyframes ?? [], currentPercentage, property);
107131

108132
// Diamond state
109133
let diamondState: DiamondState;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { CaretRight } from "@phosphor-icons/react";
2+
import type { TimelineElement } from "../store/playerStore";
3+
import { LABEL_COL_W, TRACK_H } from "./timelineLayout";
4+
5+
// Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives
6+
// here, not on the clip bar, and re-expands a collapsed layer.
7+
export function LayerDisclosureRow({
8+
keyframeClip,
9+
isExpanded,
10+
gutterBackground,
11+
onToggleClipExpanded,
12+
}: {
13+
keyframeClip: TimelineElement;
14+
isExpanded: boolean;
15+
gutterBackground: string;
16+
onToggleClipExpanded: () => void;
17+
}) {
18+
const name = keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id;
19+
return (
20+
<div
21+
className="absolute left-0 top-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
22+
style={{
23+
width: LABEL_COL_W,
24+
height: TRACK_H,
25+
color: "#ffffff",
26+
background: gutterBackground,
27+
}}
28+
>
29+
<button
30+
type="button"
31+
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
32+
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
33+
className="flex h-5 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
34+
onPointerDown={(event) => event.stopPropagation()}
35+
onClick={(event) => {
36+
event.stopPropagation();
37+
onToggleClipExpanded();
38+
}}
39+
>
40+
<CaretRight
41+
size={11}
42+
weight="bold"
43+
aria-hidden="true"
44+
style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
45+
/>
46+
</button>
47+
<span
48+
aria-label="Layer keyframe indicator"
49+
className="shrink-0 text-[13px] leading-none text-white/40"
50+
>
51+
52+
</span>
53+
<span className="min-w-0 flex-1 truncate font-medium">{name}</span>
54+
</div>
55+
);
56+
}
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act } from "react";
4+
import { createRoot, type Root } from "react-dom/client";
5+
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
6+
import { afterEach, describe, expect, it, vi } from "vitest";
7+
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
8+
import { TimelineTrackHeader } from "./TimelineTrackHeader";
9+
import { defaultTimelineTheme } from "./timelineTheme";
10+
import type { TimelineElement } from "../store/playerStore";
11+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
12+
import { LABEL_COL_W } from "./timelineLayout";
13+
14+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15+
16+
afterEach(() => {
17+
document.body.innerHTML = "";
18+
});
19+
20+
const ELEMENT: TimelineElement = {
21+
id: "clip-1",
22+
label: "Hero card",
23+
tag: "div",
24+
start: 0,
25+
duration: 2,
26+
track: 0,
27+
};
28+
29+
function animation(
30+
id: string,
31+
propertyGroup: PropertyGroupName,
32+
keyframes: Array<{
33+
percentage: number;
34+
properties: Record<string, number | string>;
35+
}>,
36+
): GsapAnimation {
37+
return {
38+
id,
39+
targetSelector: "#clip-1",
40+
method: "to",
41+
position: 0,
42+
duration: 2,
43+
properties: {},
44+
propertyGroup,
45+
keyframes: { format: "percentage", keyframes },
46+
};
47+
}
48+
49+
const POSITION = animation("position-tween", "position", [
50+
{ percentage: 0, properties: { x: 0, y: 0 } },
51+
{ percentage: 50, properties: { x: 100, y: 50 } },
52+
{ percentage: 100, properties: { x: 200, y: 100 } },
53+
]);
54+
55+
const OPACITY = animation("opacity-tween", "visual", [
56+
{ percentage: 0, properties: { opacity: 0 } },
57+
{ percentage: 50, properties: { opacity: 0.5 } },
58+
{ percentage: 100, properties: { opacity: 1 } },
59+
]);
60+
61+
interface RenderHeaderOptions {
62+
animations?: GsapAnimation[];
63+
currentTime?: number;
64+
expanded?: boolean;
65+
onSeek?: (time: number) => void;
66+
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
67+
}
68+
69+
function renderHeader(options: RenderHeaderOptions = {}): {
70+
host: HTMLDivElement;
71+
root: Root;
72+
rerender: (next: RenderHeaderOptions) => void;
73+
} {
74+
const host = document.createElement("div");
75+
document.body.append(host);
76+
const root = createRoot(host);
77+
const render = (next: RenderHeaderOptions) => {
78+
act(() => {
79+
root.render(
80+
<TimelineTrackHeader
81+
trackNumber={0}
82+
trackLabel="Hero card"
83+
contentOrigin={LABEL_COL_W}
84+
keyframeClip={ELEMENT}
85+
isExpanded={next.expanded !== false}
86+
animations={next.animations ?? [POSITION, OPACITY]}
87+
currentTime={next.currentTime ?? 0}
88+
isTrackHidden={false}
89+
isAudioTrack={false}
90+
isActive
91+
isHovered={false}
92+
theme={defaultTimelineTheme}
93+
onToggleClipExpanded={vi.fn()}
94+
onToggleTrackHidden={vi.fn()}
95+
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
96+
onSeek={next.onSeek}
97+
/>,
98+
);
99+
});
100+
};
101+
render(options);
102+
return { host, root, rerender: render };
103+
}
104+
105+
function click(host: HTMLElement, label: string) {
106+
const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
107+
expect(button).not.toBeNull();
108+
act(() => button?.click());
109+
}
110+
111+
describe("TimelineTrackHeader", () => {
112+
it("adds and removes a keyframe on the explicitly targeted property-group tween", () => {
113+
const onTogglePropertyGroupKeyframe = vi.fn();
114+
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
115+
116+
click(view.host, "Toggle Opacity keyframe");
117+
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
118+
ELEMENT,
119+
expect.objectContaining({
120+
animationId: "opacity-tween",
121+
propertyGroup: "visual",
122+
tweenPercentage: 25,
123+
properties: { opacity: 0.25 },
124+
remove: false,
125+
}),
126+
);
127+
128+
view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
129+
click(view.host, "Toggle Opacity keyframe");
130+
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
131+
ELEMENT,
132+
expect.objectContaining({
133+
animationId: "opacity-tween",
134+
propertyGroup: "visual",
135+
tweenPercentage: 50,
136+
properties: { opacity: 0.5 },
137+
remove: true,
138+
}),
139+
);
140+
expect(onTogglePropertyGroupKeyframe).not.toHaveBeenCalledWith(
141+
ELEMENT,
142+
expect.objectContaining({ animationId: "position-tween" }),
143+
);
144+
act(() => view.root.unmount());
145+
});
146+
147+
it("seeks only to the selected group's adjacent keyframes", () => {
148+
const onSeek = vi.fn();
149+
const view = renderHeader({
150+
currentTime: 1,
151+
animations: [
152+
POSITION,
153+
animation("opacity-tween", "visual", [
154+
{ percentage: 25, properties: { opacity: 0.25 } },
155+
{ percentage: 50, properties: { opacity: 0.5 } },
156+
{ percentage: 75, properties: { opacity: 0.75 } },
157+
]),
158+
],
159+
onSeek,
160+
});
161+
162+
click(view.host, "Next Position keyframe");
163+
expect(onSeek).toHaveBeenLastCalledWith(2);
164+
click(view.host, "Previous Position keyframe");
165+
expect(onSeek).toHaveBeenLastCalledWith(0);
166+
expect(onSeek).not.toHaveBeenCalledWith(1.5);
167+
act(() => view.root.unmount());
168+
});
169+
170+
it("fills the toggle diamond exactly at that group's keyframe", () => {
171+
const view = renderHeader({ currentTime: 0.5 });
172+
const positionToggle = view.host.querySelector<HTMLButtonElement>(
173+
'button[aria-label="Toggle Position keyframe"]',
174+
);
175+
expect(positionToggle?.textContent).toBe("◇");
176+
177+
view.rerender({ currentTime: 1 });
178+
expect(
179+
view.host.querySelector<HTMLButtonElement>('button[aria-label="Toggle Position keyframe"]')
180+
?.textContent,
181+
).toBe("◆");
182+
act(() => view.root.unmount());
183+
});
184+
185+
it("updates formatted group values when the playhead moves", () => {
186+
const view = renderHeader({ currentTime: 0.5 });
187+
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
188+
"50, 25",
189+
);
190+
expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("25%");
191+
192+
view.rerender({ currentTime: 1.5 });
193+
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
194+
"150, 75",
195+
);
196+
expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("75%");
197+
act(() => view.root.unmount());
198+
});
199+
200+
it("disables the previous chevron at or before the group's first keyframe", () => {
201+
const view = renderHeader({ currentTime: 0 });
202+
const prevAt0 = view.host.querySelector<HTMLButtonElement>(
203+
'button[aria-label="Previous Position keyframe"]',
204+
);
205+
expect(prevAt0).not.toBeNull();
206+
expect(prevAt0?.disabled).toBe(true);
207+
208+
view.rerender({ currentTime: 1 });
209+
const prevAt1 = view.host.querySelector<HTMLButtonElement>(
210+
'button[aria-label="Previous Position keyframe"]',
211+
);
212+
expect(prevAt1?.disabled).toBe(false);
213+
act(() => view.root.unmount());
214+
});
215+
216+
it("uses the same lane row offsets when collapsed, expanded once, and expanded multiple times", () => {
217+
const view = renderHeader({ expanded: false });
218+
expect(view.host.querySelectorAll("[data-timeline-lane-top]")).toHaveLength(0);
219+
220+
const assertAligned = (animations: GsapAnimation[]) => {
221+
view.rerender({ animations });
222+
const lanesHost = document.createElement("div");
223+
document.body.append(lanesHost);
224+
const lanesRoot = createRoot(lanesHost);
225+
act(() => {
226+
lanesRoot.render(
227+
<TimelinePropertyLanes
228+
animations={animations}
229+
clipStart={0}
230+
clipDuration={2}
231+
clipLeftPx={120}
232+
clipWidthPx={200}
233+
accentColor="#3CE6AC"
234+
isSelected
235+
currentPercentage={0}
236+
elementId="clip-1"
237+
selectedKeyframes={new Set()}
238+
/>,
239+
);
240+
});
241+
expect(
242+
Array.from(view.host.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
243+
(row) => row.style.top,
244+
),
245+
).toEqual(
246+
Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
247+
(row) => row.style.top,
248+
),
249+
);
250+
expect(
251+
Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-property-lane]")).map(
252+
(row) => row.style.left,
253+
),
254+
).toEqual(animations.map(() => "120px"));
255+
act(() => lanesRoot.unmount());
256+
};
257+
258+
assertAligned([POSITION]);
259+
assertAligned([POSITION, OPACITY]);
260+
act(() => view.root.unmount());
261+
});
262+
});

0 commit comments

Comments
 (0)