diff --git a/packages/studio/src/components/editor/KeyframeNavigation.tsx b/packages/studio/src/components/editor/KeyframeNavigation.tsx index c54047c30..b8016f1a6 100644 --- a/packages/studio/src/components/editor/KeyframeNavigation.tsx +++ b/packages/studio/src/components/editor/KeyframeNavigation.tsx @@ -22,6 +22,36 @@ interface KeyframeNavigationProps { const TOLERANCE = 0.5; +interface NavigableKeyframe { + percentage: number; + tweenPercentage?: number; + properties: Record; +} + +export function getKeyframeNavigationState( + 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; diff --git a/packages/studio/src/player/components/LayerDisclosureRow.tsx b/packages/studio/src/player/components/LayerDisclosureRow.tsx new file mode 100644 index 000000000..b630482d6 --- /dev/null +++ b/packages/studio/src/player/components/LayerDisclosureRow.tsx @@ -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 ( +
+ + + ◇ + + {name} +
+ ); +} diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx new file mode 100644 index 000000000..57d6fb127 --- /dev/null +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -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; + }>, +): 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( + , + ); + }); + }; + render(options); + return { host, root, rerender: render }; +} + +function click(host: HTMLElement, label: string) { + const button = host.querySelector(`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( + 'button[aria-label="Toggle Position keyframe"]', + ); + expect(positionToggle?.textContent).toBe("◇"); + + view.rerender({ currentTime: 1 }); + expect( + view.host.querySelector('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( + 'button[aria-label="Previous Position keyframe"]', + ); + expect(prevAt0).not.toBeNull(); + expect(prevAt0?.disabled).toBe(true); + + view.rerender({ currentTime: 1 }); + const prevAt1 = view.host.querySelector( + '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( + , + ); + }); + expect( + Array.from(view.host.querySelectorAll("[data-timeline-lane-top]")).map( + (row) => row.style.top, + ), + ).toEqual( + Array.from(lanesHost.querySelectorAll("[data-timeline-lane-top]")).map( + (row) => row.style.top, + ), + ); + expect( + Array.from(lanesHost.querySelectorAll("[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()); + }); +}); diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx new file mode 100644 index 000000000..e27812aab --- /dev/null +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -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 { + 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, + visible, + onToggle, +}: { + hidden: boolean; + trackNumber: number; + visible: boolean; + onToggle: TimelineEditCallbacks["onToggleTrackHidden"]; +}) { + if (!visible) return