11import type {
22 GsapAnimation ,
33 GsapKeyframesData ,
4- GsapPercentageKeyframe ,
4+ SourcedGsapPercentageKeyframe ,
55} from "@hyperframes/core/gsap-parser" ;
66import { PROPERTY_DEFAULTS } from "./gsapShared" ;
77
@@ -22,33 +22,60 @@ export function isStaticPositionHold(anim: GsapAnimation): boolean {
2222 return propKeys . length > 0 && propKeys . every ( ( k ) => k === "x" || k === "y" ) ;
2323}
2424
25- export function deduplicateKeyframes <
26- T extends GsapPercentageKeyframe & { animationId ?: string ; easeAmbiguous ?: boolean } ,
27- > ( keyframes : T [ ] ) : T [ ] {
25+ export interface AnimationKeyframeTarget {
26+ animationId : string ;
27+ tweenPercentage : number ;
28+ }
29+
30+ function accumulateCollidingAnimationTargets (
31+ keyframe : AnimationKeyframeTarget & {
32+ collidingAnimationTargets ?: AnimationKeyframeTarget [ ] ;
33+ } ,
34+ incoming : AnimationKeyframeTarget ,
35+ ) : void {
36+ const primaryId = keyframe . animationId ;
37+ // One tween meeting itself is not a collision. Both identity fields are
38+ // required by the parameter types rather than guarded at runtime: a keyframe
39+ // that arrives without them cannot be attributed to a tween at all, and an
40+ // early return here would silently record no collision and let the inline
41+ // ease button edit an arbitrary one of the tweens that met at this
42+ // percentage. The compiler now refuses the incomplete keyframe instead.
43+ if ( primaryId === incoming . animationId ) return ;
44+ const collisionTargets = keyframe . collidingAnimationTargets ;
45+ if ( collisionTargets ?. some ( ( target ) => target . animationId === incoming . animationId ) ) return ;
46+ keyframe . collidingAnimationTargets = [
47+ ...( collisionTargets === undefined || collisionTargets . length === 0
48+ ? [ { animationId : primaryId , tweenPercentage : keyframe . tweenPercentage } ]
49+ : collisionTargets ) ,
50+ { animationId : incoming . animationId , tweenPercentage : incoming . tweenPercentage } ,
51+ ] ;
52+ }
53+
54+ /**
55+ * What a keyframe looks like once it has been attributed to its source tween
56+ * and is ready to be merged with the other tweens landing on the same row. The
57+ * runtime scan produces unattributed keyframes and they never reach a merge, so
58+ * they are deliberately not this type.
59+ */
60+ export type MergeableKeyframe = SourcedGsapPercentageKeyframe & {
61+ propertyGroup ?: string ;
62+ collidingAnimationTargets ?: AnimationKeyframeTarget [ ] ;
63+ } ;
64+
65+ export function deduplicateKeyframes < T extends MergeableKeyframe > ( keyframes : T [ ] ) : T [ ] {
2866 const byPct = new Map < number , T > ( ) ;
2967 for ( const kf of keyframes ) {
3068 const existing = byPct . get ( kf . percentage ) ;
3169 if ( existing ) {
3270 existing . properties = { ...existing . properties , ...kf . properties } ;
33- // Two DIFFERENT source animations with a keyframe at the same clip %: a
34- // single inline ease button can only target one of them, and which one is
35- // arbitrary (each may also inherit a different easeEach/animation ease, so
36- // comparing raw keyframe eases isn't enough). Flag it so the collapsed row
37- // hides the button there and the user edits per-lane instead.
38- if (
39- existing . animationId !== undefined &&
40- kf . animationId !== undefined &&
41- existing . animationId !== kf . animationId
42- ) {
43- existing . easeAmbiguous = true ;
44- }
45- // Whichever tween iterated last used to win `ease`, so the merged
46- // keyframe carried an arbitrary one of the colliding curves. Readers that
47- // do not check easeAmbiguous (drag readouts, lane hints) then showed a
48- // curve belonging to a different animation than the one an edit targets.
49- // Drop it instead: ambiguous means "no single ease", and the flag is the
50- // only honest answer.
51- if ( existing . easeAmbiguous ) delete existing . ease ;
71+ accumulateCollidingAnimationTargets ( existing , kf ) ;
72+ // Whichever tween iterated last used to win `ease`, so the merged keyframe
73+ // carried an arbitrary one of the colliding curves. Readers that show a
74+ // single curve (drag readouts, lane hints, the inline ease button) then
75+ // displayed one belonging to a different animation than the one an edit
76+ // targets. A collision means "no single ease", and dropping it is the only
77+ // honest answer; collidingAnimationTargets still names every tween there.
78+ if ( ( existing . collidingAnimationTargets ?. length ?? 0 ) > 1 ) delete existing . ease ;
5279 else if ( kf . ease ) existing . ease = kf . ease ;
5380 } else {
5481 byPct . set ( kf . percentage , { ...kf , properties : { ...kf . properties } } ) ;
0 commit comments