mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): add keyframe track headers
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user