feat(studio): add timeline property lanes

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-27 19:52:06 +02:00
parent 6e0118cb3f
commit fed5e5b71d
6 changed files with 712 additions and 1 deletions
@@ -12,7 +12,7 @@ import {
timelineKeyframeSelectionKey,
type TimelineKeyframeTarget,
} from "./timelineKeyframeIdentity";
interface TimelineDiamondKeyframe {
export interface TimelineDiamondKeyframe {
percentage: number;
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */
tweenPercentage?: number;
@@ -0,0 +1,433 @@
// @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 { TimelineClipDiamonds } from "./TimelineClipDiamonds";
import {
getTimelinePropertyLanes,
TimelinePropertyLanes,
type TimelinePropertyLanesProps,
} from "./TimelinePropertyLanes";
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
function animation(
id: string,
propertyGroup: PropertyGroupName,
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>,
): GsapAnimation {
return {
id,
targetSelector: "#clip-1",
method: "to",
position: 0,
duration: 1,
properties: {},
propertyGroup,
keyframes: { format: "percentage", keyframes },
};
}
function flatAnimation(
id: string,
propertyGroup: PropertyGroupName,
properties: Record<string, number | string>,
): GsapAnimation {
return {
id,
targetSelector: "#clip-1",
method: "to",
position: 0,
duration: 1,
properties,
propertyGroup,
};
}
function renderPropertyLanes(overrides: Partial<TimelinePropertyLanesProps> = {}): {
host: HTMLDivElement;
root: Root;
} {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<TimelinePropertyLanes
animations={[]}
clipStart={0}
clipDuration={1}
clipLeftPx={0}
clipWidthPx={200}
accentColor="#4ba3d2"
isSelected
currentPercentage={-10}
elementId="clip-1"
selectedKeyframes={new Set()}
{...overrides}
/>,
);
});
return { host, root };
}
function laneDiamonds(host: HTMLElement, group: string): HTMLButtonElement[] {
return Array.from(
host.querySelectorAll<HTMLButtonElement>(
`[data-property-group="${group}"] button[data-keyframe-percentage]`,
),
);
}
function expectLanePercentages(host: HTMLElement, group: string, percentages: string[]) {
expect(laneDiamonds(host, group).map((diamond) => diamond.dataset.keyframePercentage)).toEqual(
percentages,
);
}
function laneEaseButtons(host: HTMLElement, group: string): HTMLButtonElement[] {
return Array.from(
host.querySelectorAll<HTMLButtonElement>(
`[data-property-group="${group}"] button[data-keyframe-ease-button]`,
),
);
}
function laneEaseSegments(host: HTMLElement, group: string): HTMLElement[] {
return Array.from(
host.querySelectorAll<HTMLElement>(
`[data-property-group="${group}"] [data-keyframe-ease-segment]`,
),
);
}
// The mid-segment ease button is revealed on hover (Figma parity), so tests must
// hover the segment strip before its button exists. React derives onMouseEnter
// from a bubbling mouseover, so dispatching that is what arms the hover.
function revealEaseButton(segment: HTMLElement): HTMLButtonElement | null {
act(() => {
segment.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
});
return segment.querySelector<HTMLButtonElement>("button[data-keyframe-ease-button]");
}
const POSITION_SEGMENT_ANIMATION = animation("position-tween", "position", [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 50 } },
]);
describe("TimelinePropertyLanes", () => {
it("returns a position lane with synthesized endpoints for a flat tween", () => {
const lanes = getTimelinePropertyLanes(
[flatAnimation("position-tween", "position", { x: 420 })],
0,
1,
);
expect(lanes).toHaveLength(1);
expect(lanes[0]?.group).toBe("position");
expect(lanes[0]?.keyframes).toEqual([
{
percentage: 0,
tweenPercentage: 0,
properties: { x: 0 },
propertyGroup: "position",
animationId: "position-tween",
},
{
percentage: 100,
tweenPercentage: 100,
properties: { x: 420 },
propertyGroup: "position",
animationId: "position-tween",
},
]);
});
it("returns both flat and authored keyframe property groups", () => {
const lanes = getTimelinePropertyLanes(
[
flatAnimation("position-tween", "position", { x: 420 }),
animation("visual-tween", "visual", [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
]),
],
0,
1,
);
expect(lanes.map((lane) => lane.group)).toEqual(["position", "visual"]);
expect(lanes.map((lane) => lane.keyframes.map((keyframe) => keyframe.percentage))).toEqual([
[0, 100],
[0, 100],
]);
});
it("renders each source property group at its independent keyframe positions", () => {
const animations = [
animation("position-tween", "position", [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 50, properties: { x: 100, y: 20 } },
{ percentage: 100, properties: { x: 200, y: 40 } },
]),
animation("visual-tween", "visual", [{ percentage: 25, properties: { opacity: 0.5 } }]),
];
const { host, root } = renderPropertyLanes({ animations });
const position = laneDiamonds(host, "position");
const visual = laneDiamonds(host, "visual");
expect(position).toHaveLength(3);
expect(visual).toHaveLength(1);
// Diamonds are centered on their true keyframe time (0% at -half); the
// reserved left gutter (content origin inset, tested at the Timeline level)
// keeps the overflowing left half visible rather than clamping it inward.
expect(position.map((diamond) => diamond.style.left)).toEqual(["-11px", "89px", "189px"]);
expect(visual[0]?.style.left).toBe("39px");
expect(
host.querySelectorAll('[data-property-group="position"] [data-keyframe-connector]'),
).toHaveLength(2);
act(() => root.unmount());
});
it("keeps both groups' diamonds when their source keyframes share 0% and 100%", () => {
const animations = [
animation("position-tween", "position", [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 100 } },
]),
animation("visual-tween", "visual", [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
]),
];
const { host, root } = renderPropertyLanes({ animations });
expectLanePercentages(host, "position", ["0", "100"]);
expectLanePercentages(host, "visual", ["0", "100"]);
act(() => root.unmount());
});
it("renders an authored hold keyframe whose value equals its predecessor", () => {
const animations = [
animation("position-tween", "position", [
{ percentage: 0, properties: { x: 10 } },
{ percentage: 50, properties: { x: 10 } },
{ percentage: 100, properties: { x: 20 } },
]),
];
const { host, root } = renderPropertyLanes({ animations });
expectLanePercentages(host, "position", ["0", "50", "100"]);
act(() => root.unmount());
});
it("keeps Position@50% selection distinct from Opacity@50%", () => {
const onClickKeyframe = vi.fn();
const animations = [
animation("position-tween", "position", [{ percentage: 50, properties: { x: 50 } }]),
animation("visual-tween", "visual", [{ percentage: 50, properties: { opacity: 0.5 } }]),
];
const { host, root } = renderPropertyLanes({ animations, onClickKeyframe });
const position = laneDiamonds(host, "position")[0]!;
act(() => {
position.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, button: 0 }));
});
const target = onClickKeyframe.mock.calls[0]?.[0];
expect(target).toEqual({
animationId: "position-tween",
percentage: 50,
propertyGroup: "position",
tweenPercentage: 50,
});
act(() => {
root.render(
<TimelinePropertyLanes
animations={animations}
clipStart={0}
clipDuration={1}
clipLeftPx={0}
clipWidthPx={200}
accentColor="#4ba3d2"
isSelected
currentPercentage={-10}
elementId="clip-1"
selectedKeyframes={new Set([timelineKeyframeSelectionKey("clip-1", target)])}
/>,
);
});
const positionFill = laneDiamonds(host, "position")[0]?.querySelector("path:last-child");
const visualFill = laneDiamonds(host, "visual")[0]?.querySelector("path:last-child");
expect(positionFill?.getAttribute("fill")).toBe("#4ba3d2");
expect(visualFill?.getAttribute("fill")).toBe("#a3a3a3");
act(() => root.unmount());
});
it("reveals one midpoint ease button per segment on hover, regardless of selection", () => {
const animations = [
animation("position-tween", "position", [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 50 } },
{ percentage: 100, properties: { x: 100 } },
]),
];
const { host, root } = renderPropertyLanes({ animations, onSelectSegment: vi.fn() });
const segments = laneEaseSegments(host, "position");
expect(segments).toHaveLength(2);
expect(segments.map((segment) => segment.style.left)).toEqual(["0px", "100px"]);
expect(laneDiamonds(host, "position")).toHaveLength(3);
// Resting state: no button until a segment is hovered.
expect(laneEaseButtons(host, "position")).toHaveLength(0);
// Hovering reveals exactly one button — the hovered segment's.
expect(revealEaseButton(segments[0]!)).not.toBeNull();
expect(laneEaseButtons(host, "position")).toHaveLength(1);
// The ease button is available on hover even when the element is NOT selected
// (a lane shows for the track's active/primary clip, not only the selected one).
act(() => {
root.render(
<TimelinePropertyLanes
animations={animations}
clipStart={0}
clipDuration={1}
clipLeftPx={0}
clipWidthPx={200}
accentColor="#4ba3d2"
isSelected={false}
currentPercentage={-10}
elementId="clip-1"
selectedKeyframes={new Set()}
onSelectSegment={vi.fn()}
/>,
);
});
const unselectedSegments = laneEaseSegments(host, "position");
expect(unselectedSegments).toHaveLength(2);
expect(revealEaseButton(unselectedSegments[0]!)).not.toBeNull();
act(() => root.unmount());
});
it("reveals each segment's button with its destination keyframe ease curve", () => {
const animations = [
animation("position-tween", "position", [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 33, properties: { x: 33 }, ease: "none" },
{ percentage: 66, properties: { x: 66 }, ease: "power2.out" },
{
percentage: 100,
properties: { x: 100 },
ease: "custom(M0,0 C0.1,0.2 0.3,0.9 1,1)",
},
]),
];
const { host, root } = renderPropertyLanes({ animations, onSelectSegment: vi.fn() });
const segments = laneEaseSegments(host, "position");
expect(segments).toHaveLength(3);
const paths = segments.map((segment) =>
revealEaseButton(segment)?.querySelector("path")?.getAttribute("d"),
);
expect(paths).toHaveLength(3);
expect(new Set(paths).size).toBe(3);
act(() => root.unmount());
});
it("selects the destination keyframe when a hovered segment's ease button is clicked", () => {
const onSelectSegment = vi.fn();
const { host, root } = renderPropertyLanes({
animations: [POSITION_SEGMENT_ANIMATION],
onSelectSegment,
});
const button = revealEaseButton(laneEaseSegments(host, "position")[0]!);
act(() => button?.click());
expect(onSelectSegment).toHaveBeenCalledWith({
animationId: "position-tween",
percentage: 50,
propertyGroup: "position",
tweenPercentage: 50,
});
act(() => root.unmount());
});
it("routes a colliding Position segment to the Position animation", () => {
const onSelectSegment = vi.fn();
const animations = [
POSITION_SEGMENT_ANIMATION,
animation("visual-tween", "visual", [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 50, properties: { opacity: 0.5 } },
]),
];
const { host, root } = renderPropertyLanes({ animations, onSelectSegment });
const button = revealEaseButton(laneEaseSegments(host, "position")[0]!);
act(() => button?.click());
expect(onSelectSegment.mock.calls[0]?.[0]).toMatchObject({
animationId: "position-tween",
propertyGroup: "position",
});
act(() => root.unmount());
});
it("keeps the collapsed TimelineClipDiamonds positions and callback contract unchanged", () => {
const onClickKeyframe = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<TimelineClipDiamonds
keyframesData={{
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 } },
],
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={-10}
elementId="clip-1"
selectedKeyframes={new Set(["clip-1:50"])}
onClickKeyframe={onClickKeyframe}
/>,
);
});
const diamonds = Array.from(host.querySelectorAll<HTMLButtonElement>("button"));
// Unified keyframe-diamond size (LANE_H·ratio ≈ 22px, half 11) on collapsed
// clips too, so 0% sits at -11px regardless of clip-bar height.
expect(diamonds.map((diamond) => diamond.style.left)).toEqual(["-11px", "89px"]);
expect(diamonds[1]?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2");
act(() => {
diamonds[1]?.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, button: 0 }));
});
expect(onClickKeyframe).toHaveBeenCalledWith(50);
act(() => root.unmount());
});
});
@@ -0,0 +1,163 @@
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import {
classifyPropertyGroup,
type GsapAnimation,
type PropertyGroupName,
} from "@hyperframes/core/gsap-parser";
import { toAbsoluteTime } from "../../hooks/gsapShared";
import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth";
import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
export interface TimelinePropertyLanesProps {
animations: readonly GsapAnimation[];
clipStart: number;
clipDuration: number;
clipLeftPx: number;
clipWidthPx: number;
accentColor: string;
isSelected: boolean;
currentPercentage: number;
elementId: string;
selectedKeyframes: ReadonlySet<string>;
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
onContextMenuKeyframe?: (e: ReactMouseEvent, target: TimelineKeyframeTarget) => void;
onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
suppressClickRef?: RefObject<boolean>;
}
function hasGroupProperty(
properties: Record<string, number | string>,
group: PropertyGroupName,
): boolean {
return Object.keys(properties).some((property) => classifyPropertyGroup(property) === group);
}
/** The tween's editable keyframes: its real keyframes, or the start→end pair
* synthesized for a flat tween. Empty for a tween that animates nothing. */
function animationKeyframes(animation: GsapAnimation) {
return animation.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(animation)?.keyframes ?? [];
}
/** A tween contributes a property lane when it has a group and at least one
* editable keyframe (real or synthesized). */
export function animationContributesLane(animation: GsapAnimation): boolean {
return !!animation.propertyGroup && animationKeyframes(animation).length > 0;
}
function sourceGroups(animations: readonly GsapAnimation[]) {
const groups = new Map<PropertyGroupName, GsapAnimation[]>();
for (const animation of animations) {
if (!animation.propertyGroup || !animationContributesLane(animation)) continue;
const groupAnimations = groups.get(animation.propertyGroup) ?? [];
groupAnimations.push(animation);
groups.set(animation.propertyGroup, groupAnimations);
}
return groups;
}
function groupKeyframes(
animations: readonly GsapAnimation[],
group: PropertyGroupName,
clipStart: number,
clipDuration: number,
): TimelineDiamondKeyframe[] {
const keyframes: TimelineDiamondKeyframe[] = [];
for (const animation of animations) {
const tweenStart =
animation.resolvedStart ?? (typeof animation.position === "number" ? animation.position : 0);
const tweenDuration = animation.duration ?? clipDuration;
for (const keyframe of animationKeyframes(animation)) {
if (!hasGroupProperty(keyframe.properties, group)) continue;
const absoluteTime = toAbsoluteTime(tweenStart, tweenDuration, keyframe.percentage);
keyframes.push({
...keyframe,
percentage: ((absoluteTime - clipStart) / clipDuration) * 100,
tweenPercentage: keyframe.percentage,
propertyGroup: group,
animationId: animation.id,
});
}
}
return keyframes;
}
export function getTimelinePropertyLanes(
animations: readonly GsapAnimation[],
clipStart: number,
clipDuration: number,
) {
if (clipDuration <= 0) return [];
return Array.from(sourceGroups(animations), ([group, groupAnimations]) => ({
group,
animations: groupAnimations,
keyframes: groupKeyframes(groupAnimations, group, clipStart, clipDuration),
})).filter((lane) => lane.keyframes.length > 0);
}
export function TimelinePropertyLanes({
animations,
clipStart,
clipDuration,
clipLeftPx,
clipWidthPx,
accentColor,
isSelected,
currentPercentage,
elementId,
selectedKeyframes,
onSelectSegment,
onClickKeyframe,
onShiftClickKeyframe,
onContextMenuKeyframe,
onMoveKeyframe,
suppressClickRef,
}: TimelinePropertyLanesProps) {
if (clipWidthPx < 20 || clipDuration <= 0) return null;
const lanes = getTimelinePropertyLanes(animations, clipStart, clipDuration);
if (lanes.length === 0) return null;
return (
<>
{lanes.map(({ group, animations: groupAnimations, keyframes }, laneIndex) => (
<div
key={group}
data-property-group={group}
data-timeline-property-lane=""
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
className="absolute"
style={{
left: clipLeftPx,
top: getTimelineLaneTop(laneIndex),
width: clipWidthPx,
height: LANE_H,
}}
>
<TimelineDiamondLane
keyframesData={{ format: "percentage", keyframes }}
globalEase={
groupAnimations[0]?.keyframes?.easeEach ?? groupAnimations[0]?.ease ?? "none"
}
clipWidthPx={clipWidthPx}
clipHeightPx={LANE_H}
accentColor={accentColor}
isSelected={isSelected}
currentPercentage={currentPercentage}
elementId={elementId}
selectedKeyframes={selectedKeyframes}
onSelectSegment={onSelectSegment}
onClickKeyframe={onClickKeyframe}
onShiftClickKeyframe={onShiftClickKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
suppressClickRef={suppressClickRef}
groupAware
/>
</div>
))}
</>
);
}
@@ -9,6 +9,10 @@ export const RULER_H = 24;
export const CLIP_Y = 3;
export const CLIP_HANDLE_W = 18;
export function getTimelineLaneTop(laneIndex: number): number {
return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
}
/**
* Collapsed-row characterization value for the new-track INSERT band. Runtime
* hit-testing uses getTimelineInsertBoundaryBand with the concrete row height.
@@ -0,0 +1,75 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore } from "../store/playerStore";
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
const studioShell = vi.hoisted(() => ({ projectId: "project-a" }));
vi.mock("../../contexts/StudioContext", () => ({
useStudioShellContextOptional: () => studioShell,
}));
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
studioShell.projectId = "project-a";
usePlayerStore.getState().reset();
});
const animations = new Map<string, GsapAnimation[]>([
[
"clip-1",
[
{
id: "position-tween",
targetSelector: "#clip-1",
method: "to",
position: 0,
duration: 1,
properties: { x: 100 },
propertyGroup: "position",
},
],
],
]);
function AutoExpandHarness({ value }: { value: Map<string, GsapAnimation[]> }) {
useAutoExpandKeyframedClips(value);
return null;
}
describe("useAutoExpandKeyframedClips", () => {
it("preserves manual collapse within a project and expands again in a different project", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (projectId: string, value = new Map(animations)) => {
studioShell.projectId = projectId;
act(() => root.render(<AutoExpandHarness value={value} />));
};
const projectAAnimations = new Map(animations);
render("project-a", projectAAnimations);
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"]));
act(() => usePlayerStore.getState().toggleClipExpanded("clip-1"));
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set());
const refreshedProjectAAnimations = new Map(animations);
render("project-a", refreshedProjectAAnimations);
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set());
render("project-b", refreshedProjectAAnimations);
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set());
render("project-b", new Map());
render("project-b");
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"]));
act(() => root.unmount());
});
});
@@ -0,0 +1,36 @@
import { useEffect, useRef } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore } from "../store/playerStore";
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
import { animationContributesLane } from "./TimelinePropertyLanes";
/**
* Keyframed clips start expanded (AE/Figma default). Auto-expands each clip the
* first time it contributes a lane real keyframes OR a synthesizable flat tween
* tracked per-clip so a later user collapse sticks and never bounces back open
* (and clips added later still auto-expand).
*/
export function useAutoExpandKeyframedClips(gsapAnimations: Map<string, GsapAnimation[]>): void {
const expandClips = usePlayerStore((s) => s.expandClips);
const projectId = useStudioShellContextOptional()?.projectId ?? null;
const seen = useRef({ projectId, source: gsapAnimations, clips: new Set<string>() });
useEffect(() => {
if (!STUDIO_KEYFRAMES_ENABLED) return;
if (seen.current.projectId !== projectId) {
const sourceChanged = seen.current.source !== gsapAnimations;
seen.current = { projectId, source: gsapAnimations, clips: new Set() };
if (!sourceChanged) return;
} else {
seen.current.source = gsapAnimations;
}
const fresh: string[] = [];
for (const [key, animations] of gsapAnimations) {
if (seen.current.clips.has(key)) continue;
if (animations.some(animationContributesLane)) fresh.push(key);
}
if (fresh.length === 0) return;
for (const key of fresh) seen.current.clips.add(key);
expandClips(fresh);
}, [gsapAnimations, expandClips, projectId]);
}