feat(studio): add keyframe track headers

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-27 19:52:06 +02:00
parent fed5e5b71d
commit 8bb31b3949
6 changed files with 937 additions and 14 deletions
@@ -22,6 +22,36 @@ interface KeyframeNavigationProps {
const TOLERANCE = 0.5;
interface NavigableKeyframe {
percentage: number;
tweenPercentage?: number;
properties: Record<string, number | string>;
}
export function getKeyframeNavigationState<Keyframe extends NavigableKeyframe>(
keyframes: readonly Keyframe[],
currentPercentage: number,
property?: string,
) {
const propertyKeyframes = property
? keyframes.filter((keyframe) => property in keyframe.properties)
: keyframes;
return {
propertyKeyframes,
prevKeyframe:
propertyKeyframes
.filter((keyframe) => keyframe.percentage < currentPercentage - TOLERANCE)
.at(-1) ?? null,
nextKeyframe:
propertyKeyframes.find((keyframe) => keyframe.percentage > currentPercentage + TOLERANCE) ??
null,
currentKeyframe:
propertyKeyframes.find(
(keyframe) => Math.abs(keyframe.percentage - currentPercentage) <= TOLERANCE,
) ?? null,
};
}
/**
* Convert a clip-relative percentage (element lifetime, used for display/seek) to
* the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween
@@ -92,18 +122,12 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
onRemoveKeyframe,
onConvertToKeyframes,
}: KeyframeNavigationProps) {
// Find keyframes that contain this property
const propertyKeyframes = keyframes?.filter((kf) => property in kf.properties) ?? [];
const prevKf =
propertyKeyframes.filter((kf) => kf.percentage < currentPercentage - TOLERANCE).at(-1) ?? null;
const nextKf =
propertyKeyframes.find((kf) => kf.percentage > currentPercentage + TOLERANCE) ?? null;
const atCurrent =
propertyKeyframes.find((kf) => Math.abs(kf.percentage - currentPercentage) <= TOLERANCE) ??
null;
const {
propertyKeyframes,
prevKeyframe: prevKf,
nextKeyframe: nextKf,
currentKeyframe: atCurrent,
} = getKeyframeNavigationState(keyframes ?? [], currentPercentage, property);
// Diamond state
let diamondState: DiamondState;
@@ -0,0 +1,56 @@
import { CaretRight } from "@phosphor-icons/react";
import type { TimelineElement } from "../store/playerStore";
import { LABEL_COL_W, TRACK_H } from "./timelineLayout";
// 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,
isExpanded,
gutterBackground,
onToggleClipExpanded,
}: {
keyframeClip: TimelineElement;
isExpanded: boolean;
gutterBackground: string;
onToggleClipExpanded: () => void;
}) {
const name = keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id;
return (
<div
className="absolute left-0 top-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[11px]"
style={{
width: LABEL_COL_W,
height: TRACK_H,
color: "#ffffff",
background: gutterBackground,
}}
>
<button
type="button"
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
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]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleClipExpanded();
}}
>
<CaretRight
size={11}
weight="bold"
aria-hidden="true"
style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
/>
</button>
<span
aria-label="Layer keyframe indicator"
className="shrink-0 text-[13px] leading-none text-white/40"
>
</span>
<span className="min-w-0 flex-1 truncate font-medium">{name}</span>
</div>
);
}
@@ -0,0 +1,262 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { defaultTimelineTheme } from "./timelineTheme";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { LABEL_COL_W } from "./timelineLayout";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
const ELEMENT: TimelineElement = {
id: "clip-1",
label: "Hero card",
tag: "div",
start: 0,
duration: 2,
track: 0,
};
function animation(
id: string,
propertyGroup: PropertyGroupName,
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
}>,
): GsapAnimation {
return {
id,
targetSelector: "#clip-1",
method: "to",
position: 0,
duration: 2,
properties: {},
propertyGroup,
keyframes: { format: "percentage", keyframes },
};
}
const POSITION = animation("position-tween", "position", [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 50, properties: { x: 100, y: 50 } },
{ percentage: 100, properties: { x: 200, y: 100 } },
]);
const OPACITY = animation("opacity-tween", "visual", [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 50, properties: { opacity: 0.5 } },
{ percentage: 100, properties: { opacity: 1 } },
]);
interface RenderHeaderOptions {
animations?: GsapAnimation[];
currentTime?: number;
expanded?: boolean;
onSeek?: (time: number) => void;
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
}
function renderHeader(options: RenderHeaderOptions = {}): {
host: HTMLDivElement;
root: Root;
rerender: (next: RenderHeaderOptions) => void;
} {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (next: RenderHeaderOptions) => {
act(() => {
root.render(
<TimelineTrackHeader
trackNumber={0}
trackLabel="Hero card"
contentOrigin={LABEL_COL_W}
keyframeClip={ELEMENT}
isExpanded={next.expanded !== false}
animations={next.animations ?? [POSITION, OPACITY]}
currentTime={next.currentTime ?? 0}
isTrackHidden={false}
isAudioTrack={false}
isActive
isHovered={false}
theme={defaultTimelineTheme}
onToggleClipExpanded={vi.fn()}
onToggleTrackHidden={vi.fn()}
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
onSeek={next.onSeek}
/>,
);
});
};
render(options);
return { host, root, rerender: render };
}
function click(host: HTMLElement, label: string) {
const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
expect(button).not.toBeNull();
act(() => button?.click());
}
describe("TimelineTrackHeader", () => {
it("adds and removes a keyframe on the explicitly targeted property-group tween", () => {
const onTogglePropertyGroupKeyframe = vi.fn();
const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
click(view.host, "Toggle Opacity keyframe");
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
ELEMENT,
expect.objectContaining({
animationId: "opacity-tween",
propertyGroup: "visual",
tweenPercentage: 25,
properties: { opacity: 0.25 },
remove: false,
}),
);
view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
click(view.host, "Toggle Opacity keyframe");
expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
ELEMENT,
expect.objectContaining({
animationId: "opacity-tween",
propertyGroup: "visual",
tweenPercentage: 50,
properties: { opacity: 0.5 },
remove: true,
}),
);
expect(onTogglePropertyGroupKeyframe).not.toHaveBeenCalledWith(
ELEMENT,
expect.objectContaining({ animationId: "position-tween" }),
);
act(() => view.root.unmount());
});
it("seeks only to the selected group's adjacent keyframes", () => {
const onSeek = vi.fn();
const view = renderHeader({
currentTime: 1,
animations: [
POSITION,
animation("opacity-tween", "visual", [
{ percentage: 25, properties: { opacity: 0.25 } },
{ percentage: 50, properties: { opacity: 0.5 } },
{ percentage: 75, properties: { opacity: 0.75 } },
]),
],
onSeek,
});
click(view.host, "Next Position keyframe");
expect(onSeek).toHaveBeenLastCalledWith(2);
click(view.host, "Previous Position keyframe");
expect(onSeek).toHaveBeenLastCalledWith(0);
expect(onSeek).not.toHaveBeenCalledWith(1.5);
act(() => view.root.unmount());
});
it("fills the toggle diamond exactly at that group's keyframe", () => {
const view = renderHeader({ currentTime: 0.5 });
const positionToggle = view.host.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle Position keyframe"]',
);
expect(positionToggle?.textContent).toBe("◇");
view.rerender({ currentTime: 1 });
expect(
view.host.querySelector<HTMLButtonElement>('button[aria-label="Toggle Position keyframe"]')
?.textContent,
).toBe("◆");
act(() => view.root.unmount());
});
it("updates formatted group values when the playhead moves", () => {
const view = renderHeader({ currentTime: 0.5 });
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
"50, 25",
);
expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("25%");
view.rerender({ currentTime: 1.5 });
expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
"150, 75",
);
expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("75%");
act(() => view.root.unmount());
});
it("disables the previous chevron at or before the group's first keyframe", () => {
const view = renderHeader({ currentTime: 0 });
const prevAt0 = view.host.querySelector<HTMLButtonElement>(
'button[aria-label="Previous Position keyframe"]',
);
expect(prevAt0).not.toBeNull();
expect(prevAt0?.disabled).toBe(true);
view.rerender({ currentTime: 1 });
const prevAt1 = view.host.querySelector<HTMLButtonElement>(
'button[aria-label="Previous Position keyframe"]',
);
expect(prevAt1?.disabled).toBe(false);
act(() => view.root.unmount());
});
it("uses the same lane row offsets when collapsed, expanded once, and expanded multiple times", () => {
const view = renderHeader({ expanded: false });
expect(view.host.querySelectorAll("[data-timeline-lane-top]")).toHaveLength(0);
const assertAligned = (animations: GsapAnimation[]) => {
view.rerender({ animations });
const lanesHost = document.createElement("div");
document.body.append(lanesHost);
const lanesRoot = createRoot(lanesHost);
act(() => {
lanesRoot.render(
<TimelinePropertyLanes
animations={animations}
clipStart={0}
clipDuration={2}
clipLeftPx={120}
clipWidthPx={200}
accentColor="#3CE6AC"
isSelected
currentPercentage={0}
elementId="clip-1"
selectedKeyframes={new Set()}
/>,
);
});
expect(
Array.from(view.host.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
(row) => row.style.top,
),
).toEqual(
Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
(row) => row.style.top,
),
);
expect(
Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-property-lane]")).map(
(row) => row.style.left,
),
).toEqual(animations.map(() => "120px"));
act(() => lanesRoot.unmount());
};
assertAligned([POSITION]);
assertAligned([POSITION, OPACITY]);
act(() => view.root.unmount());
});
});
@@ -0,0 +1,552 @@
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 { 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 { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { LayerDisclosureRow } from "./LayerDisclosureRow";
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
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;
isExpanded: boolean;
animations: readonly GsapAnimation[];
currentTime: number;
isTrackHidden: boolean;
isAudioTrack: boolean;
isActive: boolean;
isHovered: boolean;
theme: TimelineTheme;
onToggleClipExpanded: () => void;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
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<string, number | string> {
const propertyNames = new Set<string>();
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<string, number | string> = {};
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, number | string>): 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<string, number | string>;
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<PropertyGroupName, (values: LaneValues) => string | null>
> = {
position: positionValueReadout,
rotation: rotationValueReadout,
visual: visualValueReadout,
};
function valueReadout(group: PropertyGroupName, values: Record<string, number | string>): string {
return GROUP_VALUE_READOUTS[group]?.(values) ?? defaultValueReadout(values);
}
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>
);
}
function LegacyTrackHeader({
trackNumber,
trackLabel,
showTrackLabel,
isTrackHidden,
isAudioTrack,
onToggleTrackHidden,
}: Pick<
TimelineTrackHeaderProps,
"trackNumber" | "trackLabel" | "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]">{trackLabel}</span>}
<VisibilityButton
hidden={isTrackHidden}
trackNumber={trackNumber}
visible
onToggle={onToggleTrackHidden}
/>
</>
);
}
type TimelinePropertyLane = ReturnType<typeof getTimelinePropertyLanes>[number];
type KeyframeNavigationState = ReturnType<
typeof getKeyframeNavigationState<TimelinePropertyLane["keyframes"][number]>
>;
function findNearestLaneKeyframe(lane: TimelinePropertyLane, clipPercentage: number) {
return lane.keyframes.reduce<(typeof lane.keyframes)[number] | null>(
(nearest, keyframe) =>
!nearest ||
Math.abs(keyframe.percentage - clipPercentage) < Math.abs(nearest.percentage - clipPercentage)
? keyframe
: nearest,
null,
);
}
function findAnimationAtTime(animations: TimelinePropertyLane["animations"], currentTime: number) {
return animations.find((candidate) => {
const start = resolveTweenStart(candidate);
return start !== null && isTimeWithinTween(currentTime, start, resolveTweenDuration(candidate));
});
}
function resolveLaneAnimation(
lane: TimelinePropertyLane,
navigation: KeyframeNavigationState,
nearestKeyframe: TimelinePropertyLane["keyframes"][number] | null,
animationAtPlayhead: GsapAnimation | undefined,
) {
const animationId = navigation.currentKeyframe?.animationId ?? nearestKeyframe?.animationId;
return animationAtPlayhead ?? lane.animations.find((candidate) => candidate.id === animationId);
}
function resolveLaneTweenPercentage(
navigation: KeyframeNavigationState,
animation: GsapAnimation | undefined,
animationKeyframes: TimelinePropertyLane["keyframes"],
currentTime: number,
clipPercentage: number,
) {
return (
navigation.currentKeyframe?.tweenPercentage ??
(animation ? absoluteToPercentageForAnimation(currentTime, animation) : null) ??
clipToTweenPercentage(animationKeyframes, clipPercentage)
);
}
function valuesForLaneAnimation(
animation: GsapAnimation | undefined,
lane: TimelinePropertyLane,
tweenPercentage: number,
) {
return animation ? valuesAt(animation, lane.group, tweenPercentage) : {};
}
function createLaneToggleTarget(
animation: GsapAnimation | undefined,
lane: TimelinePropertyLane,
tweenPercentage: number,
values: LaneValues,
navigation: KeyframeNavigationState,
): TimelinePropertyGroupKeyframeToggle | null {
return animation
? {
animationId: animation.id,
propertyGroup: lane.group,
tweenPercentage,
properties: values,
remove: navigation.currentKeyframe !== null,
}
: null;
}
function resolveLaneHeaderState(
lane: TimelinePropertyLane,
currentTime: number,
clipPercentage: number,
) {
const navigation = getKeyframeNavigationState(lane.keyframes, clipPercentage);
const nearestKeyframe = findNearestLaneKeyframe(lane, clipPercentage);
const animationAtPlayhead = findAnimationAtTime(lane.animations, currentTime);
const animation = resolveLaneAnimation(lane, navigation, nearestKeyframe, animationAtPlayhead);
const animationKeyframes = lane.keyframes.filter(
(keyframe) => keyframe.animationId === animation?.id,
);
const tweenPercentage = resolveLaneTweenPercentage(
navigation,
animation,
animationKeyframes,
currentTime,
clipPercentage,
);
const values = valuesForLaneAnimation(animation, lane, tweenPercentage);
const label = groupLabel(lane.group, values);
const toggleTarget = createLaneToggleTarget(animation, lane, tweenPercentage, values, navigation);
return {
navigation,
nearestKeyframe,
animationAtPlayhead,
animation,
animationKeyframes,
tweenPercentage,
values,
label,
toggleTarget,
};
}
// 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;
}) {
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="h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15"
onClick={() => seekTo(navigation.prevKeyframe)}
>
</button>
{children}
<button
type="button"
aria-label={`Next ${label} keyframe`}
disabled={!navigation.nextKeyframe}
className="h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15"
onClick={() => seekTo(navigation.nextKeyframe)}
>
</button>
</span>
);
}
function PropertyGroupHeaderRow({
lane,
laneIndex,
isLastLane,
expandedElement,
currentTime,
clipPercentage,
hoveredGroup,
setHoveredGroup,
isActive,
isHovered,
isTrackHidden,
trackNumber,
gutterBackground,
onToggleTrackHidden,
onTogglePropertyGroupKeyframe,
onSeek,
}: {
lane: TimelinePropertyLane;
laneIndex: number;
isLastLane: boolean;
expandedElement: TimelineElement;
currentTime: number;
clipPercentage: number;
hoveredGroup: PropertyGroupName | null;
setHoveredGroup: (group: PropertyGroupName | null) => void;
isActive: boolean;
isHovered: boolean;
isTrackHidden: boolean;
trackNumber: number;
gutterBackground: string;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onSeek?: (time: number) => void;
}) {
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
lane,
currentTime,
clipPercentage,
);
const showEye =
hoveredGroup === lane.group ||
(hoveredGroup === null && laneIndex === 0 && (isActive || isHovered));
return (
<div
data-property-group={lane.group}
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
className="absolute left-0 flex items-center gap-1 px-1.5 text-[10px] text-white/65"
style={{
top: getTimelineLaneTop(laneIndex),
width: LABEL_COL_W,
height: LANE_H,
background: gutterBackground,
}}
onPointerEnter={() => setHoveredGroup(lane.group)}
onPointerLeave={() => setHoveredGroup(null)}
>
{/* 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">{label}</span>
<PropertyGroupNavigation
navigation={navigation}
label={label}
expandedElement={expandedElement}
onSeek={onSeek}
>
<button
type="button"
aria-label={`Toggle ${label} keyframe`}
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
className="flex h-5 w-4 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={() => {
if (expandedElement && toggleTarget) {
void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
}
}}
>
{navigation.currentKeyframe ? "◆" : "◇"}
</button>
</PropertyGroupNavigation>
<span className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45">
{valueReadout(lane.group, values)}
</span>
<VisibilityButton
hidden={isTrackHidden}
trackNumber={trackNumber}
visible={showEye}
onToggle={onToggleTrackHidden}
/>
</div>
);
}
export function TimelineTrackHeader({
trackNumber,
trackLabel,
contentOrigin,
keyframeClip,
isExpanded,
animations,
currentTime,
isTrackHidden,
isAudioTrack,
isActive,
isHovered,
theme,
onToggleClipExpanded,
onToggleTrackHidden,
onTogglePropertyGroupKeyframe,
onSeek,
}: TimelineTrackHeaderProps) {
const [hoveredGroup, setHoveredGroup] = useState<PropertyGroupName | null>(null);
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;
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 ? (
<LegacyTrackHeader
trackNumber={trackNumber}
trackLabel={trackLabel}
showTrackLabel={showTrackLabel}
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
onToggleTrackHidden={onToggleTrackHidden}
/>
) : (
<>
<LayerDisclosureRow
keyframeClip={keyframeClip}
isExpanded={isExpanded}
gutterBackground={theme.gutterBackground}
onToggleClipExpanded={onToggleClipExpanded}
/>
{isExpanded &&
lanes.map((lane, laneIndex) => (
<PropertyGroupHeaderRow
key={lane.group}
lane={lane}
laneIndex={laneIndex}
isLastLane={laneIndex === lanes.length - 1}
expandedElement={keyframeClip}
currentTime={currentTime}
clipPercentage={clipPercentage}
hoveredGroup={hoveredGroup}
setHoveredGroup={setHoveredGroup}
isActive={isActive}
isHovered={isHovered}
isTrackHidden={isTrackHidden}
trackNumber={trackNumber}
gutterBackground={theme.gutterBackground}
onToggleTrackHidden={onToggleTrackHidden}
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
onSeek={onSeek}
/>
))}
</>
)}
</div>
);
}
@@ -3,6 +3,15 @@
import type { TimelineElement } from "../store/playerStore";
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
import type { BlockedTimelineEditIntent } from "./timelineEditing";
import type { PropertyGroupName } from "@hyperframes/core/gsap-parser";
export interface TimelinePropertyGroupKeyframeToggle {
animationId: string;
propertyGroup: PropertyGroupName;
tweenPercentage: number;
properties: Record<string, number | string>;
remove: boolean;
}
/**
* Shared callback signatures for timeline editing operations.
@@ -62,14 +71,33 @@ export interface TimelineEditCallbacks {
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
onDeleteKeyframe?: (
elementId: string,
percentage: number,
propertyGroup?: string,
tweenPercentage?: number,
animationId?: string,
) => void;
onDeleteAllKeyframes?: (elementId: string) => void;
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
onMoveKeyframeToPlayhead?: (elementId: string, percentage: number) => void;
onMoveKeyframeToPlayhead?: (
elementId: string,
percentage: number,
propertyGroup?: string,
tweenPercentage?: number,
animationId?: string,
) => void;
onMoveKeyframe?: (
elementId: string,
fromClipPercentage: number,
toClipPercentage: number,
propertyGroup?: string,
tweenPercentage?: number,
animationId?: string,
) => Promise<boolean>;
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
onTogglePropertyGroupKeyframe?: (
element: TimelineElement,
target: TimelinePropertyGroupKeyframeToggle,
) => Promise<void> | void;
}
@@ -3,6 +3,7 @@ import type { ZoomMode } from "../store/playerStore";
/* ── Layout constants ──────────────────────────────────────────────── */
export const GUTTER = 32;
export const LABEL_COL_W = 232;
export const TRACK_H = 48;
export const LANE_H = 28;
export const RULER_H = 24;