feat(studio): a host can hide the timeline's keyframe lanes and diamonds (#4715)

* feat(studio): a host can hide the timeline's keyframe lanes and diamonds

* refactor(studio): read the timeline's keyframe data through one gate

* fix(studio): hidden keyframes keep the track names in the timeline header

* test(studio): check diamonds on a collapsed clip and lanes on a keyframed one

* style(studio): format the keyframes switch test
This commit is contained in:
Miguel Ángel
2026-09-29 01:21:05 +00:00
committed by GitHub
parent 82ae73a031
commit 00a907d6b3
4 changed files with 111 additions and 3 deletions
@@ -0,0 +1,91 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { Timeline } from "./Timeline";
import { installTimelineMountEnv } from "./timelineMountTestEnv";
import { usePlayerStore } from "../store/playerStore";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
installTimelineMountEnv();
afterEach(() => {
document.body.innerHTML = "";
});
async function mountKeyframed(showKeyframes?: boolean, withLanes = true) {
usePlayerStore.setState({
duration: 10,
currentTime: 0,
timelineReady: true,
selectedElementId: "card",
elements: [{ id: "card", label: "Hero card", tag: "div", start: 0, duration: 4, track: 0 }],
gsapAnimations: withLanes
? new Map([
[
"card",
[
{
id: "card-position",
targetSelector: "#card",
method: "to",
position: 0,
duration: 2,
properties: {},
propertyGroup: "position",
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 } },
],
},
},
],
],
])
: new Map(),
keyframeCache: new Map([
[
"card",
{
format: "percentage",
keyframes: [{ percentage: 50, properties: { x: 100 }, tweenPercentage: 50 }],
},
],
]),
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(<Timeline showKeyframes={showKeyframes} />));
const diamonds = host.querySelectorAll('button[title="50%"]').length;
const named = Array.from(host.querySelectorAll("[role=rowheader]")).some((h) =>
h.textContent?.includes("Hero card"),
);
const lanes = host.textContent?.includes("Position") ?? false;
act(() => root.unmount());
return { diamonds, named, lanes };
}
describe("Timeline showKeyframes", () => {
it("draws a keyframed clip's diamonds by default", async () => {
expect((await mountKeyframed(undefined, false)).diamonds).toBeGreaterThan(0);
});
it("draws no keyframe diamonds when the host turns keyframes off", async () => {
expect((await mountKeyframed(false, false)).diamonds).toBe(0);
});
it("draws the keyframe lanes by default and none when keyframes are off", async () => {
expect((await mountKeyframed()).lanes).toBe(true);
expect((await mountKeyframed(false)).lanes).toBe(false);
});
it("keeps each track's name in the header when keyframes are off", async () => {
expect((await mountKeyframed()).named).toBe(true);
expect((await mountKeyframed(false)).named).toBe(true);
});
});
@@ -34,4 +34,5 @@ export interface TimelineProps
canPasteClip?: () => boolean;
theme?: Partial<TimelineTheme>;
showAudioEffects?: boolean;
showKeyframes?: boolean;
}
@@ -0,0 +1,14 @@
import { usePlayerStore, type KeyframeCacheEntry } from "../store/playerStore";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
const HIDDEN = {
gsapAnimations: new Map<string, GsapAnimation[]>(),
keyframeCache: new Map<string, KeyframeCacheEntry>(),
};
/** The keyframe data the timeline draws, or none when the host hides keyframes. */
export function useTimelineKeyframeData(showKeyframes: boolean) {
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
return showKeyframes ? { gsapAnimations, keyframeCache } : HIDDEN;
}
@@ -21,6 +21,7 @@ import { useTimelineGeometry } from "./useTimelineGeometry";
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
import { GUTTER, LABEL_COL_W } from "./timelineLayout";
import { useTimelineLabelColumn } from "./useTimelineLabelColumn";
import { useTimelineKeyframeData } from "./useTimelineKeyframeData";
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
import { ClipContentOnceShown } from "./timelineClipChildren";
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
@@ -68,6 +69,7 @@ export function useTimelineProviderState({
canPasteClip,
theme: themeOverrides,
showAudioEffects = true,
showKeyframes = true,
sessionEpoch = 0,
previewIframeRef,
onZIndexReorder,
@@ -104,8 +106,9 @@ export function useTimelineProviderState({
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
const { labelMode, contentOrigin } = useTimelineLabelColumn(gsapAnimations, timelineElements);
const { gsapAnimations, keyframeCache } = useTimelineKeyframeData(showKeyframes);
const namedAnimations = usePlayerStore((s) => s.gsapAnimations);
const { labelMode, contentOrigin } = useTimelineLabelColumn(namedAnimations, timelineElements);
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const currentTime = usePlayerStore((s) => s.currentTime);
const beatDragging = usePlayerStore((s) => s.beatDragging);
@@ -130,7 +133,6 @@ export function useTimelineProviderState({
() => getEffectiveTimelineDuration(duration, timelineElements),
[duration, timelineElements],
);
const keyframeCache = usePlayerStore((s) => s.keyframeCache);
useAutoExpandKeyframedClips(gsapAnimations);
const {
tracks,