From 3f93794fa669575985747f129fbfa3b6021cf786 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 26 Jul 2026 00:06:20 +0200 Subject: [PATCH] fix(studio): resolve lane-header removal against the clicked element The lane-header keyframe toggle fires on whichever element owns the lane, which need not be the selected one. The remove path looked the animation up in the selected element's animations, so a non-selected element's flat tween missed and silently took the remove-one-keyframe branch, stranding the tween instead of deleting it. --- .../nle/useTimelineEditCallbacks.test.tsx | 35 +++++++++++++++++++ .../nle/useTimelineEditCallbacks.ts | 5 ++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx index a06b18ece..cf240afa9 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx @@ -330,6 +330,41 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => { view.unmount(); }); + // The lane-header toggle fires on whichever element owns the lane, which need + // not be the selected one. Looking the flat tween up in the selected element's + // animations misses, and the miss silently takes the remove-one-keyframe + // branch, which strands the flat tween instead of deleting it. + it("removes a non-selected element's flat tween through that element's own animations", async () => { + const circle: TimelineElement = { + ...element, + id: "circle", + key: "scenes/main.html#circle", + domId: "circle", + }; + usePlayerStore.setState({ + elements: [element, circle], + gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]), + }); + const view = renderCallbacks(); + + await act(async () => { + await view.callbacks.onTogglePropertyGroupKeyframe?.(circle, { + animationId: otherFlatAnimation.id, + propertyGroup: "position", + tweenPercentage: 0, + properties: { x: 0 }, + remove: true, + }); + }); + + expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith( + otherFlatAnimation.id, + mocks.selection, + ); + expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled(); + view.unmount(); + }); + it("keeps authored interior deletion on the per-keyframe path", () => { mocks.animations = [authoredInteriorAnimation()]; usePlayerStore.setState({ gsapAnimations: new Map([["box", mocks.animations]]) }); diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts index e72d4bc7b..0ac9a3274 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -324,10 +324,13 @@ export function useTimelineEditCallbacks({ const selection = await buildDomSelectionForTimelineElement(element); if (!selection) return; if (target.remove) { + // The clicked element's animations, not the selected element's: this + // lookup decides delete-the-flat-tween vs remove-one-keyframe, and a + // miss silently takes the keyframe branch, stranding the flat tween. removeKeyframeTarget( target.animationId, target.tweenPercentage, - selectedGsapAnimations, + resolveElementAnimations(element.key ?? element.id), selection, ); return;