mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
The header file owned value sampling, readout formatting, lane-state resolution and the JSX at once, so a formatting change and a layout change edited the same file. Sampling and formatting now live in trackHeaderLaneValues, lane-state resolution in trackHeaderLaneState, and resolveLaneHeaderState returns only the four fields its caller reads. Also shows the track's clip count next to the track identity, which the header promised but never rendered.
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
// @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[];
|
|
clipCount?: number;
|
|
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}
|
|
clipCount={next.clipCount ?? 1}
|
|
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", () => {
|
|
// The header shows one clip's lanes, so how many clips the track holds is
|
|
// otherwise invisible from the label column. A single-clip track stays silent.
|
|
it("shows the track's clip count only once the track holds more than one clip", () => {
|
|
const view = renderHeader({ clipCount: 1 });
|
|
expect(view.host.querySelector('[aria-label="1 clips"]')).toBeNull();
|
|
|
|
view.rerender({ clipCount: 3 });
|
|
expect(view.host.querySelector('[aria-label="3 clips"]')?.textContent).toBe("3");
|
|
act(() => view.root.unmount());
|
|
});
|
|
|
|
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());
|
|
});
|
|
|
|
// The lane header sits inside the track row, whose own click handler selects
|
|
// the track. Every control in the label column has to own its click, or
|
|
// seeking to a keyframe also reselects whatever is behind the header.
|
|
it("keeps lane-header control clicks off the ancestor track row", () => {
|
|
const onAncestorClick = vi.fn();
|
|
const view = renderHeader({
|
|
currentTime: 1,
|
|
onSeek: vi.fn(),
|
|
onTogglePropertyGroupKeyframe: vi.fn(),
|
|
});
|
|
// React 18 delegates from the root container, so an ancestor of it is where
|
|
// a leaked click actually shows up.
|
|
document.body.addEventListener("click", onAncestorClick);
|
|
|
|
// Every control in the lane's label column, found by row rather than by
|
|
// label, so a wording change to one button can't silently drop it here.
|
|
const controls = view.host.querySelectorAll<HTMLButtonElement>(
|
|
'[data-property-group="position"] button',
|
|
);
|
|
expect(controls.length).toBeGreaterThanOrEqual(3);
|
|
for (const button of controls) {
|
|
act(() => {
|
|
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
document.body.removeEventListener("click", onAncestorClick);
|
|
expect(onAncestorClick).not.toHaveBeenCalled();
|
|
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());
|
|
});
|
|
});
|