fix(studio): target colliding keyframes exactly (#2692)

This commit is contained in:
Miguel Ángel
2026-07-29 03:39:17 +02:00
committed by GitHub
parent 9bbb6d50a0
commit 7482c22d82
15 changed files with 401 additions and 182 deletions
@@ -14,6 +14,7 @@ export type {
GsapMethod, GsapMethod,
GsapKeyframesData, GsapKeyframesData,
GsapPercentageKeyframe, GsapPercentageKeyframe,
SourcedGsapPercentageKeyframe,
ParsedGsap, ParsedGsap,
ArcPathConfig, ArcPathConfig,
ArcPathSegment, ArcPathSegment,
+16 -2
View File
@@ -94,6 +94,20 @@ export interface WritableGsapPercentageKeyframe extends GsapPercentageKeyframe {
auto?: boolean; auto?: boolean;
} }
/**
* A keyframe that still knows which tween emitted it, and where inside that
* tween it sat. Merging several tweens onto one timeline row drops that
* provenance unless it rides along on the keyframe, and an editor needs it to
* route an edit back to the animation the user actually clicked. Required, not
* optional: a keyframe that reaches a merge without it cannot be attributed at
* all, and silently treating that as "no collision" is how an edit lands on the
* wrong tween.
*/
export interface SourcedGsapPercentageKeyframe extends GsapPercentageKeyframe {
animationId: string;
tweenPercentage: number;
}
/** /**
* Collapse duplicate percentage entries before serializing an object literal. * Collapse duplicate percentage entries before serializing an object literal.
* Matches addKeyframeToScript's merge contract: later properties/ease win while * Matches addKeyframeToScript's merge contract: later properties/ease win while
@@ -122,9 +136,9 @@ export function mergePercentageKeyframes(
export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array"; export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array";
export interface GsapKeyframesData { export interface GsapKeyframesData<K extends GsapPercentageKeyframe = GsapPercentageKeyframe> {
format: GsapKeyframeFormat; format: GsapKeyframeFormat;
keyframes: GsapPercentageKeyframe[]; keyframes: K[];
ease?: string; ease?: string;
easeEach?: string; easeEach?: string;
} }
@@ -155,6 +155,44 @@ describe("pruneKeyframeCacheToFiles", () => {
}); });
describe("updateKeyframeCacheFromParsed", () => { describe("updateKeyframeCacheFromParsed", () => {
it("records colliding animation targets with their own tween percentages", () => {
const animation = (
id: string,
propertyGroup: string,
properties: Record<string, number>,
percentage: number,
resolvedStart: number,
): GsapAnimation => ({
...animWithKeyframes(id),
targetSelector: "#hero",
propertyGroup,
resolvedStart,
keyframes: { format: "percentage", keyframes: [{ percentage, properties }] },
});
usePlayerStore.setState({
elements: [{ id: "hero", domId: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
});
updateKeyframeCacheFromParsed(
[
animation("hero-position", "position", { x: 100 }, 50, 0.5),
animation("hero-visual", "visual", { opacity: 1 }, 80, 0.2),
animation("hero-position", "position", { y: 50 }, 25, 0.75),
animation("hero-scale", "scale", { scale: 2 }, 60, 0.4),
],
"scene.html",
"hero",
{},
);
expect(cache().get("scene.html#hero")?.keyframes[0]?.collidingAnimationTargets).toEqual([
{ animationId: "hero-position", tweenPercentage: 50 },
{ animationId: "hero-visual", tweenPercentage: 80 },
{ animationId: "hero-scale", tweenPercentage: 60 },
]);
});
it("serializes a multi-keyframe tween with a stable shape and animation identity", () => { it("serializes a multi-keyframe tween with a stable shape and animation identity", () => {
const animation: GsapAnimation = { const animation: GsapAnimation = {
...animWithKeyframes("hero"), ...animWithKeyframes("hero"),
@@ -5,7 +5,11 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore"; import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared"; import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth"; import {
deduplicateKeyframes,
synthesizeFlatTweenKeyframes,
type MergeableKeyframe,
} from "./gsapTweenSynth";
export function updateKeyframeCacheFromParsed( export function updateKeyframeCacheFromParsed(
animations: GsapAnimation[], animations: GsapAnimation[],
@@ -16,7 +20,11 @@ export function updateKeyframeCacheFromParsed(
): void { ): void {
const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState(); const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState();
const idsWithKeyframes = new Set<string>(); const idsWithKeyframes = new Set<string>();
const merged = new Map<string, KeyframeCacheEntry>(); // Attributed keyframes only: everything in here came from a parsed tween via
// toClipKeyframes, so the merge can rely on the source identity. It widens
// back into KeyframeCacheEntry on the way to the store, which also holds the
// runtime scan's unattributed keyframes.
const merged = new Map<string, KeyframeCacheEntry & { keyframes: MergeableKeyframe[] }>();
const sourceAnimations = new Map<string, GsapAnimation[]>(); const sourceAnimations = new Map<string, GsapAnimation[]>();
for (const anim of animations) { for (const anim of animations) {
const kfSource = const kfSource =
@@ -49,9 +57,9 @@ export function updateKeyframeCacheFromParsed(
const existing = merged.get(id); const existing = merged.get(id);
if (existing) { if (existing) {
// deduplicateKeyframes owns the same-% merge (including the easeAmbiguous // deduplicateKeyframes owns the same-% merge (including the colliding
// flag downstream lanes read); a second copy of that rule here is how the // animation targets downstream lanes read); a second copy of that rule
// two writers drift. // here is how the two writers drift.
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]); existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
} else { } else {
merged.set(id, { merged.set(id, {
@@ -54,31 +54,86 @@ describe("synthesizeFlatTweenKeyframes", () => {
}); });
}); });
describe("deduplicateKeyframes ease ambiguity", () => { describe("deduplicateKeyframes colliding animation targets", () => {
it("flags a same-% collision from different animations (different eases)", () => { it("records each animation's tween percentage in first-seen order", () => {
const merged = deduplicateKeyframes([ const merged = deduplicateKeyframes([
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" }, {
{ percentage: 45, properties: { opacity: 1 }, ease: "power2.out", animationId: "#a-visual" }, percentage: 45,
tweenPercentage: 20,
properties: { x: 10 },
ease: "power2.in",
animationId: "#a-position",
},
{
percentage: 45,
tweenPercentage: 80,
properties: { opacity: 1 },
ease: "power2.out",
animationId: "#a-visual",
},
]); ]);
const kf = merged.find((k) => k.percentage === 45); const kf = merged.find((k) => k.percentage === 45);
expect(kf?.easeAmbiguous).toBe(true); expect(kf?.collidingAnimationTargets).toEqual([
{ animationId: "#a-position", tweenPercentage: 20 },
{ animationId: "#a-visual", tweenPercentage: 80 },
]);
}); });
it("flags a cross-animation collision even when the raw eases match", () => { it("deduplicates three colliding animations while preserving first-seen order", () => {
// The button can still only target one arbitrary animation, and each may
// inherit a different easeEach/animation ease that raw comparison misses.
const merged = deduplicateKeyframes([ const merged = deduplicateKeyframes([
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" }, {
{ percentage: 45, properties: { opacity: 1 }, ease: "power2.in", animationId: "#a-visual" }, percentage: 45,
tweenPercentage: 20,
properties: { x: 10 },
ease: "power2.in",
animationId: "#a-position",
},
{
percentage: 45,
tweenPercentage: 80,
properties: { opacity: 1 },
ease: "power2.in",
animationId: "#a-visual",
},
{
percentage: 45,
tweenPercentage: 40,
properties: { y: 20 },
ease: "power2.out",
animationId: "#a-position",
},
{
percentage: 45,
tweenPercentage: 60,
properties: { scale: 2 },
ease: "power2.in",
animationId: "#a-scale",
},
]);
expect(merged.find((k) => k.percentage === 45)?.collidingAnimationTargets).toEqual([
{ animationId: "#a-position", tweenPercentage: 20 },
{ animationId: "#a-visual", tweenPercentage: 80 },
{ animationId: "#a-scale", tweenPercentage: 60 },
]); ]);
expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBe(true);
}); });
it("does not flag a same-% collision within a single animation", () => { it("leaves the collision set undefined within a single animation", () => {
const merged = deduplicateKeyframes([ const merged = deduplicateKeyframes([
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" }, {
{ percentage: 45, properties: { y: 20 }, ease: "power2.out", animationId: "#a-position" }, percentage: 45,
tweenPercentage: 20,
properties: { x: 10 },
ease: "power2.in",
animationId: "#a-position",
},
{
percentage: 45,
tweenPercentage: 80,
properties: { y: 20 },
ease: "power2.out",
animationId: "#a-position",
},
]); ]);
expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBeFalsy(); expect(merged.find((k) => k.percentage === 45)?.collidingAnimationTargets).toBeUndefined();
}); });
}); });
+50 -23
View File
@@ -1,7 +1,7 @@
import type { import type {
GsapAnimation, GsapAnimation,
GsapKeyframesData, GsapKeyframesData,
GsapPercentageKeyframe, SourcedGsapPercentageKeyframe,
} from "@hyperframes/core/gsap-parser"; } from "@hyperframes/core/gsap-parser";
import { PROPERTY_DEFAULTS } from "./gsapShared"; import { PROPERTY_DEFAULTS } from "./gsapShared";
@@ -22,33 +22,60 @@ export function isStaticPositionHold(anim: GsapAnimation): boolean {
return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y"); return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y");
} }
export function deduplicateKeyframes< export interface AnimationKeyframeTarget {
T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean }, animationId: string;
>(keyframes: T[]): T[] { tweenPercentage: number;
}
function accumulateCollidingAnimationTargets(
keyframe: AnimationKeyframeTarget & {
collidingAnimationTargets?: AnimationKeyframeTarget[];
},
incoming: AnimationKeyframeTarget,
): void {
const primaryId = keyframe.animationId;
// One tween meeting itself is not a collision. Both identity fields are
// required by the parameter types rather than guarded at runtime: a keyframe
// that arrives without them cannot be attributed to a tween at all, and an
// early return here would silently record no collision and let the inline
// ease button edit an arbitrary one of the tweens that met at this
// percentage. The compiler now refuses the incomplete keyframe instead.
if (primaryId === incoming.animationId) return;
const collisionTargets = keyframe.collidingAnimationTargets;
if (collisionTargets?.some((target) => target.animationId === incoming.animationId)) return;
keyframe.collidingAnimationTargets = [
...(collisionTargets === undefined || collisionTargets.length === 0
? [{ animationId: primaryId, tweenPercentage: keyframe.tweenPercentage }]
: collisionTargets),
{ animationId: incoming.animationId, tweenPercentage: incoming.tweenPercentage },
];
}
/**
* What a keyframe looks like once it has been attributed to its source tween
* and is ready to be merged with the other tweens landing on the same row. The
* runtime scan produces unattributed keyframes and they never reach a merge, so
* they are deliberately not this type.
*/
export type MergeableKeyframe = SourcedGsapPercentageKeyframe & {
propertyGroup?: string;
collidingAnimationTargets?: AnimationKeyframeTarget[];
};
export function deduplicateKeyframes<T extends MergeableKeyframe>(keyframes: T[]): T[] {
const byPct = new Map<number, T>(); const byPct = new Map<number, T>();
for (const kf of keyframes) { for (const kf of keyframes) {
const existing = byPct.get(kf.percentage); const existing = byPct.get(kf.percentage);
if (existing) { if (existing) {
existing.properties = { ...existing.properties, ...kf.properties }; existing.properties = { ...existing.properties, ...kf.properties };
// Two DIFFERENT source animations with a keyframe at the same clip %: a accumulateCollidingAnimationTargets(existing, kf);
// single inline ease button can only target one of them, and which one is // Whichever tween iterated last used to win `ease`, so the merged keyframe
// arbitrary (each may also inherit a different easeEach/animation ease, so // carried an arbitrary one of the colliding curves. Readers that show a
// comparing raw keyframe eases isn't enough). Flag it so the collapsed row // single curve (drag readouts, lane hints, the inline ease button) then
// hides the button there and the user edits per-lane instead. // displayed one belonging to a different animation than the one an edit
if ( // targets. A collision means "no single ease", and dropping it is the only
existing.animationId !== undefined && // honest answer; collidingAnimationTargets still names every tween there.
kf.animationId !== undefined && if ((existing.collidingAnimationTargets?.length ?? 0) > 1) delete existing.ease;
existing.animationId !== kf.animationId
) {
existing.easeAmbiguous = true;
}
// Whichever tween iterated last used to win `ease`, so the merged
// keyframe carried an arbitrary one of the colliding curves. Readers that
// do not check easeAmbiguous (drag readouts, lane hints) then showed a
// curve belonging to a different animation than the one an edit targets.
// Drop it instead: ambiguous means "no single ease", and the flag is the
// only honest answer.
if (existing.easeAmbiguous) delete existing.ease;
else if (kf.ease) existing.ease = kf.ease; else if (kf.ease) existing.ease = kf.ease;
} else { } else {
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } }); byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
@@ -16,6 +16,7 @@ import {
deduplicateKeyframes, deduplicateKeyframes,
isStaticPositionHold, isStaticPositionHold,
synthesizeFlatTweenKeyframes, synthesizeFlatTweenKeyframes,
type MergeableKeyframe,
} from "./gsapTweenSynth"; } from "./gsapTweenSynth";
export { resolveSelectorElementIds }; export { resolveSelectorElementIds };
@@ -83,7 +84,7 @@ export async function populateKeyframeCacheFromAst(
const { setKeyframeCache } = usePlayerStore.getState(); const { setKeyframeCache } = usePlayerStore.getState();
clearKeyframeCacheForFile(sf); clearKeyframeCacheForFile(sf);
const { elements, domClipChildren } = usePlayerStore.getState(); const { elements, domClipChildren } = usePlayerStore.getState();
const mergedByElement = new Map<string, GsapKeyframesData>(); const mergedByElement = new Map<string, GsapKeyframesData<MergeableKeyframe>>();
const sourceByElement = new Map<string, GsapAnimation[]>(); const sourceByElement = new Map<string, GsapAnimation[]>();
for (const anim of parsed.animations) { for (const anim of parsed.animations) {
if (anim.hasUnresolvedKeyframes) continue; if (anim.hasUnresolvedKeyframes) continue;
@@ -12,6 +12,7 @@ import {
deduplicateKeyframes, deduplicateKeyframes,
isStaticPositionHold, isStaticPositionHold,
synthesizeFlatTweenKeyframes, synthesizeFlatTweenKeyframes,
type MergeableKeyframe,
} from "./gsapTweenSynth"; } from "./gsapTweenSynth";
import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad"; import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
@@ -266,13 +267,7 @@ export function useGsapAnimationsForElement(
domClipChildren, domClipChildren,
); );
const allKeyframes: Array< const allKeyframes: MergeableKeyframe[] = [];
GsapKeyframesData["keyframes"][0] & {
tweenPercentage?: number;
propertyGroup?: string;
animationId?: string;
}
> = [];
let format: GsapKeyframesData["format"] = "percentage"; let format: GsapKeyframesData["format"] = "percentage";
let ease: string | undefined; let ease: string | undefined;
let easeEach: string | undefined; let easeEach: string | undefined;
@@ -658,7 +658,18 @@ describe("TimelineClipDiamonds", () => {
<TimelineDiamondLane <TimelineDiamondLane
keyframesData={{ keyframesData={{
format: "percentage", format: "percentage",
keyframes: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })], keyframes: [
kf(0),
kf(50),
kf(100, {
collidingAnimationTargets: lastAmbiguous
? [
{ animationId: "anim-1", tweenPercentage: 100 },
{ animationId: "anim-2", tweenPercentage: 75 },
]
: undefined,
}),
],
}} }}
clipWidthPx={clipWidthPx} clipWidthPx={clipWidthPx}
clipHeightPx={48} clipHeightPx={48}
@@ -675,15 +686,16 @@ describe("TimelineClipDiamonds", () => {
return { host, root }; return { host, root };
}; };
it("hides the inline ease button on an ambiguous merged segment", () => { it("hides the inline ease button on a colliding merged segment", () => {
// Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous // The 50->100 segment ends on a keyframe shared by two animations, so one
// keyframe, so its hover/ease-button area is not rendered. // button cannot honestly stand for the several curves that meet there. Only
// the unambiguous 0->50 segment keeps its button.
const { host, root } = renderSegmentLane(true); const { host, root } = renderSegmentLane(true);
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1); expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
act(() => root.unmount()); act(() => root.unmount());
}); });
it("keeps the inline ease button on unambiguous merged segments", () => { it("shows the inline ease button on single-animation merged segments", () => {
const { host, root } = renderSegmentLane(false); const { host, root } = renderSegmentLane(false);
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2); expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
act(() => root.unmount()); act(() => root.unmount());
@@ -37,13 +37,6 @@ export function TimelineDiamondConnectors({
keyframeTarget: (keyframe: TimelineDiamondKeyframe) => TimelineKeyframeTarget; keyframeTarget: (keyframe: TimelineDiamondKeyframe) => TimelineKeyframeTarget;
onSelectSegment?: (target: TimelineKeyframeTarget) => void; onSelectSegment?: (target: TimelineKeyframeTarget) => void;
}) { }) {
// The ease button sits dead centre of its segment, which on a two-keyframe clip
// is the centre of the clip bar — the natural place to grab a clip and drag it.
// Swallowing pointerdown there made that grab a no-op. Instead the press falls
// through to the clip (so the drag starts normally) and the button keeps only
// the click, which we drop if the pointer actually travelled.
const pressXRef = useRef<number | null>(null);
return ( return (
<> <>
{markers.map((marker, i) => { {markers.map((marker, i) => {
@@ -55,19 +48,6 @@ export function TimelineDiamondConnectors({
if (x2 - x1 < 1) return null; if (x2 - x1 < 1) return null;
const connectorLeft = x1 + previous.visualSize / 2; const connectorLeft = x1 + previous.visualSize / 2;
const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.visualSize / 2; const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.visualSize / 2;
// The ease button targets one segment, so it needs the keyframe's own
// animationId/tweenPercentage. On a merged inline row the button is
// hidden where the segment is ambiguous (two source animations collide
// at this % with different eases; see easeAmbiguous) or the keyframe has
// no source animation id (runtime-scanned) so there is no tween to target.
const target = keyframeTarget(kf);
const ease = kf.ease ?? globalEase;
// connectorWidth is the clear span between the two diamonds' edges, so a
// 24x24 target centred in it overhangs a diamond as soon as the span is
// narrower than 24. The segment wrapper sits at z-index 3, above the
// diamonds, so that overhang would win the hit test and steal their
// clicks at fit zoom. Grow the target only where the room exists.
const roomForFullTarget = connectorWidth >= 24;
return ( return (
<Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}> <Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
<div <div
@@ -84,23 +64,87 @@ export function TimelineDiamondConnectors({
borderRadius: 1, borderRadius: 1,
}} }}
/> />
{onSelectSegment && !kf.easeAmbiguous && kf.animationId !== undefined && ( {onSelectSegment && showsEaseControl(kf) && (
<SegmentEaseControl
left={x1}
width={x2 - x1}
centerY={centerY}
ease={kf.ease ?? globalEase}
target={keyframeTarget(kf)}
// connectorWidth is the clear span between the two diamonds'
// edges, so a 24x24 target centred in it overhangs a diamond as
// soon as the span is narrower than 24. The segment wrapper sits
// at z-index 3, above the diamonds, so that overhang would win
// the hit test and steal their clicks at fit zoom. Grow the
// target only where the room exists.
roomForFullTarget={connectorWidth >= 24}
onSelectSegment={onSelectSegment}
/>
)}
</Fragment>
);
})}
</>
);
}
/**
* The ease control targets one segment, so it needs the keyframe's own
* animationId/tweenPercentage. On a merged inline row it is hidden where two
* source animations collide at this percentage (one button cannot honestly
* stand for several curves) or the keyframe has no source animation id
* (runtime-scanned) so there is no tween to target.
*/
function showsEaseControl(kf: TimelineDiamondKeyframe): boolean {
return (kf.collidingAnimationTargets?.length ?? 0) <= 1 && kf.animationId !== undefined;
}
/**
* The ease button centred on one connector segment, plus the transparent
* wrapper that positions it. Split out of the connector map so that map stays a
* geometry loop and this keeps the press guard, hit-target sizing and click
* filtering together.
*/
function SegmentEaseControl({
left,
width,
centerY,
ease,
target,
roomForFullTarget,
onSelectSegment,
}: {
left: number;
width: number;
centerY: number;
ease: string;
target: TimelineKeyframeTarget;
roomForFullTarget: boolean;
onSelectSegment: (target: TimelineKeyframeTarget) => void;
}) {
// The ease button sits dead centre of its segment, which on a two-keyframe clip
// is the centre of the clip bar, the natural place to grab a clip and drag it.
// Swallowing pointerdown there made that grab a no-op. Instead the press falls
// through to the clip (so the drag starts normally) and the button keeps only
// the click, which we drop if the pointer actually travelled.
const pressXRef = useRef<number | null>(null);
return (
<div <div
className="group absolute" className="group absolute"
data-keyframe-ease-segment="" data-keyframe-ease-segment=""
style={{ style={{
left: x1, left,
top: centerY, top: centerY,
width: x2 - x1, width,
height: 18, height: 18,
transform: "translateY(-50%)", transform: "translateY(-50%)",
// Own a stacking context above the diamond buttons. At fit // Own a stacking context above the diamond buttons. At fit zoom the 16px
// zoom the 16px ease control can overlap its neighbouring // ease control can overlap its neighbouring diamond; without a z-index
// diamond; without a z-index here the later diamond wins the // here the later diamond wins the hit test even though the child button
// hit test even though the child button has z-index 3. // has z-index 3.
zIndex: 3, zIndex: 3,
// Only the centered control is interactive. The transparent // Only the centered control is interactive. The transparent segment
// segment wrapper must not swallow connector/clip gestures. // wrapper must not swallow connector/clip gestures.
pointerEvents: "none", pointerEvents: "none",
}} }}
> >
@@ -109,13 +153,12 @@ export function TimelineDiamondConnectors({
data-keyframe-ease-button="" data-keyframe-ease-button=""
aria-label={`Edit ${ease} easing`} aria-label={`Edit ${ease} easing`}
title={`Edit ${ease} easing`} title={`Edit ${ease} easing`}
// A visible 24x24 badge would collide with the diamonds either // A visible 24x24 badge would collide with the diamonds either side, so
// side, so the WCAG 2.2 (2.5.8) target is met with a centered // the WCAG 2.2 (2.5.8) target is met with a centered transparent
// transparent ::before overlay; the box stays 16x16. Where the // ::before overlay; the box stays 16x16. Where the segment is too narrow
// segment is too narrow for that overlay the button keeps its // for that overlay the button keeps its 16x16 hit area, which is WCAG's
// 16x16 hit area, which is WCAG's target-spacing exception: // target-spacing exception: the neighbouring diamonds are themselves the
// the neighbouring diamonds are themselves the reason it // reason it cannot grow, and stealing their clicks is the worse failure.
// cannot grow, and stealing their clicks is the worse failure.
className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`} className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
style={{ style={{
left: "50%", left: "50%",
@@ -137,22 +180,12 @@ export function TimelineDiamondConnectors({
e.stopPropagation(); e.stopPropagation();
const pressX = pressXRef.current; const pressX = pressXRef.current;
pressXRef.current = null; pressXRef.current = null;
if ( if (pressX !== null && Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX) return;
pressX !== null &&
Math.abs(e.clientX - pressX) >= KEYFRAME_DRAG_THRESHOLD_PX
) {
return;
}
onSelectSegment(target); onSelectSegment(target);
}} }}
> >
<MiniCurveSvg ease={ease} active size={12} /> <MiniCurveSvg ease={ease} active size={12} />
</button> </button>
</div> </div>
)}
</Fragment>
);
})}
</>
); );
} }
@@ -4,6 +4,7 @@
* keyframe-identity helper live here; the rendering lives there. * keyframe-identity helper live here; the rendering lives there.
*/ */
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
export interface TimelineDiamondKeyframe { export interface TimelineDiamondKeyframe {
percentage: number; percentage: number;
@@ -13,9 +14,8 @@ export interface TimelineDiamondKeyframe {
animationId?: string; animationId?: string;
properties: Record<string, number | string>; properties: Record<string, number | string>;
ease?: string; ease?: string;
/** Set when 2+ source animations collide at this percentage (a single inline /** Source animation/keyframe targets that collide at this clip percentage. */
* ease button can't target one): the collapsed row hides the button here. */ collidingAnimationTargets?: AnimationKeyframeTarget[];
easeAmbiguous?: boolean;
} }
interface KeyframeCacheEntry { interface KeyframeCacheEntry {
@@ -116,5 +116,6 @@ export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyfr
tweenPercentage: keyframe.tweenPercentage, tweenPercentage: keyframe.tweenPercentage,
propertyGroup: keyframe.propertyGroup, propertyGroup: keyframe.propertyGroup,
animationId: keyframe.animationId, animationId: keyframe.animationId,
collidingAnimationTargets: keyframe.collidingAnimationTargets,
}; };
} }
@@ -1,8 +1,11 @@
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
export interface TimelineKeyframeTarget { export interface TimelineKeyframeTarget {
percentage: number; percentage: number;
tweenPercentage?: number; tweenPercentage?: number;
propertyGroup?: string; propertyGroup?: string;
animationId?: string; animationId?: string;
collidingAnimationTargets?: AnimationKeyframeTarget[];
} }
/** /**
@@ -36,72 +36,92 @@ const FLAT_TWEEN_TARGET: TimelineKeyframeTarget = {
animationId: "position-tween", animationId: "position-tween",
}; };
const COLLIDING_TARGET: TimelineKeyframeTarget = {
...FLAT_TWEEN_TARGET,
collidingAnimationTargets: [
{ animationId: "position-tween", tweenPercentage: 100 },
{ animationId: "scale-tween", tweenPercentage: 75 },
],
};
afterEach(() => { afterEach(() => {
document.body.innerHTML = ""; document.body.innerHTML = "";
trackStudioSegmentEaseEdit.mockClear(); trackStudioSegmentEaseEdit.mockClear();
usePlayerStore.setState({ focusedEaseSegment: null }); usePlayerStore.setState({ focusedEaseSegment: null });
}); });
describe("useTimelineKeyframeHandlers", () => { /**
it("tracks opening the segment ease editor when a timeline segment is selected", () => { * Mount the hook on its own and hand back the handlers it returned, with the
let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined; * options every test shares already filled in. Each test overrides only the
* inputs its assertion is about.
*/
function mountHandlers(options: Partial<Parameters<typeof useTimelineKeyframeHandlers>[0]> = {}) {
const handlers: Partial<ReturnType<typeof useTimelineKeyframeHandlers>> = {};
function Harness() { function Harness() {
({ onSelectSegment } = useTimelineKeyframeHandlers({ Object.assign(
handlers,
useTimelineKeyframeHandlers({
expandedElements: [ELEMENT], expandedElements: [ELEMENT],
keyframeCache: new Map(), keyframeCache: new Map(),
setSelectedElementId: vi.fn(), setSelectedElementId: vi.fn(),
setKfContextMenu: vi.fn(), setKfContextMenu: vi.fn(),
toggleSelectedKeyframe: vi.fn(), toggleSelectedKeyframe: vi.fn(),
})); ...options,
}),
);
return null; return null;
} }
const root = mountReactHarness(<Harness />); return { root: mountReactHarness(<Harness />), handlers };
act(() => onSelectSegment?.(ELEMENT.id, TARGET)); }
describe("useTimelineKeyframeHandlers", () => {
it("tracks opening the segment ease editor when a timeline segment is selected", () => {
const { root, handlers } = mountHandlers();
act(() => handlers.onSelectSegment?.(ELEMENT.id, TARGET));
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledOnce(); expect(trackStudioSegmentEaseEdit).toHaveBeenCalledOnce();
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "open" }); expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "open" });
act(() => root.unmount()); act(() => root.unmount());
}); });
it("focuses a merged segment with its colliding animation targets", () => {
const { root, handlers } = mountHandlers();
act(() => handlers.onSelectSegment?.(ELEMENT.id, COLLIDING_TARGET));
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
animationId: "position-tween",
collidingAnimationTargets: [
{ animationId: "position-tween", tweenPercentage: 100 },
{ animationId: "scale-tween", tweenPercentage: 75 },
],
tweenPercentage: 100,
elementId: ELEMENT.id,
});
act(() => root.unmount());
});
it("focuses a flat tween segment without seeking, while keyframe clicks still seek", () => { it("focuses a flat tween segment without seeking, while keyframe clicks still seek", () => {
const onSeek = vi.fn(); const onSeek = vi.fn();
const onSelectElement = vi.fn(); const onSelectElement = vi.fn();
const setSelectedElementId = vi.fn(); const setSelectedElementId = vi.fn();
let onClickKeyframe: const { root, handlers } = mountHandlers({ onSelectElement, onSeek, setSelectedElementId });
| ((el: TimelineElement, target: TimelineKeyframeTarget) => void)
| undefined;
let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
function Harness() {
({ onClickKeyframe, onSelectSegment } = useTimelineKeyframeHandlers({
expandedElements: [ELEMENT],
keyframeCache: new Map(),
onSelectElement,
onSeek,
setSelectedElementId,
setKfContextMenu: vi.fn(),
toggleSelectedKeyframe: vi.fn(),
}));
return null;
}
const root = mountReactHarness(<Harness />);
// Selecting a segment must NOT move the playhead. // Selecting a segment must NOT move the playhead.
act(() => onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET)); act(() => handlers.onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
expect(onSeek).not.toHaveBeenCalled(); expect(onSeek).not.toHaveBeenCalled();
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({ expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
animationId: "position-tween", animationId: "position-tween",
tweenPercentage: 100, tweenPercentage: 100,
elementId: ELEMENT.id, elementId: ELEMENT.id,
}); });
expect(usePlayerStore.getState().focusedEaseSegment?.collidingAnimationTargets).toBeUndefined();
expect(setSelectedElementId).toHaveBeenCalledWith(ELEMENT.id); expect(setSelectedElementId).toHaveBeenCalledWith(ELEMENT.id);
expect(onSelectElement).toHaveBeenCalledWith(ELEMENT); expect(onSelectElement).toHaveBeenCalledWith(ELEMENT);
// Clicking the keyframe itself still seeks to it (start 1 + 50% of 2 = 2). // Clicking the keyframe itself still seeks to it (start 1 + 50% of 2 = 2).
act(() => onClickKeyframe?.(ELEMENT, TARGET)); act(() => handlers.onClickKeyframe?.(ELEMENT, TARGET));
expect(onSeek).toHaveBeenCalledExactlyOnceWith(2); expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
act(() => root.unmount()); act(() => root.unmount());
}); });
@@ -65,6 +65,7 @@ export function useTimelineKeyframeHandlers({
if (target.animationId !== undefined && target.tweenPercentage !== undefined) { if (target.animationId !== undefined && target.tweenPercentage !== undefined) {
usePlayerStore.getState().setFocusedEaseSegment({ usePlayerStore.getState().setFocusedEaseSegment({
animationId: target.animationId, animationId: target.animationId,
collidingAnimationTargets: target.collidingAnimationTargets,
tweenPercentage: target.tweenPercentage, tweenPercentage: target.tweenPercentage,
elementId: elId, elementId: elId,
}); });
@@ -1,5 +1,6 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { StoreApi } from "zustand"; import type { StoreApi } from "zustand";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */ /** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
export interface KeyframeCacheEntry { export interface KeyframeCacheEntry {
@@ -14,9 +15,8 @@ export interface KeyframeCacheEntry {
animationId?: string; animationId?: string;
properties: Record<string, number | string>; properties: Record<string, number | string>;
ease?: string; ease?: string;
/** Set when 2+ source animations collide at this percentage (a single inline /** Source animation/keyframe targets that collide at this clip percentage. */
* ease button can't target one): the collapsed row hides the button here. */ collidingAnimationTargets?: AnimationKeyframeTarget[];
easeAmbiguous?: boolean;
}>; }>;
ease?: string; ease?: string;
easeEach?: string; easeEach?: string;
@@ -37,9 +37,19 @@ export interface KeyframeSlice {
/** elementId scopes the request to one element so a shared (class-selector) /** elementId scopes the request to one element so a shared (class-selector)
* animation id can't open the ease editor on the wrong element. */ * animation id can't open the ease editor on the wrong element. */
focusedEaseSegment: { animationId: string; tweenPercentage: number; elementId: string } | null; focusedEaseSegment: {
animationId: string;
collidingAnimationTargets?: AnimationKeyframeTarget[];
tweenPercentage: number;
elementId: string;
} | null;
setFocusedEaseSegment: ( setFocusedEaseSegment: (
target: { animationId: string; tweenPercentage: number; elementId: string } | null, target: {
animationId: string;
collidingAnimationTargets?: AnimationKeyframeTarget[];
tweenPercentage: number;
elementId: string;
} | null,
) => void; ) => void;
/** Keyframe data per element id, populated from parsed GSAP animations. */ /** Keyframe data per element id, populated from parsed GSAP animations. */