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