diff --git a/packages/parsers/src/gsapParserExports.ts b/packages/parsers/src/gsapParserExports.ts index 5917d13de..d2def7337 100644 --- a/packages/parsers/src/gsapParserExports.ts +++ b/packages/parsers/src/gsapParserExports.ts @@ -14,6 +14,7 @@ export type { GsapMethod, GsapKeyframesData, GsapPercentageKeyframe, + SourcedGsapPercentageKeyframe, ParsedGsap, ArcPathConfig, ArcPathSegment, diff --git a/packages/parsers/src/gsapSerialize.ts b/packages/parsers/src/gsapSerialize.ts index 7ccc62db4..b90136685 100644 --- a/packages/parsers/src/gsapSerialize.ts +++ b/packages/parsers/src/gsapSerialize.ts @@ -94,6 +94,20 @@ export interface WritableGsapPercentageKeyframe extends GsapPercentageKeyframe { 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. * 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 interface GsapKeyframesData { +export interface GsapKeyframesData { format: GsapKeyframeFormat; - keyframes: GsapPercentageKeyframe[]; + keyframes: K[]; ease?: string; easeEach?: string; } diff --git a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts index 2434f6c87..9502479b5 100644 --- a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts +++ b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts @@ -155,6 +155,44 @@ describe("pruneKeyframeCacheToFiles", () => { }); describe("updateKeyframeCacheFromParsed", () => { + it("records colliding animation targets with their own tween percentages", () => { + const animation = ( + id: string, + propertyGroup: string, + properties: Record, + 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", () => { const animation: GsapAnimation = { ...animWithKeyframes("hero"), diff --git a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts index a2ed338cb..788a3dfc1 100644 --- a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts +++ b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts @@ -5,7 +5,11 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore"; import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared"; -import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth"; +import { + deduplicateKeyframes, + synthesizeFlatTweenKeyframes, + type MergeableKeyframe, +} from "./gsapTweenSynth"; export function updateKeyframeCacheFromParsed( animations: GsapAnimation[], @@ -16,7 +20,11 @@ export function updateKeyframeCacheFromParsed( ): void { const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState(); const idsWithKeyframes = new Set(); - const merged = new Map(); + // 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(); const sourceAnimations = new Map(); for (const anim of animations) { const kfSource = @@ -49,9 +57,9 @@ export function updateKeyframeCacheFromParsed( const existing = merged.get(id); if (existing) { - // deduplicateKeyframes owns the same-% merge (including the easeAmbiguous - // flag downstream lanes read); a second copy of that rule here is how the - // two writers drift. + // deduplicateKeyframes owns the same-% merge (including the colliding + // animation targets downstream lanes read); a second copy of that rule + // here is how the two writers drift. existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]); } else { merged.set(id, { diff --git a/packages/studio/src/hooks/gsapTweenSynth.test.ts b/packages/studio/src/hooks/gsapTweenSynth.test.ts index b5474cf1d..e4b58b4be 100644 --- a/packages/studio/src/hooks/gsapTweenSynth.test.ts +++ b/packages/studio/src/hooks/gsapTweenSynth.test.ts @@ -54,31 +54,86 @@ describe("synthesizeFlatTweenKeyframes", () => { }); }); -describe("deduplicateKeyframes ease ambiguity", () => { - it("flags a same-% collision from different animations (different eases)", () => { +describe("deduplicateKeyframes colliding animation targets", () => { + it("records each animation's tween percentage in first-seen order", () => { 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); - 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", () => { - // The button can still only target one arbitrary animation, and each may - // inherit a different easeEach/animation ease that raw comparison misses. + it("deduplicates three colliding animations while preserving first-seen order", () => { 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([ - { 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(); }); }); diff --git a/packages/studio/src/hooks/gsapTweenSynth.ts b/packages/studio/src/hooks/gsapTweenSynth.ts index a3b100be4..7009ae7f4 100644 --- a/packages/studio/src/hooks/gsapTweenSynth.ts +++ b/packages/studio/src/hooks/gsapTweenSynth.ts @@ -1,7 +1,7 @@ import type { GsapAnimation, GsapKeyframesData, - GsapPercentageKeyframe, + SourcedGsapPercentageKeyframe, } from "@hyperframes/core/gsap-parser"; 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"); } -export function deduplicateKeyframes< - T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean }, ->(keyframes: T[]): T[] { +export interface AnimationKeyframeTarget { + animationId: string; + 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(keyframes: T[]): T[] { const byPct = new Map(); for (const kf of keyframes) { const existing = byPct.get(kf.percentage); if (existing) { existing.properties = { ...existing.properties, ...kf.properties }; - // Two DIFFERENT source animations with a keyframe at the same clip %: a - // single inline ease button can only target one of them, and which one is - // arbitrary (each may also inherit a different easeEach/animation ease, so - // comparing raw keyframe eases isn't enough). Flag it so the collapsed row - // hides the button there and the user edits per-lane instead. - if ( - existing.animationId !== undefined && - kf.animationId !== undefined && - 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; + accumulateCollidingAnimationTargets(existing, kf); + // Whichever tween iterated last used to win `ease`, so the merged keyframe + // carried an arbitrary one of the colliding curves. Readers that show a + // single curve (drag readouts, lane hints, the inline ease button) then + // displayed one belonging to a different animation than the one an edit + // targets. A collision means "no single ease", and dropping it is the only + // honest answer; collidingAnimationTargets still names every tween there. + if ((existing.collidingAnimationTargets?.length ?? 0) > 1) delete existing.ease; else if (kf.ease) existing.ease = kf.ease; } else { byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } }); diff --git a/packages/studio/src/hooks/keyframeCacheAstLoad.ts b/packages/studio/src/hooks/keyframeCacheAstLoad.ts index 9ae59b3d5..9e20c6ec1 100644 --- a/packages/studio/src/hooks/keyframeCacheAstLoad.ts +++ b/packages/studio/src/hooks/keyframeCacheAstLoad.ts @@ -16,6 +16,7 @@ import { deduplicateKeyframes, isStaticPositionHold, synthesizeFlatTweenKeyframes, + type MergeableKeyframe, } from "./gsapTweenSynth"; export { resolveSelectorElementIds }; @@ -83,7 +84,7 @@ export async function populateKeyframeCacheFromAst( const { setKeyframeCache } = usePlayerStore.getState(); clearKeyframeCacheForFile(sf); const { elements, domClipChildren } = usePlayerStore.getState(); - const mergedByElement = new Map(); + const mergedByElement = new Map>(); const sourceByElement = new Map(); for (const anim of parsed.animations) { if (anim.hasUnresolvedKeyframes) continue; diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 23a8ea728..3c693bf9b 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -12,6 +12,7 @@ import { deduplicateKeyframes, isStaticPositionHold, synthesizeFlatTweenKeyframes, + type MergeableKeyframe, } from "./gsapTweenSynth"; import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad"; @@ -266,13 +267,7 @@ export function useGsapAnimationsForElement( domClipChildren, ); - const allKeyframes: Array< - GsapKeyframesData["keyframes"][0] & { - tweenPercentage?: number; - propertyGroup?: string; - animationId?: string; - } - > = []; + const allKeyframes: MergeableKeyframe[] = []; let format: GsapKeyframesData["format"] = "percentage"; let ease: string | undefined; let easeEach: string | undefined; diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx index fc00b55c0..9c56a3dd4 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx @@ -658,7 +658,18 @@ describe("TimelineClipDiamonds", () => { { return { host, root }; }; - it("hides the inline ease button on an ambiguous merged segment", () => { - // Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous - // keyframe, so its hover/ease-button area is not rendered. + it("hides the inline ease button on a colliding merged segment", () => { + // The 50->100 segment ends on a keyframe shared by two animations, so one + // 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); expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1); 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); expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2); act(() => root.unmount()); diff --git a/packages/studio/src/player/components/TimelineDiamondConnectors.tsx b/packages/studio/src/player/components/TimelineDiamondConnectors.tsx index 4b155ef71..9bff0a629 100644 --- a/packages/studio/src/player/components/TimelineDiamondConnectors.tsx +++ b/packages/studio/src/player/components/TimelineDiamondConnectors.tsx @@ -37,13 +37,6 @@ export function TimelineDiamondConnectors({ keyframeTarget: (keyframe: TimelineDiamondKeyframe) => TimelineKeyframeTarget; 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(null); - return ( <> {markers.map((marker, i) => { @@ -55,19 +48,6 @@ export function TimelineDiamondConnectors({ if (x2 - x1 < 1) return null; const connectorLeft = x1 + previous.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 (
- {onSelectSegment && !kf.easeAmbiguous && kf.animationId !== undefined && ( -
- -
+ {onSelectSegment && showsEaseControl(kf) && ( + = 24} + onSelectSegment={onSelectSegment} + /> )} ); @@ -156,3 +87,105 @@ export function TimelineDiamondConnectors({ ); } + +/** + * 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(null); + return ( +
+ +
+ ); +} diff --git a/packages/studio/src/player/components/timelineDiamondTypes.ts b/packages/studio/src/player/components/timelineDiamondTypes.ts index d5541a418..0e3c34bc0 100644 --- a/packages/studio/src/player/components/timelineDiamondTypes.ts +++ b/packages/studio/src/player/components/timelineDiamondTypes.ts @@ -4,6 +4,7 @@ * keyframe-identity helper live here; the rendering lives there. */ import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; +import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; export interface TimelineDiamondKeyframe { percentage: number; @@ -13,9 +14,8 @@ export interface TimelineDiamondKeyframe { animationId?: string; properties: Record; ease?: string; - /** Set when 2+ source animations collide at this percentage (a single inline - * ease button can't target one): the collapsed row hides the button here. */ - easeAmbiguous?: boolean; + /** Source animation/keyframe targets that collide at this clip percentage. */ + collidingAnimationTargets?: AnimationKeyframeTarget[]; } interface KeyframeCacheEntry { @@ -116,5 +116,6 @@ export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyfr tweenPercentage: keyframe.tweenPercentage, propertyGroup: keyframe.propertyGroup, animationId: keyframe.animationId, + collidingAnimationTargets: keyframe.collidingAnimationTargets, }; } diff --git a/packages/studio/src/player/components/timelineKeyframeIdentity.ts b/packages/studio/src/player/components/timelineKeyframeIdentity.ts index e016fe39b..07ca0fdff 100644 --- a/packages/studio/src/player/components/timelineKeyframeIdentity.ts +++ b/packages/studio/src/player/components/timelineKeyframeIdentity.ts @@ -1,8 +1,11 @@ +import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; + export interface TimelineKeyframeTarget { percentage: number; tweenPercentage?: number; propertyGroup?: string; animationId?: string; + collidingAnimationTargets?: AnimationKeyframeTarget[]; } /** diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx index 0c10202db..f70425303 100644 --- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx +++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx @@ -36,72 +36,92 @@ const FLAT_TWEEN_TARGET: TimelineKeyframeTarget = { animationId: "position-tween", }; +const COLLIDING_TARGET: TimelineKeyframeTarget = { + ...FLAT_TWEEN_TARGET, + collidingAnimationTargets: [ + { animationId: "position-tween", tweenPercentage: 100 }, + { animationId: "scale-tween", tweenPercentage: 75 }, + ], +}; + afterEach(() => { document.body.innerHTML = ""; trackStudioSegmentEaseEdit.mockClear(); usePlayerStore.setState({ focusedEaseSegment: null }); }); -describe("useTimelineKeyframeHandlers", () => { - it("tracks opening the segment ease editor when a timeline segment is selected", () => { - let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined; +/** + * Mount the hook on its own and hand back the handlers it returned, with the + * options every test shares already filled in. Each test overrides only the + * inputs its assertion is about. + */ +function mountHandlers(options: Partial[0]> = {}) { + const handlers: Partial> = {}; - function Harness() { - ({ onSelectSegment } = useTimelineKeyframeHandlers({ + function Harness() { + Object.assign( + handlers, + useTimelineKeyframeHandlers({ expandedElements: [ELEMENT], keyframeCache: new Map(), setSelectedElementId: vi.fn(), setKfContextMenu: vi.fn(), toggleSelectedKeyframe: vi.fn(), - })); - return null; - } + ...options, + }), + ); + return null; + } - const root = mountReactHarness(); - act(() => onSelectSegment?.(ELEMENT.id, TARGET)); + return { root: mountReactHarness(), handlers }; +} + +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).toHaveBeenCalledWith({ action: "open" }); 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", () => { const onSeek = vi.fn(); const onSelectElement = vi.fn(); const setSelectedElementId = vi.fn(); - let onClickKeyframe: - | ((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(); + const { root, handlers } = mountHandlers({ onSelectElement, onSeek, setSelectedElementId }); // 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(usePlayerStore.getState().focusedEaseSegment).toEqual({ animationId: "position-tween", tweenPercentage: 100, elementId: ELEMENT.id, }); + expect(usePlayerStore.getState().focusedEaseSegment?.collidingAnimationTargets).toBeUndefined(); expect(setSelectedElementId).toHaveBeenCalledWith(ELEMENT.id); expect(onSelectElement).toHaveBeenCalledWith(ELEMENT); // 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); act(() => root.unmount()); }); diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts index 2d18bace1..657aac930 100644 --- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts +++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts @@ -65,6 +65,7 @@ export function useTimelineKeyframeHandlers({ if (target.animationId !== undefined && target.tweenPercentage !== undefined) { usePlayerStore.getState().setFocusedEaseSegment({ animationId: target.animationId, + collidingAnimationTargets: target.collidingAnimationTargets, tweenPercentage: target.tweenPercentage, elementId: elId, }); diff --git a/packages/studio/src/player/store/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts index 7a97d986c..9ee447e62 100644 --- a/packages/studio/src/player/store/keyframeSlice.ts +++ b/packages/studio/src/player/store/keyframeSlice.ts @@ -1,5 +1,6 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { StoreApi } from "zustand"; +import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth"; /** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */ export interface KeyframeCacheEntry { @@ -14,9 +15,8 @@ export interface KeyframeCacheEntry { animationId?: string; properties: Record; ease?: string; - /** Set when 2+ source animations collide at this percentage (a single inline - * ease button can't target one): the collapsed row hides the button here. */ - easeAmbiguous?: boolean; + /** Source animation/keyframe targets that collide at this clip percentage. */ + collidingAnimationTargets?: AnimationKeyframeTarget[]; }>; ease?: string; easeEach?: string; @@ -37,9 +37,19 @@ export interface KeyframeSlice { /** elementId scopes the request to one element so a shared (class-selector) * 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: ( - target: { animationId: string; tweenPercentage: number; elementId: string } | null, + target: { + animationId: string; + collidingAnimationTargets?: AnimationKeyframeTarget[]; + tweenPercentage: number; + elementId: string; + } | null, ) => void; /** Keyframe data per element id, populated from parsed GSAP animations. */